UNPKG

wingbot

Version:

Enterprise Messaging Bot Conversation Engine

1,766 lines (1,577 loc) 50.3 kB
/* * @author David Menger */ 'use strict'; const Ai = require('./Ai'); const ReceiptTemplate = require('./templates/ReceiptTemplate'); const ButtonTemplate = require('./templates/ButtonTemplate'); const GenericTemplate = require('./templates/GenericTemplate'); const ListTemplate = require('./templates/ListTemplate'); const { makeAbsolute, makeQuickReplies, tokenize } = require('./utils'); const { ResponseFlag } = require('./analytics/consts'); const { checkSetState } = require('./utils/stateVariables'); const { FEATURE_VOICE, FEATURE_SSML, FEATURE_PHRASES } = require('./features'); const transcriptFromHistory = require('./transcript/transcriptFromHistory'); const LLM = require('./LLM'); const LLMSession = require('./LLMSession'); const TYPE_RESPONSE = 'RESPONSE'; const TYPE_UPDATE = 'UPDATE'; const TYPE_MESSAGE_TAG = 'MESSAGE_TAG'; const EXCEPTION_HOPCOUNT_THRESHOLD = 5; /** @typedef {import('./Request')} Request */ /** @typedef {import('./ReturnSender').UploadResult} UploadResult */ /** @typedef {import('./ReturnSender').SendOptions} SendOptions */ /** @typedef {import('./ReturnSender').TextFilter} TextFilter */ /** @typedef {import('./analytics/consts').TrackingCategory} TrackingCategory */ /** @typedef {import('./transcript/transcriptFromHistory').Transcript} Transcript */ /** @typedef {import('./analytics/consts').TrackingType} TrackingType */ /** @typedef {import('./LLM').LLMConfiguration} LLMConfiguration */ /** @typedef {import('./LLM').PreprocessedRule} PreprocessedRule */ /** @typedef {import('./LLM').EvaluationRuleAction} EvaluationRuleAction */ /** @typedef {import('./LLM').EvaluationResult} EvaluationResult */ /** @typedef {import('./LLMSession').LLMMessage} LLMMessage */ /** @typedef {import('./LLMSession').LLMFilterFn} LLMFilterFn */ /** @typedef {import('./LLMSession').LLMFilter} LLMFilter */ /** @typedef {import('./LLMSession').FilterScope} FilterScope */ /** @typedef {import('./utils/stateData').IStateRequest} IStateRequest */ /** * @enum {string} ExpectedInput * @readonly */ const ExpectedInput = { TYPE_PASSWORD: 'password', TYPE_NONE: 'none', TYPE_UPLOAD: 'upload', TYPE_WEBVIEW: 'webview' }; Object.freeze(ExpectedInput); /** * @typedef {object} ExpectedInputOptions * @prop {string} [url] * @prop {string} [webview_height_ratio] * @prop {string} [onCloseAction] * @prop {string} [onCloseActionData] */ /** * @typedef {object} QuickReply * @prop {string} title * @prop {string} [action] * @prop {object} [data] * @prop {object} [setState] * @prop {string|Function} [aiTitle] * @prop {RegExp|string|string[]} [match] */ /** * @typedef {object} SenderMeta * @prop {ResponseFlag|null} flag * @prop {string} [likelyIntent] * @prop {string} [disambText] * @prop {string[]} [disambiguationIntents] */ /** * @typedef {object} VoiceControl * @prop {string} [ssml] * @prop {number} [speed] * @prop {number} [pitch] * @prop {number} [volume] * @prop {string} [style] * @prop {string} [language] * @prop {string} [voice] * @prop {number} [timeout] * @prop {number} [minTimeout] * @prop {number} [endTimeout] * @prop {string} [recognitionLanguage] * @prop {string} [recognitionEngine] */ /** * @callback VoiceControlFactory * @param {object} state * @returns {VoiceControl} */ /** * @typedef {object} Persona * @prop {string} [profile_pic_url] * @prop {string} [name] */ /** * @callback PromptGetter * @param {Responder} res * @returns {string|Promise<string>} */ /** * @typedef {PromptGetter|string} PromptSource */ const PERSONA_DEFAULT = '_default'; /** * Instance of responder is passed as second parameter of handler (res) * * @class */ class Responder { constructor ( senderId, messageSender, token = null, options = {}, data = {}, configuration = {}, senderMeta = null, llm = null ) { this._messageSender = messageSender; this._senderId = senderId; this._pageId = options.pageId; this.token = token; this._configuration = configuration; /** * The empty object, which is filled with res.setState() method * and saved (with Object.assign) at the end of event processing * into the conversation state. * * @prop {object} */ this.newState = {}; this.path = ''; this.routePath = ''; this._bookmark = null; this.options = { state: Object.freeze({}), translator: (w) => w, appUrl: '' }; /** * @prop {Object<keyof ExpectedInput,ExpectedInput>} */ this.ExpectedInputTypes = ExpectedInput; Object.assign(this.options, options); if (this.options.autoTyping) { this.options.autoTyping = { time: 750, perCharacters: 'Sample text Sample texts Sample texts'.length, minTime: 550, maxTime: 1750, ...this.options.autoTyping }; } this._t = this.options.translator; this._features = this.options.features || []; this._quickReplyCollector = []; this._data = data; this._messagingType = TYPE_RESPONSE; this._tag = null; this._firstTypingSkipped = false; /** * Run a code block defined by a plugin * * @prop {Function} * @param {string} blockName * @returns {Promise} */ this.run = (blockName) => Promise.resolve(blockName && undefined); /** * Is true, when a final message (the quick replies by default) has been sent * * @prop {boolean} */ this.finalMessageSent = false; /** * Is true, when a an output started during the event dispatch * * @prop {boolean} */ this.startedOutput = false; /** * @type {VoiceControl|VoiceControlFactory} */ this.voiceControl = null; this._trackAsAction = null; // both vars are package protected this._senderMeta = senderMeta || { flag: null }; this._persona = null; this._recipient = { id: senderId }; this._textResponses = []; this._typingSent = false; /** @type {SendOptions} */ this._nextMessageSendOptions = null; /** @type {LLM} */ this.llm = llm; this.LLM_CTX_DEFAULT = 'default'; /** @typedef {PromptSource|Promise<string>} PromptContextItem */ /** @type {Map<string,PromptContextItem[]>} */ this._llmContext = new Map([ [this.LLM_CTX_DEFAULT, []] ]); /** @type {Map<string,PreprocessedRule[]>} */ this._llmResultRules = new Map([ [this.LLM_CTX_DEFAULT, []] ]); /** @type {Map<string,LLMFilter[]>} */ this._llmFilters = new Map([ [this.LLM_CTX_DEFAULT, []], [null, []] ]); } /** * * @deprecated use llmAddInstructions() instead * @param {PromptSource} systemPrompt * @param {string} [contextType] * @returns {this} */ llmAddSystemPrompt (systemPrompt, contextType) { return this.llmAddInstructions(systemPrompt, contextType); } /** * * @param {PromptSource} systemPrompt * @param {string} contextType * @returns {this} */ llmAddInstructions (systemPrompt, contextType = this.LLM_CTX_DEFAULT) { if (!systemPrompt) { return this; } if (!this._llmContext.has(contextType)) { // @todo make it array of messages / maybe keep it in a single array this._llmContext.set(contextType, []); } this._llmContext.get(contextType).push(systemPrompt); return this; } /** * * @param {LLMFilter|LLMFilterFn} filter * @param {FilterScope} [scope] * @param {string} [contextType] * @returns {this} */ llmAddFilter ( filter, scope = LLM.FILTER_SCOPE_CONVERSATION, contextType = null ) { /** @type {LLMFilter} */ const addFilter = typeof filter === 'function' ? { filter, scope } : filter; if (!this._llmFilters.has(contextType)) { this._llmFilters.set(contextType, []); } this._llmFilters.get(contextType).push(addFilter); return this; } /** * * @param {string[]|PreprocessedRule} rule * @param {EvaluationRuleAction} [action] * @param {object} [setState] * @param {string} [contextType] * @returns {this} */ llmAddResultRule ( rule, action = null, setState = null, contextType = this.LLM_CTX_DEFAULT ) { let addRule = rule; if (Array.isArray(addRule)) { [addRule] = LLM.preprocessEvaluationRules([{ // @ts-ignore aiTags: rule, action, setState }], { ai: this.llm.ai }); } if (!this._llmResultRules.has(contextType)) { this._llmResultRules.set(contextType, []); } this._llmResultRules.get(contextType).push(addRule); return this; } async llmSession (contextType = this.LLM_CTX_DEFAULT) { const system = await this._getSystemContentForType(contextType); const chat = system.map((content) => ({ role: LLM.ROLE_SYSTEM, content })); const filters = this._filtersForContext(contextType); return new LLMSession(this.llm, chat, this._llmSend.bind(this), filters); } /** * * @param {LLMSession} session * @param {string} [contextType] * @returns {Promise<EvaluationResult>} */ async llmEvaluate (session, contextType = this.LLM_CTX_DEFAULT) { const rules = this._llmResultRules.get(contextType) || []; const text = session.lastResponse(); if (rules.length === 0 || !text) { return { action: null, setState: {}, results: [], discard: false }; } /** @type {IStateRequest} */ const req = { state: this.options.state, text: () => text, senderId: this._senderId, pageId: this._pageId, actionData: () => this._data, isConfidentInput: () => false }; const result = await this.llm.evaluateResultWithRules(text, rules, req, this); this.setState(result.setState); return result; } async _replaceAsync (str, regex, asyncFn) { const promises = []; str.replace(regex, (full, ...args) => { promises.push(asyncFn(full, ...args)); return full; }); const data = await Promise.all(promises); return str.replace(regex, () => data.shift()); } /** * * @param {string} contextType * @param {string[]} [callStack] * @returns {Promise<string[]>} */ async _getSystemContentForType (contextType, callStack = []) { if (new Set(callStack).size < callStack.length) { throw new Error(`Circular reference detected: contextType -> ${callStack}`); } if (!this._llmContext.has(contextType)) { return []; } /** @typedef {Promise<string>} PromisedString */ /** @type {PromisedString[]} */ const promiseStrings = this._llmContext.get(contextType) .map(async (p) => (typeof p === 'function' ? p(this) : p)); this._llmContext.set(contextType, promiseStrings); const resolved = await Promise.all( promiseStrings .map(async (promiseString) => { const s = await promiseString; const replaced = await this._replaceAsync(s.trim(), /\$\{([a-zA-Z0-9\s]+)\}/g, async (str, reqType) => { const nested = await this ._getSystemContentForType(reqType, [...callStack, contextType]); return nested.join('\n\n'); }); return replaced.trim(); }) ); return resolved; } async llmSessionWithHistory (contextType = this.LLM_CTX_DEFAULT) { const { transcriptAnonymize, transcriptFlag, transcriptLength } = this.llm.configuration; const [systems, transcript] = await Promise.all([ this._getSystemContentForType(contextType), this.getTranscript(transcriptLength, transcriptFlag) ]); const chat = [ ...systems.map((content) => ({ role: LLM.ROLE_SYSTEM, content })), ...LLM.anonymizeTranscript(transcript, transcriptAnonymize) ]; const filters = this._filtersForContext(contextType); return new LLMSession(this.llm, chat, this._llmSend.bind(this), filters); } /** * * @param {string|null} contextType * @returns {LLMFilter[]} */ _filtersForContext (contextType) { return [ ...(this._llmFilters.get(contextType) || []), ...this._llmFilters.get(null) ]; } /** * * @param {LLMMessage[]} messages * @param {QuickReply[]} quickReplies */ _llmSend (messages, quickReplies) { this.setFlag(LLM.GPT_FLAG); const { persona } = this.llm.configuration; if (typeof persona === 'string') { this.setPersona({ name: persona }); } else if (persona) { this.setPersona(persona); } messages.forEach((m, i) => { const addQuickReply = i === (messages.length - 1); this.text(m.content, addQuickReply ? quickReplies : null); }); if (persona) { this.setPersona({ name: null }); } } _findPersonaConfiguration (name) { // @ts-ignore if (!name || !this._configuration.persona) { return null; } // @ts-ignore if (!this._configuration._cachedPersonas) { // @ts-ignore this._configuration._cachedPersonas = new Map( // @ts-ignore Object.entries(this._configuration.persona) .map(([k, v]) => [k === PERSONA_DEFAULT ? k : tokenize(k), v]) ); } const nameKey = name === PERSONA_DEFAULT ? PERSONA_DEFAULT : tokenize(name); // @ts-ignore return this._configuration._cachedPersonas.get(nameKey); } /** * * @param {string} flag * @returns {this} */ setFlag (flag) { this._senderMeta.flag = flag; // @ts-ignore return this; } /** * * Returns current conversation transcript * * @param {number} [limit] * @param {string} [onlyFlag] * @param {boolean} [skipThisTurnaround] * @returns {Promise<Transcript[]>} */ async getTranscript (limit = 10, onlyFlag = null, skipThisTurnaround = false) { const { chatLogStorage, timestamp } = this._messageSender; let transcript = []; if (chatLogStorage) { transcript = await transcriptFromHistory( chatLogStorage, this._senderId, this._pageId, limit, onlyFlag ); } if (!skipThisTurnaround) { const { responseTexts = [], requestTexts = [] } = this._messageSender; transcript.push(...requestTexts.map((text) => ({ fromBot: false, text, timestamp }))); transcript.push(...responseTexts.map((text) => ({ fromBot: true, text, timestamp }))); } return transcript; } /** * Replaces recipient and disables autotyping * Usefull for sending a one-time notification * * @param {object} recipient */ setNotificationRecipient (recipient) { this._recipient = recipient; this.options.autoTyping = false; } /** * Response has been marked with a flag * * @returns {SenderMeta} */ get senderMeta () { return this._senderMeta; } /** * Disables logging the event to history * * @returns {this} */ doNotLogTheEvent () { this._senderMeta = { flag: ResponseFlag.DO_NOT_LOG }; return this; } /** * Fire tracking event * Events are aggregated within ReturnSender and can be caught * within Processor's `interaction` event (event.tracking.events) * * @param {TrackingType} type - (log,report,conversation,audit,user,training) * @param {TrackingCategory} category * @param {string} [action] * @param {string} [label] * @param {number} [value] * @returns {this} */ trackEvent (type, category, action = '', label = '', value = 0) { this.send({ tracking: { events: [ { type, category, action, label, value } ] } }); return this; } // PROTECTED METHOD (called from ReducerWrapper) _visitedInteraction (action) { this._messageSender.visitedInteraction(action); } /** * Send a raw messaging event. * If no recipient is provided, a default (senderId) will be added. * * @param {object} data * @returns {this} * @example * res.send({ message: { text: 'Hello!' } }); */ send (data) { if (!data || typeof data !== 'object') { throw new Error('Send method requires an object as first param'); } this.setPersona(PERSONA_DEFAULT); if (!data.recipient) { Object.assign(data, { recipient: { ...this._recipient } }); } if (!data.messagingType) { Object.assign(data, { messaging_type: this._messagingType }); } if (!data.messagingType) { Object.assign(data, { messaging_type: this._messagingType }); } if (typeof this._persona === 'string') { Object.assign(data, { persona_id: this._persona }); } else if (this._persona && typeof this._persona === 'object') { Object.assign(data, { persona: this._persona }); } if (!data.tag && this._tag) { Object.assign(data, { tag: this._tag }); } this.startedOutput = true; this._typingSent = data.sender_action === 'typing_on'; let opts; if (!data.sender_action && this._nextMessageSendOptions) { opts = this._nextMessageSendOptions; this._nextMessageSendOptions = null; } this._messageSender.send(data, opts); return this; } /** * Stores current action to be able to all it again * * @param {string} [action] * @param {object} [winningIntent] * @returns {this} * @deprecated * @example * bot.use(['action-name', /keyword/], (req, res) => { * if (req.action() !== res.currentAction()) { * // only for routes with action name (action-name) * res.setBookmark(); * return Router.BREAK; * } * res.text('Keyword reaction'); * }); * * // check out the res.runBookmark() method */ setBookmark (action = this.currentAction(), winningIntent = null) { this._bookmark = makeAbsolute(action, this.path); this._winningIntent = winningIntent; return this; } /** * Returns the action of bookmark * * @deprecated * @returns {string|null} */ bookmark () { return this._bookmark; } /** * * * @param {Function} postBack - the postback func * @param {object} [data] - data for bookmark action * @returns {Promise<null|boolean>} * @deprecated * @example * // there should be a named intent intent matcher (ai.match() and 'action-name') * * bot.use('action', (req, res) => { * res.text('tell me your name'); * res.expected('onName'); * }); * * bot.use('onName', (req, res, postBack) => { * if (res.bookmark()) { * await res.runBookmark(postBack); * * res.text('But I'll need your name') * .expected('onName'); * return; * } * * res.text(`Your name is: ${res.text()}`); * }) */ async runBookmark (postBack, data = {}) { if (!this._bookmark) { return true; } const bookmark = this._bookmark; const sendData = { bookmark, _winningIntent: this._winningIntent, ...data }; const res = await postBack(bookmark, sendData, true); this._bookmark = null; return res; } /** * * @param {string} messagingType * @param {string} [tag] * @returns {this} */ setMessagingType (messagingType, tag = null) { this._messagingType = messagingType; this._tag = tag; return this; } /** * Tets the persona for following requests * * @param {Persona|string|null} personaId * @returns {this} */ setPersona (personaId = null) { if (personaId === PERSONA_DEFAULT && this._persona) { return this; } if (typeof personaId === 'string') { const persona = this._findPersonaConfiguration(personaId); if (persona) { this._persona = persona; return this; } if (personaId === PERSONA_DEFAULT) { return this; } } this._persona = personaId; return this; } /** * Returns true, when responder is not sending an update (notification) message * * @returns {boolean} */ isResponseType () { return this._messagingType === TYPE_RESPONSE; } /** * @type {object} */ get data () { return this._data; } /** * Set temporary data to responder, which are persisted through single event * * @param {object} data * @returns {this} * @example * * bot.use('foo', (req, res, postBack) => { * res.setData({ a: 1 }); * postBack('bar'); * }); * * bot.use('bar', (req, res) => { * res.data.a; // === 1 from postback * }); */ setData (data) { Object.assign(this._data, data); return this; } setPath (absolutePath, routePath = '') { this.path = absolutePath; this.routePath = routePath; } /** * @typedef {object} MessageOptions * @prop {boolean} [disableAutoTyping] */ /** * Send text as a response * * @param {string} text - text to send to user, can contain placeholders (%s) * @param {Object.<string,string|QuickReply>|QuickReply[]} [replies] - quick replies * @param {VoiceControl} [voice] - voice control data * @param {MessageOptions} [options={}] * @returns {this} * * @example * // simply * res.text('Hello', { * action: 'Quick reply', * another: 'Another quick reply' * }); * * // complex * res.text('Hello', [ * { action: 'action', title: 'Quick reply' }, * { * action: 'complexAction', // required * title: 'Another quick reply', // required * setState: { prop: 'value' }, // optional * match: 'text' || /regexp/ || ['intent'], // optional * data: { foo: 1 }'Will be included in payload data' // optional * } * ]); */ text (text, replies = null, voice = null, options = {}) { const messageData = { message: { text: this._t(text) } }; this._textResponses.push(text); if (replies || this._quickReplyCollector.length !== 0) { const qrc = this._quickReplyCollector; const { quickReplies: qrs, expectedKeywords, disambiguationIntents } = makeQuickReplies(replies, this.path, this._t, qrc, Ai.ai, this.currentAction()); if (disambiguationIntents.length > 0) { this._senderMeta = { flag: ResponseFlag.DISAMBIGUATION_OFFERED, disambiguationIntents }; } if (qrs.length > 0) { this.finalMessageSent = true; messageData.message.quick_replies = qrs; this._addExpectedIntents(expectedKeywords); this._quickReplyCollector = []; } } if (this._features.includes(FEATURE_VOICE) && (voice || this.voiceControl)) { Object.assign(messageData.message, { voice: { ...(typeof this.voiceControl === 'function' ? this.voiceControl(Object.freeze({ ...this.options.state, ...this.newState })) : this.voiceControl), ...voice } }); if (!this._features.includes(FEATURE_SSML)) { delete messageData.message.voice.ssml; } } if (!options.disableAutoTyping) { this._autoTypingIfEnabled(messageData.message.text); } this.send(messageData); return this; } /* eslint jsdoc/check-param-names: 1 */ /** * Sets new attributes to state (with Object.assign()) * * @param {object} object * @returns {this} * * @example * res.setState({ visited: true }); */ setState (object) { Object.assign(this.newState, object); checkSetState(object, this.newState); return this; } /** * Appends quick reply, to be sent with following text method * * @param {string|QuickReply} action - relative or absolute action * @param {string} [title] - quick reply title * @param {object} [data] - additional data * @param {boolean} [prepend] - set true to add reply at the beginning * @param {boolean} [justToExisting] - add quick reply only to existing replies * @deprecated use #quickReply instead * @example * * bot.use((req, res) => { * res.addQuickReply('barAction', 'last action'); * * res.addQuickReply('theAction', 'first action', {}, true); * * res.text('Text', { * fooAction: 'goto foo' * }); // will be merged and sent with previously added quick replies * }); */ addQuickReply (action, title, data = {}, prepend = false, justToExisting = false) { const actionIsObject = typeof action === 'object' && action; const prep = actionIsObject ? action : {}; if (prepend) Object.assign(prep, { _prepend: true }); if (justToExisting) Object.assign(prep, { _justToExisting: true }); const useCa = this.currentAction(); if (actionIsObject) { this._quickReplyCollector.push({ ...prep, action: this.toAbsoluteAction(action.action), data: { ...prep.data, ...data }, useCa }); } else { this._quickReplyCollector.push({ // @ts-ignore action: this.toAbsoluteAction(action), title, data, useCa, ...prep }); } return this; } /** * Adds quick reply, to be sent by following text message * * @param {QuickReply} reply * @param {boolean} [atStart] * @param {boolean} [toLastMessage] * @param {boolean} [ifNotExists] * @returns {this} * @example * * bot.use((req, res) => { * res.quickReply({ action: 'barAction', title: 'last action' }); * * res.text('Text', { * fooAction: 'goto foo' * }); // will be merged and sent with previously added quick replies * }); */ quickReply (reply, atStart = false, toLastMessage = true, ifNotExists = false) { const useCa = this.currentAction(); const action = this.toAbsoluteAction(reply.action); if (ifNotExists && this._quickReplyCollector.some((q) => q.action === action)) { return this; } this._quickReplyCollector.push({ ...reply, action, useCa, ...(atStart && { _prepend: true }), ...(toLastMessage && { _justToExisting: true }) }); return this; } /** * To be able to keep context of previous interaction (expected action and intents) * Just use this method to let user to answer again. * * @param {Request} req * @param {boolean} [justOnce] - don't do it again * @param {boolean} [includeKeywords] - keep intents from quick replies * @returns {this} * @example * * bot.use('start', (req, res) => { * res.text('What color do you like?', [ * { match: ['@Color=red'], text: 'red', action: 'red' }, * { match: ['@Color=blue'], text: 'blue', action: 'blue' } * ]); * res.expected('need-color') * }); * * bot.use('need-color', (req, res) => { * res.keepPreviousContext(req); * res.text('Sorry, only red or blue.'); * }); */ keepPreviousContext (req, justOnce = false, includeKeywords = false) { // @ts-ignore this.setState(req.expectedContext(justOnce, includeKeywords)); return this; } /** * * @param {string|string[]} intents * @param {string} action * @param {object} data * @param {object} [setState] * @param {string|object[]} [aiTitle] */ expectedIntent (intents, action, data = {}, setState = null, aiTitle = null) { const push = { action: this.toAbsoluteAction(action), match: intents, data }; if (setState) { Object.assign(push, { setState }); } if (aiTitle) { Object.assign(push, { title: aiTitle, hasAiTitle: true }); } this._addExpectedIntents([push]); return this; } _addExpectedIntents (add) { const { _expectedKeywords: ex = [] } = this.newState; ex.push(...add); this.setState({ _expectedKeywords: ex }); if (!this._features.includes(FEATURE_PHRASES)) { return; } const expectedIntentsAndEntities = []; // collect entities add.forEach((e) => { if (!Array.isArray(e.match)) return; e.match .forEach((rule) => { if (rule.startsWith('#')) { if (!rule.match(/^#(\|?[a-z0-9-]+)+#?$/i)) { return; } const keywords = rule.match(/\|?[a-z0-9-]+/ig) .map((k) => k .replace(/\|/g, '') .replace(/[-\s]+/g, ' ')); expectedIntentsAndEntities.push( ...keywords ); return; } if (rule.startsWith('@')) { expectedIntentsAndEntities.push(rule .replace(/([!=><?]{1,3})([^=><!]+)?/, '')); } else { expectedIntentsAndEntities.push(rule); } }); }); if (expectedIntentsAndEntities.length !== 0) { this._messageSender.send({ expectedIntentsAndEntities }); } } /** * When user writes some text as reply, it will be processed as action * * @param {string} action - desired action * @param {object} data - desired action data * @returns {this} */ expected (action, data = {}) { if (!action) { return this.setState({ _expected: null }); } this.finalMessageSent = true; return this.setState({ _expected: { action: makeAbsolute(action, this.path), data } }); } /** * Makes a following user input anonymized * * - disables processing of it with NLP * - replaces text content of incomming request before * storing it at ChatLogStorage using a `confidentInputFilter` * - `req.isConfidentInput()` will return true * * After processing the user input, next requests will be processed as usual, * * @param {ExpectedInput} [expectedInput] * @returns {this} * @example * * const { Router } = require('wingbot'); * * const bot = new Router(); * * bot.use('start', (req, res) => { * // evil question * res.text('Give me your CARD NUMBER :D') * .expected('received-card-number') * .expectedConfidentInput(); * }); * * bot.use('received-card-number', (req, res) => { * const cardNumber = req.text(); * * // raw card number * * req.isConfidentInput(); // true * * res.text('got it') * .setState({ cardNumber }); * }); */ expectedConfidentInput (expectedInput = null) { if (expectedInput) { this.expectedInput(expectedInput); } return this.setState({ _expectedConfidentInput: true }); } /** * * @param {ExpectedInput} type * @param {ExpectedInputOptions} [options] * @returns {this} * @example * bot.use((req, res) => { * res.expectedInput(res.ExpectedInputTypes.TYPE_PASSWORD) * }); */ expectedInput (type, options = {}) { const { onCloseAction, onCloseActionData = {}, ...rest } = options; if (onCloseAction) { Object.assign(rest, { on_close_payload: this._makePayload(onCloseAction, onCloseActionData) }); } this._messageSender.send({ expectedIntentsAndEntities: [{ type, ...rest }] }); return this; } /** * Converts relative action to absolute action path * * @param {string} action - relative action to covert to absolute * @param {boolean} [forceStartingSlash=false] * @returns {string} absolute action path */ toAbsoluteAction (action, forceStartingSlash = false) { return makeAbsolute(action, this.path || (forceStartingSlash ? '/' : '')); } /** * Returns current action path * * @returns {string} */ currentAction () { const routePath = this.routePath.replace(/^\//, ''); let ret; if (!routePath) { ret = this.path; } else { ret = makeAbsolute(routePath, this.path); } if (!ret.match(/^\//)) { return `/${ret}`; } return ret; } /** * * @param {Buffer} data * @param {string} contentType * @param {string} fileName * @returns {Promise<UploadResult>} */ async upload (data, contentType, fileName) { const result = await this._messageSender.upload(data, contentType, fileName); if (!result.url) { throw new Error(`Got no url on file upload ${fileName}. Probably a compatibility issue.`); } let [type] = contentType.split('/'); if (!['image', 'video', 'audio'].includes(type)) { type = 'file'; } this._attachment(result.url, type, true); return result; } /** * Sends image as response. Requires appUrl option to send images from server * * @param {string} imageUrl - relative or absolute url * @param {boolean} [reusable] - force facebook to cache image * @returns {this} * * @example * // image on same server (appUrl option) * res.image('/img/foo.png'); * * // image at url * res.image('https://google.com/img/foo.png'); */ image (imageUrl, reusable = false) { this._attachment(imageUrl, 'image', reusable); return this; } /** * Sends video as response. Requires appUrl option to send videos from server * * @param {string} videoUrl - relative or absolute url * @param {boolean} [reusable] - force facebook to cache asset * @returns {this} * * @example * // file on same server (appUrl option) * res.video('/img/foo.mp4'); * * // file at url * res.video('https://google.com/img/foo.mp4'); */ video (videoUrl, reusable = false) { this._attachment(videoUrl, 'video', reusable); return this; } /** * Sends file as response. Requires appUrl option to send files from server * * @param {string} fileUrl - relative or absolute url * @param {boolean} [reusable] - force facebook to cache asset * @returns {this} * * @example * // file on same server (appUrl option) * res.file('/img/foo.pdf'); * * // file at url * res.file('https://google.com/img/foo.pdf'); */ file (fileUrl, reusable = false) { this._attachment(fileUrl, 'file', reusable); return this; } _attachment (attachmentUrl, type, reusable = false) { let url = attachmentUrl; if (!url.match(/^https?:\/\//)) { url = `${this.options.appUrl}${url}`; } const messageData = { message: { attachment: { type, payload: { url, is_reusable: reusable } } } }; const autoTyping = reusable ? null : false; this._autoTypingIfEnabled(autoTyping); this.send(messageData); return this; } /** * One-time Notification request * * use tag to be able to use the specific token with a specific campaign * * @param {string} title - propmt text * @param {string} action - target action, when user subscribes * @param {string} [tag] - subscribtion tag, which will be matched against a campaign * @param {object} [data] * @returns {this} */ oneTimeNotificationRequest (title, action, tag = null, data = {}) { return this.template({ template_type: 'one_time_notif_req', title: this._t(title), payload: this._makePayload(action, { ...data, _ntfTag: tag }) }); } _makePayload (action, data) { return JSON.stringify({ action: makeAbsolute(action, this.path), data }); } template (payload) { const messageData = { message: { attachment: { type: 'template', payload } } }; const autoTyping = payload.text || payload.title || null; this._autoTypingIfEnabled(autoTyping); this.send(messageData); return this; } /** * Sets delay between two responses * * @param {number} [ms=600] * @returns {this} */ wait (ms = 600) { this.send({ wait: ms }); return this; } /** * Sends "typing..." information * * @param {boolean} [force] - send even if was recently sent * @returns {this} */ typingOn (force = false) { return this._senderAction('typing_on', force); } /** * Stops "typing..." information * * @returns {this} */ typingOff () { return this._senderAction('typing_off'); } /** * Reports last message from user as seen * * @returns {this} */ seen () { return this._senderAction('mark_seen'); } /** * Pass thread to another app * * @param {string} targetAppId * @param {string|object} [data] * @returns {this} */ passThread (targetAppId, data = null) { let metadata = data; let { _$hopCount: $hopCount = -1 } = this._data; if ($hopCount >= EXCEPTION_HOPCOUNT_THRESHOLD) { throw new Error(`More than ${EXCEPTION_HOPCOUNT_THRESHOLD} handovers occured`); } else { $hopCount++; } this._senderMeta = { flag: ResponseFlag.HANDOVER }; if (data === null) { metadata = JSON.stringify({ data: { $hopCount } }); } else if (typeof data === 'object') { metadata = JSON.stringify({ ...data, data: { $hopCount, ...data.data } }); } else if (typeof data !== 'string') { metadata = JSON.stringify(data); } const messageData = { target_app_id: targetAppId, metadata }; this.finalMessageSent = true; this.send(messageData); return this; } /** * Request thread from Primary Receiver app * * @param {string|object} [data] * @returns {this} */ requestThread (data = null) { let metadata = {}; if (data !== null && typeof data !== 'string') { metadata = { metadata: JSON.stringify(data) }; } else if (data) { metadata = { metadata: data }; } const messageData = { request_thread_control: metadata }; this.finalMessageSent = true; this.send(messageData); return this; } /** * Take thread from another app * * @param {string|object} [data] * @returns {this} */ takeThead (data = null) { this.finalMessageSent = true; let metadata = {}; if (data !== null && typeof data !== 'string') { metadata = { metadata: JSON.stringify(data) }; } else if (data) { metadata = { metadata: data }; } const messageData = { take_thread_control: metadata }; this.send(messageData); return this; } /** * Sends Receipt template * * @param {string} recipientName * @param {string} [paymentMethod='Cash'] - should not contain more then 4 numbers * @param {string} [currency='USD'] - sets right currency * @param {string} [uniqueCode=null] - when omitted, will be generated randomly * @returns {ReceiptTemplate} * * @example * res.receipt('Name', 'Cash', 'CZK', '1') * .addElement('Element name', 1, 2, '/inside.png', 'text') * .send(); */ receipt (recipientName, paymentMethod = 'Cash', currency = 'USD', uniqueCode = null) { return new ReceiptTemplate( (payload) => this.template(payload), this._createContext(), recipientName, paymentMethod, currency, uniqueCode ); } /** * Sends nice button template. It can redirect user to server with token in url * * @param {string} text * @returns {ButtonTemplate} * * @example * res.button('Hello') * .postBackButton('Text', 'action') * .urlButton('Url button', '/internal', true) // opens webview with token * .urlButton('Other button', 'https://goo.gl') // opens in internal browser * .send(); */ button (text) { const btn = new ButtonTemplate( (payload) => { this._textResponses.push(text); this.template(payload); }, this._createContext(), text ); return btn; } /** * Creates a generic template * * @param {boolean} [shareable] - ability to share template * @param {boolean} [isSquare] - use square aspect ratio for images * @example * res.genericTemplate() * .addElement('title', 'subtitle') * .setElementImage('/local.png') * .setElementAction('https://www.seznam.cz') * .postBackButton('Button title', 'action', { actionData: 1 }) * .addElement('another', 'subtitle') * .setElementImage('https://goo.gl/image.png') * .setElementActionPostback('action', { actionData: 1 }) * .urlButton('Local link with extension', '/local/path', true, 'compact') * .send(); * * @returns {GenericTemplate} * */ genericTemplate (shareable = false, isSquare = false) { return new GenericTemplate( (payload) => this.template(payload), this._createContext(), shareable, isSquare ); } /** * Creates a generic template * * @example * res.list('compact') * .postBackButton('Main button', 'action', { actionData: 1 }) * .addElement('title', 'subtitle') * .setElementImage('/local.png') * .setElementUrl('https://www.seznam.cz') * .postBackButton('Button title', 'action', { actionData: 1 }) * .addElement('another', 'subtitle') * .setElementImage('https://goo.gl/image.png') * .setElementAction('action', { actionData: 1 }) * .urlButton('Local link with extension', '/local/path', true, 'compact') * .send(); * * @param {'large'|'compact'} [topElementStyle='large'] * @returns {ListTemplate} */ list (topElementStyle = 'large') { return new ListTemplate( topElementStyle, (payload) => this.template(payload), this._createContext() ); } /** * Set next message as confident * * @param {TextFilter} anonymizer */ nextOutputConfident (anonymizer) { this._nextMessageSendOptions = { anonymizer }; } /** * Override action tracking * * @param {string|boolean} action - use false to not emit analytics events * @returns {this} */ trackAs (action) { if (typeof action === 'boolean') { this._trackAsAction = action === false ? false : null; } else { this._trackAsAction = this.toAbsoluteAction(action); } return this; } /** * Set skill for tracking (will used untill it will be changed) * * @param {string|null} skill * @returns {this} */ trackAsSkill (skill) { // @ts-ignore const { _trackAsSkill: currentSkill } = this.options.state; const setState = { _trackAsSkill: skill }; if (currentSkill && currentSkill !== skill) { Object.assign(setState, { _trackPrevSkill: currentSkill }); } this.setState(setState); return this; } /** * Return array of text responses * * @returns {string[]} */ get textResponses () { return this._textResponses; } _senderAction (action, force = false) { if (action === 'typing_on' && this._typingSent && !force) { return this; } const messageData = { sender_action: action }; this.send(messageData); return this; } _createContext () { const { translator, appUrl } = this.options; return { translator, appUrl, token: this.token || '', senderId: this._senderId, path: this.path, currentAction: this.currentAction() }; } _autoTypingIfEnabled (text) { if (!this.options.autoTyping) { return; } if (this._messagingType !== TYPE_RESPONSE && !this._firstTypingSkipped) { this._firstTypingSkipped = true; return; } const typingTime = this._getTypingTimeForText(text); this.typingOn().wait(typingTime); } _getTypingTimeForText (text) { if (text === false) { return 1; } const textLength = typeof text === 'string' ? text.length : this.options.autoTyping.perCharacters; const timePerCharacter = this.options.autoTyping.time / this.options.autoTyping.perCharacters; return Math.min( Math.max( textLength * timePerCharacter, this.options.autoTyping.minTime ),