UNPKG

wingbot

Version:

Enterprise Messaging Bot Conversation Engine

813 lines (689 loc) 24 kB
/* * @author David Menger */ 'use strict'; const ai = require('./Ai'); const { FEATURE_PHRASES, FEATURE_TRACKING } = require('./features'); const { ResponseFlag } = require('./analytics/consts'); const extractText = require('./transcript/extractText'); /** @typedef {import('./Request')} Request */ /** @typedef {import('./Responder')} Responder */ /** @typedef {import('./Processor').TrackingObject} TrackingObject */ /** @typedef {import('./LLMSession').LLMMessage} LLMMessage */ /** @typedef {import('./LLM').LLMLogger} LLMLogger */ /** @typedef {import('./LLM').PromptInfo} PromptInfo */ /** * @callback GetInteractions * @param {string} senderId * @param {string} pageId * @param {number} [limit] * @param {number} [endAt] - iterate backwards to history * @param {number} [startAt] - iterate forward to last interaction * @returns {Promise<object[]>} */ /** * @typedef {object} ChatLogStorage * @prop {Function} log * @prop {Function} error * @prop {GetInteractions} [getInteractions] */ /** * @typedef {object} ReturnSenderOptions * @prop {boolean} [dontWaitForDeferredOps] * @prop {TextFilter} [textFilter] - filter for saving the texts * @prop {boolean} [logStandbyEvents] - log the standby events * @prop {TextFilter} [confidentInputFilter] - filter for confident input (@CONFIDENT) */ /** * @typedef {object} UploadResult * @prop {string} [url] * @prop {string|number} [attachmentId] */ /** * @callback DeferOperation * @returns {Promise<any>} */ /** * @typedef {object} ErrorLogger * @prop {Function} error */ /** * @typedef {object} SendOptions * @prop {TextFilter} [anonymizer] */ /** * Text filter function * * @callback TextFilter * @param {string} text - input text * @param {'text'|'title'|'url'|'content'} key * @returns {string} - filtered text */ /** * @class ReturnSender * @implements {LLMLogger} */ class ReturnSender { /** * * @param {ReturnSenderOptions} options * @param {string} senderId * @param {object} incommingMessage * @param {ChatLogStorage} logger - console like logger */ constructor (options, senderId, incommingMessage, logger = null) { this._queue = []; /** @type {object[]} */ this.responses = []; /** @type {SendOptions[]} */ this._responseOptions = []; this._results = []; this._promise = null; this._isWorking = false; const isStandbyEvent = incommingMessage.isStandby; this._sendLogs = !isStandbyEvent || options.logStandbyEvents; this._senderId = senderId; this._incommingMessage = incommingMessage; this._logger = logger; this._sequence = 0; this._features = Array.isArray(incommingMessage.features) ? incommingMessage.features : ['text']; this.timestamp = incommingMessage.timestamp; this._sendLastMessageWithFinish = this._features.includes(FEATURE_PHRASES) || this._features.includes(FEATURE_TRACKING); /** * @type {Function} * @private */ this._finish = null; this._finishedPromise = new Promise((r) => { this._finish = (val) => { this._finished = true; r(val); }; }); this._finished = false; this._catchedBeforeFinish = null; /** * @type {boolean} */ this.waits = false; this.propagatesWaitEvent = false; this._simulatesOptIn = false; this.simulateFail = false; this._simulateStateChange = null; this._simulateStateChangeOnLoad = null; /** * Preprocess text for NLP * For example to remove any confidential data * * @param {string} text * @type {TextFilter} */ this.textFilter = options.textFilter || ((text) => text); this.confidentInputFilter = options.confidentInputFilter || (() => '@CONFIDENT'); this._lastWait = 0; this._visitedInteractions = []; this._tracking = { events: [] }; /** @type {PromptInfo[]} */ this.prompts = []; this._responseTexts = []; this._intentsAndEntities = []; this._confidentInput = false; /** * @type {Function} * @private */ this._gotAnotherEventDefer = null; this._anotherEventPromise = null; this._gotAnotherEvent(); this._skipWaitForDeferred = options.dontWaitForDeferredOps; this._opQueue = new Set(); } async waitForDeferredOps () { if (this._skipWaitForDeferred) { return; } await Promise.all(Array.from(this._opQueue.values())); if (this._throwAtTheEnd) { const e = this._throwAtTheEnd; this._throwAtTheEnd = null; throw e; } } /** * * @param {PromptInfo} promptInfo */ logPrompt (promptInfo) { this.prompts.push(promptInfo); } /** * * @param {DeferOperation|Promise} operation * @param {ErrorLogger} logger */ defer (operation, logger = console) { const promise = (typeof operation === 'function' ? Promise.resolve(operation()) : operation ) .catch((e) => { if (this._skipWaitForDeferred === false) { this._throwAtTheEnd = e; } // eslint-disable-next-line no-console logger.error('DEFER# - op failed', e); }) .then(() => { this._opQueue.delete(promise); }); this._opQueue.add(promise); } set simulatesOptIn (value) { this._simulatesOptIn = value; // simulate optin const isOptIn = this._incommingMessage.optin && this._incommingMessage.optin.user_ref; if (isOptIn && this._simulatesOptIn) { this._simulateStateChange = { senderId: this._senderId }; } } get simulatesOptIn () { return this._simulatesOptIn; } /** * @returns {string[]} */ get requestTexts () { const text = extractText(this._incommingMessage); if (!text) { return []; } const filter = this._confidentInput ? this.confidentInputFilter : this.textFilter; return [ filter(text, null).trim() ]; } /** * @returns {string[]} */ get responseTexts () { const filter = this._confidentInput ? this.confidentInputFilter : this.textFilter; return this._responseTexts .map((t) => filter(t, null)) .filter((t) => t && `${t}`.trim()); } /** * @returns {ChatLogStorage} */ get chatLogStorage () { return this._logger; } _gotAnotherEvent () { if (this._gotAnotherEventDefer) { this._gotAnotherEventDefer(); } this._anotherEventPromise = new Promise((r) => { this._gotAnotherEventDefer = r; }); } /** * @returns {TrackingObject} */ get tracking () { return this._tracking; } get visitedInteractions () { return this._visitedInteractions.slice(); } get results () { return this._results; } _send (payload) { // eslint-disable-line no-unused-vars const res = { message_id: `${Date.now()}${Math.random()}.${this._sequence++}` }; if (this.simulateFail) { return Promise.reject(new Error('Fail')); } return Promise.resolve(res); } _wait (wait) { const nextWait = this._lastWait ? Math.round(wait * 0.75) + this._lastWait : wait; this._lastWait = Math.round(wait * 0.334); if (!this.waits) { return Promise.resolve(); } return new Promise((r) => setTimeout(r, nextWait)); } _filterMessage (payload, confidentInput = null, req = null) { let filter; const processButtons = typeof confidentInput === 'function'; if (processButtons) { filter = confidentInput; } else if (confidentInput) { filter = this.confidentInputFilter; } else { filter = this.textFilter; } let { message } = payload; if (message && message.voice && message.voice.ssml) { message = { ...message, text: message.text ? message.text : message.voice.ssml, voice: { ...message.voice, ssml: filter(message.voice.ssml, 'ssml') } }; } // text message if (message && message.text) { let { text } = message; if (req && req._anonymizedText) { text = req._anonymizedText; } return { ...payload, message: { ...message, text: filter(text, 'text') } }; } // button message if (message && message.attachment && message.attachment.type === 'template' && message.attachment.payload && message.attachment.payload.text) { const { payload: p } = message.attachment; return { ...payload, message: { ...message, attachment: { ...message.attachment, payload: { ...p, text: filter(p.text, 'text'), ...(processButtons && Array.isArray(p.buttons) ? { buttons: p.buttons.map((btn) => { switch (btn.type) { case 'attachment': return { ...btn, title: filter(btn.title, 'title'), ...(btn.payload && btn.payload.content ? { payload: { ...btn.payload, content: filter(btn.payload.content, 'content') } } : {} ) }; case 'web_url': return { ...btn, url: filter(btn.url, 'url'), title: filter(btn.title, 'title') }; case 'postback': return { ...btn, title: filter(btn.title, 'title') }; default: return btn; } }) } : {} ) } } } }; } return payload; } async _work () { this._isWorking = true; let payload; let options; let req; let previousResponse = null; while (this._queue.length > 0) { ({ payload, options } = this._queue.shift()); let lastInQueueForNow = this._queue.length === 0; if (this._queue.length === 0 && this._sendLastMessageWithFinish) { await Promise.race([ this._anotherEventPromise, this._finishedPromise ]); lastInQueueForNow = this._queue.length === 0; if (lastInQueueForNow) { // still last in queue - finished event came req = await this._finishedPromise; } } if (payload.wait && !this.propagatesWaitEvent) { await this._wait(payload.wait); } else if (payload.wait) { const lastResponse = this.responses[this.responses.length - 1]; if (lastResponse && lastResponse.sender_action) { Object.assign(lastResponse, { wait: payload.wait }); } } else { await this._enrichPayload(payload, req, lastInQueueForNow); this.responses.push(payload); this._responseOptions.push(options); previousResponse = await this._send(payload); this._results.push(previousResponse); } } this._isWorking = false; } async _enrichPayload (payload, req, lastInQueueForNow) { if (!lastInQueueForNow) { return; } if (this._features.includes(FEATURE_TRACKING)) { const tracking = this._createTracking(req); Object.assign(payload, { tracking }); } if (req && this._intentsAndEntities.length !== 0) { const supportsPhrases = this._features.includes(FEATURE_PHRASES); const { phrases } = supportsPhrases ? await ai.ai.getPhrases(req) : { phrases: new Map() }; const phrasesSet = new Set(); const entities = []; let input = null; this._intentsAndEntities .forEach((aiObj) => { // expected input if (aiObj && aiObj.type) { input = aiObj; return; } if (!supportsPhrases) { return; } if (aiObj.startsWith('@')) { entities.push(aiObj); } const keywords = phrases.get(aiObj) || []; keywords.forEach((kw) => { phrasesSet.add(kw); }); }); Object.assign(payload, { expected: { entities, phrases: Array.from(phrasesSet) } }); if (input) { Object.assign(payload.expected, { input }); } } } visitedInteraction (action) { this._visitedInteractions.push(action); } /** * * @param {Buffer} data * @param {string} contentType * @param {string} fileName * @returns {Promise<UploadResult>} */ async upload (data, contentType, fileName) { // eslint-disable-line no-unused-vars throw new Error('#upload() not supported by this channel'); } _isVisibleMessage (event, needTitle = true) { // @todo is also in orchestrator if (event.message) { return !!(event.message.text || event.message.attachment || event.message.attachments); } if (event.sender_action) { return true; } if (event.postback && (!needTitle || event.postback.title)) { return true; } return false; } /** * * @param {object} payload * @param {SendOptions} options * @returns {void} */ send (payload, options = {}) { if (this._finished) { throw new Error('Cannot send message after sender is finished'); } if (payload.tracking) { // collect events if (Array.isArray(payload.tracking.events)) { this._tracking.events.push(...payload.tracking.events); } return; } if (Array.isArray(payload.expectedIntentsAndEntities)) { this._intentsAndEntities.push(...payload.expectedIntentsAndEntities); this._sendLastMessageWithFinish = this._sendLastMessageWithFinish || this._intentsAndEntities.some((e) => e && e.type); return; } const lastInQueue = this._queue[this._queue.length - 1]; if (payload.set_context && !this._isVisibleMessage(payload, false) && lastInQueue && this._isVisibleMessage(lastInQueue.payload)) { const { set_context: setContext, ...rest } = payload; Object.assign( lastInQueue.payload, rest, lastInQueue.payload, { set_context: { ...lastInQueue.payload.set_context, ...setContext } } ); return; } const text = extractText(payload); if (text) { this._responseTexts.push(text); } this._queue.push({ payload, options }); this._gotAnotherEvent(); if (this._catchedBeforeFinish) { return; } if (!this._isWorking) { const promise = this._promise || new Promise((r) => process.nextTick(r)); this._promise = promise .then(() => this._work()) .catch((e) => { if (this._finished) { throw e; // ints ok } this._catchedBeforeFinish = e; }); } } /** * @returns {Promise<object|null>} */ modifyStateAfterLoad () { return Promise.resolve(this._simulateStateChangeOnLoad); } /** * @returns {Promise<object|null>} */ modifyStateBeforeStore () { return Promise.resolve(this._simulateStateChange); } _cleanupEntities (entities = []) { return entities.map((e) => ({ ...e, value: typeof e.value === 'object' ? JSON.stringify(e.value).substring(0, 20) : e.value })); } _cleanupIntent (intent) { return { ...intent, entities: this._cleanupEntities(intent.entities) }; } _createTracking (req = null, res = null) { const payload = {}; const meta = { actions: this._visitedInteractions.slice(), prompts: this.prompts }; if (req) { Object.assign(meta, { intent: req.intent(ai.ai.confidence), confidence: ai.ai.confidence, intents: (req.intents || []) .map((i) => this._cleanupIntent(i)), entities: this._cleanupEntities((req.entities || []) .filter((e) => e.score >= ai.ai.confidence)) }); } if (res) { Object.assign(payload, res.senderMeta); } return Object.assign(this._tracking, { payload, meta }); } /** * @private * @param {Request} req * @param {Responder} res */ _createMeta (req = null, res = null) { // eslint-disable-line no-unused-vars const meta = { visitedInteractions: this._visitedInteractions.slice(), prompts: this.prompts }; if (req) { let text = req.text(); if (text) { text = this.textFilter(text, null); } const expected = req.expected(); Object.assign(meta, { ...(res && res.senderMeta), timestamp: req.timestamp, text, intent: req.intent(ai.ai.confidence), aiConfidence: ai.ai.confidence, aiActions: req.aiActions() .map((a) => ({ ...a, intent: this._cleanupIntent(a.intent) })), intents: (req.intents || []) .map((i) => this._cleanupIntent(i)), entities: this._cleanupEntities((req.entities || []) .filter((e) => e.score >= ai.ai.confidence)), action: req.action(), data: req.actionData(), expected: expected ? expected.action : null, pageId: req.pageId, senderId: req.senderId }); } return meta; } /** * * @param {Request} [req] * @param {Responder} [res] * @param {Error} [err] * @param {Function} [reportError] * @returns {Promise<Object>} */ // eslint-disable-next-line no-console async finished (req = null, res = null, err = null, reportError = console.error) { this._finish(req); const meta = this._createMeta(req, res); this._confidentInput = !!req && req.isConfidentInput(); let error = err; try { await this._promise; } catch (e) { error = e; } if (!error) { error = this._catchedBeforeFinish; } try { const sent = this.responses.map((s, i) => { const opts = this._responseOptions[i]; return this._filterMessage(s, opts.anonymizer); }); const processedEvent = req ? req.event : this._incommingMessage; let incomming = this._filterMessage(processedEvent, this._confidentInput, req); if (processedEvent !== this._incommingMessage) { incomming = { ...incomming, original_event: this._incommingMessage }; } if (!this._logger || meta.flag === ResponseFlag.DO_NOT_LOG) { // noop } else if (error) { this.defer(Promise.resolve(this._logger .error(error, this._senderId, sent, incomming, meta))); } else if (this._sendLogs) { this._sendLogs = false; this.defer(Promise.resolve(this._logger .log(this._senderId, sent, incomming, meta))); } } catch (e) { console.log('meta', meta); // eslint-disable-line no-console this.defer(Promise.resolve(reportError(e, this._incommingMessage, this._senderId))); } if (error) { // @ts-ignore const { code = 500, message } = error; this.defer(Promise.resolve(reportError(error, this._incommingMessage, this._senderId))); await this.waitForDeferredOps(); return { status: code, error: message, results: this.results }; } const somethingSent = this._results.length > 0; await this.waitForDeferredOps(); return { status: somethingSent ? 200 : 204, results: this._results }; } } module.exports = ReturnSender;