wingbot
Version:
Enterprise Messaging Bot Conversation Engine
1,843 lines (1,595 loc) • 49.4 kB
JavaScript
/*
* @author David Menger
*/
'use strict';
const Ai = require('./Ai');
const { tokenize, parseActionPayload } = require('./utils');
const { quickReplyAction } = require('./utils/quickReplies');
const { ResponseFlag } = require('./analytics/consts');
const { getSetState } = require('./utils/getUpdate');
const { vars, checkSetState } = require('./utils/stateVariables');
const OrchestratorClient = require('./OrchestratorClient');
const { cachedTranslatedCompilator, stateData } = require('./resolvers/utils');
const {
FEATURE_VOICE,
FEATURE_SSML,
FEATURE_PHRASES,
FEATURE_TEXT,
FEATURE_TRACKING,
getDefaultFeatureList
} = require('./features');
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const counter = {
_t: 0,
_d: 0
};
function makeTimestamp () {
let now = Date.now();
if (now > counter._d) {
counter._t = 0;
} else {
now += ++counter._t;
}
counter._d = now;
return now;
}
/** @typedef {import('./Router').BaseConfiguration} BaseConfiguration */
/**
* @typedef {object} Entity
* @prop {string} entity
* @prop {string} value
* @prop {number} score
* @prop {Entity[]} [alternatives]
*/
/**
* @typedef {object} Intent
* @prop {string} intent
* @prop {number} score
* @prop {Entity[]} [entities]
*/
/**
* @typedef {object} Action
* @prop {string} action
* @prop {object} data
* @prop {object|null} [setState]
*/
/**
* @typedef {object} IntentAction
* @prop {string} action
* @prop {Intent} intent
* @prop {number} sort
* @prop {boolean} local
* @prop {boolean} aboveConfidence
* @prop {object} [data]
* @prop {string|string[]} [match]
* @prop {object} [setState]
* @prop {boolean} [winner]
* @prop {string|Function} [title]
* @prop {boolean} [hasAiTitle]
* @prop {object} meta
* @prop {string} [meta.targetAppId]
* @prop {string|null} [meta.targetAction]
* @prop {string} [meta.resolverTag]
*/
/**
* @typedef {object} QuickReply
* @prop {string} action
* @prop {*} title
*/
/**
* @typedef {object} QuickReplyDisambiguation
* @prop {string} action
* @prop {string} title
* @prop {object} data
* @prop {object} templateData
*/
/**
* @typedef {object} RequestOrchestratorOptions
* @prop {string} [apiUrl]
* @prop {Promise<string>} [secret]
* @prop {Function} [fetch]
* @prop {string} [appId]
*/
/**
* @typedef {object} TextAlternative
* @prop {string} text
* @prop {number} score
*/
/**
* @typedef {object} Attachment
* @prop {'file'|'audio'|'video'|'image'} type
* @prop {object} payload
* @prop {string} payload.url
*/
/**
* @typedef {number} AiSetStateOption
*/
/** @typedef {import('./OrchestratorClient').OrchestratorClientOptions} OrchestratorClientOptions */
/**
* Instance of {Request} class is passed as first parameter of handler (req)
*
* @template {object} [S=object]
* @template {BaseConfiguration} [C=object]
* @class Request
*/
class Request {
/**
* @param {*} event
* @param {S} state
* @param {string} pageId
* @param {Map} globalIntents
* @param {RequestOrchestratorOptions} [orchestratorOptions]
* @param {C} [configuration]
*/
constructor (
event,
state,
pageId,
globalIntents = new Map(),
orchestratorOptions = {},
// @ts-ignore
configuration = {}
) {
this.campaign = event.campaign || null;
this.taskId = event.taskId || null;
this._event = event;
/**
* @enum {AiSetStateOption}
*/
this.AI_SETSTATE = {
ONLY: 1,
INCLUDE: 0,
EXCLUDE: -1,
EXCLUDE_WITH_SET_ENTITIES: -2,
EXCLUDE_WITHOUT_SET_ENTITIES: -3
};
this.globalIntents = globalIntents;
/**
* @prop {object} params - plugin configuration
*/
this.params = {};
this.message = event.message || null;
this._postback = event.postback || null;
this._referral = (this._postback && this._postback.referral)
|| event.referral
|| null;
this._optin = event.optin || null;
/** @type {Attachment[]} */
this.attachments = (event.message
&& (event.message.attachment
? [event.message.attachment]
: event.message.attachments)) || [];
/**
* @type {number|null} timestamp
*/
this.timestamp = event.timestamp || Date.now();
/**
* @type {string} senderId sender.id from the event
*/
this.senderId = (event.sender && event.sender.id) || null;
/**
* @type {string} recipientId recipient.id from the event
*/
this.recipientId = event.recipient && event.recipient.id;
/**
* @type {string} pageId page identifier from the event
*/
this.pageId = pageId;
/**
* @type {S} current state of the conversation
*/
this.state = state;
/**
* @type {string[]} features supported messaging features
*/
this.features = Array.isArray(event.features)
? event.features
: getDefaultFeatureList();
/**
* @type {string[]} state list of subscribed tags
*/
this.subscribtions = [];
/**
* @type {Entity[]} entities list of entities
*/
this.entities = [];
/**
* @type {Intent[]} intents list of resolved intents
*/
this.intents = [];
/**
* @type {Action}
* @private
*/
this._action = undefined;
this._winningIntent = null;
this._aiActions = null;
this._quickReplyActions = null;
this._aiWinner = null;
// protected for now, filled by AI
this._anonymizedText = null;
/** @type {OrchestratorClientOptions} */
this._orchestratorClientOptions = {
...orchestratorOptions,
pageId: this.pageId,
senderId: this.senderId
};
this._orchestrator = null;
/**
* @constant {string} FEATURE_VOICE channel supports voice messages
*/
this.FEATURE_VOICE = FEATURE_VOICE;
/**
* @constant {string} FEATURE_SSML channel supports SSML voice messages
*/
this.FEATURE_SSML = FEATURE_SSML;
/**
* @constant {string} FEATURE_PHRASES channel supports expected phrases messages
*/
this.FEATURE_PHRASES = FEATURE_PHRASES;
/**
* @constant {string} FEATURE_TEXT channel supports text communication
*/
this.FEATURE_TEXT = FEATURE_TEXT;
/**
* @constant {string} FEATURE_TRACKING channel supports tracking protocol
*/
this.FEATURE_TRACKING = FEATURE_TRACKING;
/** @type {C} */
this.configuration = configuration;
}
get data () {
// eslint-disable-next-line
console.info('wingbot: req.data is deprecated, use req.event instead');
return this._event;
}
/**
* The original messaging event
*
* @type {object}
*/
get event () {
return this._event;
}
/**
* Returns true if a channel supports specified feature
*
* @param {string} feature
* @returns {boolean}
*/
supportsFeature (feature) {
return this.features.includes(feature);
}
/**
* Returns true, if the incoming event is standby
*
* @returns {boolean}
*/
isStandby () {
return !!this._event.isStandby;
}
/**
* Get all matched actions from NLP intents
*
* @param {boolean} [local]
* @returns {IntentAction[]}
*/
aiActions (local = false) {
if (local) {
return this._resolveQuickReplyActions();
}
this.aiActionsWinner();
return this._aiActions;
}
/**
* Covert all matched actions for disambiguation purposes
*
* @param {number} [limit]
* @param {IntentAction[]} [aiActions]
* @param {string} [overrideAction]
* @returns {QuickReplyDisambiguation[]}
*/
aiActionsForQuickReplies (limit = 5, aiActions = null, overrideAction = null) {
if (aiActions === null) {
this.aiActionsWinner();
}
const text = this.text();
const knownTexts = new Set();
return (aiActions || this._aiActions)
.filter((a) => a.title)
.slice(0, limit)
.map((a) => {
const {
action,
intent = { intent: null },
setState = null,
data = {},
match = null,
title
} = a;
const entities = intent.entities || [];
let templateData = {
...stateData(this),
...getSetState(setState || {}, this),
intent: intent.intent,
entities
};
Object.keys(templateData)
.forEach((key) => {
if (key.match(/^@/)) {
delete templateData[key];
}
});
templateData = entities.reduceRight((o, e) => ({
...o,
[`@${e.entity}`]: e.value
}), templateData);
const textTemplate = typeof title === 'function'
? title
: cachedTranslatedCompilator(title);
const res = {
title: textTemplate(templateData),
action: overrideAction || action,
templateData,
...entities.reduceRight((o, e) => ({
...o,
[`@${e.entity}`]: e.value
}), {}),
data: {
...data,
_senderMeta: {
flag: ResponseFlag.DISAMBIGUATION_SELECTED,
likelyIntent: intent.intent,
disambText: text
}
}
};
if (setState) Object.assign(res, { setState });
if (match) Object.assign(res, { match });
return res;
})
.filter((qr) => {
if (knownTexts.has(qr.title)) {
return false;
}
knownTexts.add(qr.title);
return true;
});
}
/**
* Returns true, if there is an action for disambiguation
*
* @param {number} minimum
* @param {boolean} [local]
* @returns {boolean}
*/
hasAiActionsForDisambiguation (minimum = 1, local = false) {
let iterate;
if (local) {
iterate = this._resolveQuickReplyActions();
} else {
this.aiActionsWinner();
iterate = this._aiActions;
}
return iterate
.filter((a) => a.title)
.length >= minimum;
}
/**
* Returns intent, when using AI
*
* @param {boolean|number} getDataOrScore - score limit or true for getting intent data
* @returns {null|string|Intent}
*/
intent (getDataOrScore = false) {
if (this.intents.length === 0) {
return null;
}
let {
_winningIntent: intent = this._winningIntent
} = this.actionData();
if (!intent) [intent] = this.intents;
if (typeof getDataOrScore === 'number') {
return intent.score >= getDataOrScore
? intent.intent
: null;
}
if (getDataOrScore) {
return intent;
}
return intent.intent;
}
// eslint-disable-next-line jsdoc/require-param
/**
* Get matched entity value
*
* @param {string} name - name of requested entity
* @param {number} [sequence] - when there are more then one entity
* @returns {number|string|null}
*/
entity (name, sequence = 0, useSetState = null) {
const cleanName = name.replace(/^@/, '');
const stateKeyName = `@${cleanName}`;
const {
_winningIntent: intent = this._winningIntent
} = this.actionData();
const setState = useSetState || this.getSetState();
let entities;
if (intent && intent.entities) {
({ entities } = intent);
} else if (this.entities.some((e) => e.entity === cleanName)) {
({ entities } = this);
} else if (typeof setState[stateKeyName] !== 'undefined') {
entities = [{ entity: cleanName, value: setState[stateKeyName] }];
} else {
return null;
}
const found = entities
.filter((e) => e.entity === cleanName);
if (found.length <= sequence) {
return null;
}
return found[sequence].value;
}
/**
* Checks, when message contains an attachment (file, image or location)
*
* @returns {boolean}
*/
isAttachment () {
return this.attachments.length > 0;
}
/**
* Orchestrator: check, if the request updates only $context variables
*
* - when no variables to check provided,
* returns false when `set_context` is bundled within another conversational event
*
*
* @param {string[]} varsToCheck - list of variables to check
*/
isSetContext (varsToCheck = []) {
if (this.event.set_context === null
|| typeof this.event.set_context !== 'object') {
return false;
}
if (varsToCheck.length === 0
&& (this.isMessage()
|| this.isPostBack()
|| this.isAttachment())) {
return false;
}
const keys = Object.keys(this.event.set_context);
return varsToCheck
.every((v) => keys.includes(v.replace(/^§/, '')));
}
/**
* Orchestrator: get current thread context update
*
* @param {boolean} [includeContextSync]
* @returns {object} - with `§` prefixed keys
*/
getSetContext (includeContextSync = false) {
const read = includeContextSync && typeof this.event.context === 'object'
? { ...this.event.context, ...this.event.set_context }
: this.event.set_context;
if (!read) {
return {};
}
return Object.keys(read)
.reduce((o, key) => Object.assign(o, {
[`§${key}`]: read[key]
}), {});
}
_checkAttachmentType (type, attachmentIndex = 0) {
if (this.attachments.length <= attachmentIndex) {
return false;
}
return this.attachments[attachmentIndex].type === type;
}
/**
* Checks, when the attachment is an image, but not a sticker
*
* @param {number} [attachmentIndex=0] - use, when user sends more then one attachment
* @param {boolean} [includingStickers] - return true, when the image is also a sticker
* @returns {boolean}
*/
isImage (attachmentIndex = 0, includingStickers = false) {
return this._checkAttachmentType('image', attachmentIndex)
&& (includingStickers
|| !this.isSticker(true));
}
/**
* Checks, when the attachment is a file
*
* @param {number} [attachmentIndex=0] - use, when user sends more then one attachment
* @returns {boolean}
*/
isFile (attachmentIndex = 0) {
return this._checkAttachmentType('file', attachmentIndex);
}
/**
* Checks for location in attachments
*
* @returns {boolean}
*/
hasLocation () {
return this.attachments.some((at) => at.type === 'location');
}
/**
* Gets location coordinates from attachment, when exists
*
* @returns {null|{lat:number,long:number}}
*
* @example
* const { Router } = require('wingbot');
*
* const bot = new Router();
*
* bot.use('start', (req, res) => {
* res.text('share location?', [
* // location share quick reply
* { action: 'locAction', title: 'Share location', isLocation: true }
* ]);
* });
*
* bot.use('locAction', (req, res) => {
* if (req.hasLocation()) {
* const { lat, long } = req.getLocation();
* res.text(`Got ${lat}, ${long}`);
* } else {
* res.text('No location received');
* }
* });
*/
getLocation () {
const location = this.attachments.find((at) => at.type === 'location');
if (!location) {
return null;
}
return location.payload.coordinates;
}
/**
* Returns whole attachment or null
*
* @param {number} [attachmentIndex=0] - use, when user sends more then one attachment
* @returns {object|null}
*/
attachment (attachmentIndex = 0) {
if (this.attachments.length <= attachmentIndex) {
return null;
}
return this.attachments[attachmentIndex];
}
/**
* Returns attachment URL
*
* @param {number} [attachmentIndex=0] - use, when user sends more then one attachment
* @returns {string|null}
*/
attachmentUrl (attachmentIndex = 0) {
if (this.attachments.length <= attachmentIndex) {
return null;
}
const { payload } = this.attachments[attachmentIndex];
if (!payload) {
return null;
}
return payload && payload.url;
}
/**
* Returns true, when the request is text message, quick reply or attachment
*
* @returns {boolean}
*/
isMessage () {
return this.message !== null;
}
/**
* Check, that message is a quick reply
*
* @returns {boolean}
*/
isQuickReply () {
return this.message !== null && !!this.message.quick_reply;
}
/**
* Check, that message is PURE text
*
* @returns {boolean}
*/
isText () {
return (this._postback === null
&& this.message !== null
&& !this.message.quick_reply
&& typeof this.message.text === 'string')
|| this._stickerToSmile() !== '';
}
isTextOrIntent () {
return this.isText()
|| this.intents.length > 0
|| this.entities.length > 0;
}
/**
* Returns true, when the attachment is a sticker
*
* @param {boolean} [includeToTextStickers] - including strickers transformed into a text
*
* @returns {boolean}
*/
isSticker (includeToTextStickers = false) {
return this.attachments.length === 1
&& this.attachments[0].type === 'image'
&& typeof this.attachments[0].payload === 'object'
&& this.attachments[0].payload !== null
&& typeof this.attachments[0].payload.sticker_id !== 'undefined'
&& (includeToTextStickers
|| this._stickerIdToText(this.attachments[0].payload.sticker_id) === null);
}
_stickerIdToText (stickerId) {
switch (stickerId) {
case 369239263222822:
return '👍';
default:
return null;
}
}
_stickerToSmile () {
if (!this.isSticker(true)) {
return '';
}
return this._stickerIdToText(this.attachments[0].payload.sticker_id) || '';
}
/**
* Returns text of the message
*
* @param {boolean} [tokenized=false] - when true, message is normalized to lowercase with `-`
* @returns {string}
*
* @example
* console.log(req.text(true)) // "can-you-help-me"
*/
text (tokenized = false) {
if (this.message === null) {
return '';
}
const { text } = this.message;
if (tokenized && text) {
return tokenize(text) || text.trim();
}
return text || this._stickerToSmile() || '';
}
/**
* Returns all text message alternatives including it's score
*
* @returns {TextAlternative[]}
*/
textAlternatives () {
if (!this.message || typeof this.message.text !== 'string') {
return [];
}
const { text: messageText, alternatives = [] } = this.message;
const unique = new Set();
let max = 0;
const sorted = alternatives
.slice()
.map(({ text, score = 1 }) => ({ text, score }))
.sort(({ score: a }, { score: z }) => z - a)
.filter((a) => {
const norm = tokenize(a.text);
if (unique.has(norm) || !a.text) {
return false;
}
max = Math.max(max, a.score);
unique.add(norm);
return true;
});
const normalized = tokenize(messageText);
if (unique.has(normalized)) {
return sorted;
}
max = max ? Math.min(1, max + 0.1) : 1;
return [
{ text: messageText, score: max },
...sorted
];
}
/**
* Returns the request expected handler in case have been set last response
*
* @returns {Action|null}
*/
expected () {
// @ts-ignore
return this.state._expected || null;
}
/**
* Returns all expected keywords for the next request (just expected keywords)
*
* @param {boolean} [justOnce] - - don't return already retained items
* @example
*
* bot.use('my-route', (req, res) => {
* res.setState(req.expectedKeywords());
* });
*/
expectedKeywords (justOnce = false) {
const {
// @ts-ignore
_expectedKeywords: exKeywords
} = this.state;
if (!exKeywords) {
return {};
}
if (!justOnce) {
return { _expectedKeywords: exKeywords };
}
return {
_expectedKeywords: exKeywords
// @ts-ignore
.filter(({ data = {} }) => !data._expectedFallbackOccured)
.map((keyword) => ({
...keyword,
data: {
...(keyword.data || {}),
_expectedFallbackOccured: true
}
}))
};
}
/**
* Returns current turn-around context (expected and expected keywords)
*
* @param {boolean} [justOnce] - don't return already retained items
* @param {boolean} [includeKeywords] - keep intents from quick replies
* @returns {object}
* @example
*
* bot.use('my-route', (req, res) => {
* res.setState(req.expectedContext());
* });
*/
expectedContext (justOnce = false, includeKeywords = false) {
const ad = this.actionData();
// @ts-ignore
const expected = ad._useExpected || this.state._expected;
// @ts-ignore
const confident = this.state._expectedConfidentInput;
const ret = {};
let shouldIncludeKeywords = includeKeywords;
if (expected) {
const { action, data = {} } = expected;
if (!data._expectedFallbackOccured || !justOnce) {
Object.assign(ret, {
_expected: {
action,
data: {
...data,
_expectedFallbackOccured: true
}
}
});
} else if (justOnce) {
shouldIncludeKeywords = false;
}
}
if (shouldIncludeKeywords) {
Object.assign(ret, {
...this.expectedKeywords(justOnce)
});
// get entities
Object.keys(this.state)
.forEach((k) => {
const match = k.match(/^_~(.+)$/);
if (!match) {
return;
}
const [, key] = match;
Object.assign(ret, vars.preserveMeta(key, this.state[key], this.state));
});
}
if (confident) {
Object.assign(ret, {
_expectedConfidentInput: true
});
}
return ret;
}
/**
* Returns action or data of quick reply
* When `getData` is `true`, object will be returned. Otherwise string or null.
*
* @param {boolean} [getData=false]
* @returns {null|string|object}
*
* @example
* typeof res.quickReply() === 'string' || res.quickReply() === null;
* typeof res.quickReply(true) === 'object';
*/
quickReply (getData = false) {
if (this.message === null
|| !this.message.quick_reply) {
return null;
}
return this._processPayload(this.message.quick_reply, getData);
}
/**
* Returns true, if request is the postback
*
* @returns {boolean}
*/
isPostBack () {
return this._postback !== null;
}
/**
* Returns true, if request is the referral
*
* @returns {boolean}
*/
isReferral () {
return this._referral !== null;
}
/**
* Returns true, if request is the optin
*
* @returns {boolean}
*/
isOptin () {
return this._optin !== null;
}
/**
* Sets the action and returns previous action
*
* @param {string|Action|null} action
* @param {object} [data]
* @returns {Action|null|undefined} - previous action
*/
setAction (action, data = {}) {
// fetch previous action
const previousAction = this._action;
if (typeof action === 'object' || typeof action === 'undefined') { // accepts also a null
this._action = action;
} else {
this._action = { action, data };
}
return previousAction;
}
/**
* Returns action of the postback or quickreply
*
* the order, where from the action is resolved
*
* 1. referral
* 2. postback
* 2. optin
* 3. quick reply
* 4. expected keywords & intents
* 5. expected action in state
* 6. global or local AI intent action
*
* @param {boolean} [getData=false] - deprecated
* @returns {null|string}
*
* @example
* typeof res.action() === 'string' || res.action() === null;
* typeof res.actionData() === 'object';
*/
action (getData = false) {
if (typeof this._action === 'undefined') {
this._action = this._resolveAction();
}
if (getData) {
// eslint-disable-next-line no-console
console.info('wingbot: deprecated using req.action(true), use req.actionData() instead');
return this._action ? this._action.data : {};
}
return this._action && this._action.action;
}
/**
* Returns action data of postback or quick reply
*
* @returns {object}
*/
actionData () {
if (typeof this._action === 'undefined') {
this._action = this._resolveAction();
}
return this._action ? this._action.data : {};
}
// eslint-disable-next-line jsdoc/require-param
/**
* Gets incomming setState action variable
*
* @param {AiSetStateOption} keysFromAi
* @returns {object}
*
* @example
* res.setState(req.getSetState());
*/
getSetState (keysFromAi = this.AI_SETSTATE.INCLUDE, useState = null) {
if (typeof this._action === 'undefined') {
this._action = this._resolveAction();
}
let setState = (this._action && this._action.setState)
|| this._event.setState;
// orchestrators context updates
if (this.event.set_context || this.event.context) {
const updatedProps = this.getSetContext(true);
setState = {
...setState,
...updatedProps
};
}
if (!setState || typeof setState !== 'object') {
return {};
}
if (keysFromAi === this.AI_SETSTATE.INCLUDE) {
return getSetState(setState, this, null, useState);
}
// @ts-ignore
const { _aiKeys: aiKeys = [] } = this._action || {};
const ret = {};
const findEntity = [
this.AI_SETSTATE.EXCLUDE_WITHOUT_SET_ENTITIES,
this.AI_SETSTATE.EXCLUDE_WITH_SET_ENTITIES
].includes(keysFromAi);
Object.keys(setState)
.forEach((key) => {
const isAiKey = aiKeys.includes(key);
if (findEntity && !isAiKey) {
const isEntity = key.match(/^@/);
if ((isEntity && keysFromAi === this.AI_SETSTATE.EXCLUDE_WITH_SET_ENTITIES)
|| (!isEntity
&& keysFromAi === this.AI_SETSTATE.EXCLUDE_WITHOUT_SET_ENTITIES)) {
ret[key] = setState[key];
}
} else if ((isAiKey && keysFromAi === this.AI_SETSTATE.ONLY)
|| (!isAiKey && keysFromAi === this.AI_SETSTATE.EXCLUDE)) {
ret[key] = setState[key];
}
});
return getSetState(ret, this, null, useState);
}
/**
* Returns true, if previous request has been
* marked as confident using `res.expectedConfidentInput()`
*
* It's good to consider this state in "analytics" integrations.
*
* @returns {boolean}
*/
isConfidentInput () {
// @ts-ignore
return this.state._expectedConfidentInput === true;
}
_resolveAction () {
let res = null;
if (this._referral !== null && this._referral.ref) {
res = parseActionPayload({ payload: this._referral.ref });
}
if (!res && this._postback !== null) {
res = parseActionPayload(this._postback);
}
if (!res && this._optin !== null && this._optin.ref) {
res = this._base64Ref(this._optin);
}
if (!res && this._optin !== null && this._optin.payload) {
res = parseActionPayload(this._optin);
}
if (!res && this.message !== null && this.message.quick_reply) {
res = parseActionPayload(this.message.quick_reply);
}
// @ts-ignore
if (!res && this.state._expectedKeywords) {
// @ts-ignore
res = this._actionByExpectedKeywords(this.state._expected);
}
// @ts-ignore
if (!res && this.state._expected) {
// @ts-ignore
res = parseActionPayload(this.state._expected);
}
if (res) {
// find global intent
let entitiesSetState = {};
let { setState = {} } = res;
for (const gi of this.globalIntents.values()) {
if (gi.action === res.action) {
entitiesSetState = { ...gi.entitiesSetState };
const values = Array.from(Object.values(entitiesSetState));
for (const value of values) {
if (typeof value === 'function') {
Object.assign(entitiesSetState, value(stateData(this)));
}
}
}
}
const newState = {
...entitiesSetState,
...setState
};
checkSetState(setState, newState);
setState = newState;
const aiKeysSet = new Set([
...(res._aiKeys || []),
...Object.keys(entitiesSetState)
]);
const aiKeys = Array.from(aiKeysSet)
.filter((k) => typeof setState[k] !== 'undefined' && k.startsWith('@'));
return {
...res,
setState,
_aiKeys: aiKeys
};
}
if (this.isTextOrIntent()) {
const winner = this.aiActionsWinner();
if (winner) {
const _aiKeys = winner.setState ? Object.keys(winner.setState) : [];
res = {
action: winner.action, data: {}, setState: winner.setState, _aiKeys
};
}
}
return res;
}
/**
* Returs action string, if there is an action detected by NLP
*
* > use rather designer's bounce feature instead of this pattern
*
* @returns {string|null}
* @example
*
* const { Router } = require('wingbot');
*
* const bot = new Router();
*
* bot.use('question', (req, res) => {
* res.text('tell me your email')
* .expected('email');
* });
*
* bot.use('email', async (req, res, postBack) => {
* if (req.actionByAi()) {
* await postBack(req.actionByAi(), {}, true);
* return;
* }
* res.text('thank you for your email');
* res.setState({ email: req.text() });
* });
*
*/
actionByAi () {
const winner = this.aiActionsWinner();
return winner ? winner.action : null;
}
_getLocalPathRegexp () {
// @ts-ignore
if (this.state._lastVisitedPath) {
// @ts-ignore
return new RegExp(`^${this.state._lastVisitedPath}/[^/]+`);
}
let expected = this.expected();
if (expected) {
// @ts-ignore
expected = expected.action.replace(/\/?[^/]+$/, '');
return new RegExp(`^${expected}/[^/]+$`);
}
return null;
}
/**
* Returns full detected AI action
*
* @returns {IntentAction|null}
*/
aiActionsWinner () {
if (this._aiActions) {
return this._aiWinner;
}
if (!this.isTextOrIntent()) {
this._aiActions = [];
return null;
}
const aiActions = [];
// to match the local context intent
const localRegexToMatch = this._getLocalPathRegexp();
for (const gi of this.globalIntents.values()) {
const pathMatches = localRegexToMatch && localRegexToMatch.exec(gi.action);
if (gi.local && !pathMatches) {
continue;
}
const intent = gi.matcher(this, null, true);
if (intent !== null) {
const sort = intent.score + (pathMatches
? Ai.ai.localEnhancement
: 0);
// console.log(sort, wi.intent);
aiActions.push({
...gi,
intent,
setState: intent.setState,
aboveConfidence: intent.aboveConfidence,
sort,
winner: false
});
}
}
aiActions.sort((l, r) => r.sort - l.sort);
const winner = this._winner(aiActions);
this._aiActions = aiActions;
this._aiWinner = winner;
return winner;
}
_winner (aiActions) {
if (aiActions.length === 0 || !aiActions[0].aboveConfidence) {
return null;
}
// there will be no winner, if there are two different intents
if (Ai.ai.shouldDisambiguate(aiActions)) {
return null;
}
if (aiActions[0]) {
// eslint-disable-next-line no-param-reassign
aiActions[0].winner = true;
}
return aiActions[0];
}
_actionByExpectedKeywords (expected) {
// @ts-ignore
if (!this.state._expectedKeywords) {
return null;
}
const actions = this._resolveQuickReplyActions();
if (expected && Ai.ai.shouldDisambiguate(actions, true)) {
return parseActionPayload(expected);
}
const [payload] = actions;
if (!payload || !payload.aboveConfidence) {
return null;
}
return parseActionPayload(payload);
}
_resolveQuickReplyActions () {
if (this._quickReplyActions === null) {
// @ts-ignore
if (this.state._expectedKeywords) {
this._quickReplyActions = quickReplyAction(
// @ts-ignore
this.state._expectedKeywords,
this,
Ai.ai
);
} else {
this._quickReplyActions = [];
}
}
return this._quickReplyActions;
}
/**
* Returns action or data of postback
* When `getData` is `true`, object will be returned. Otherwise string or null.
*
* @param {boolean} [getData=false]
* @returns {null|string|object}
*
* @example
* typeof res.postBack() === 'string' || res.postBack() === null;
* typeof res.postBack(true) === 'object';
*/
postBack (getData = false) {
if (this._postback === null) {
return null;
}
return this._processPayload(this._postback, getData);
}
_base64Ref (object = {}) {
let process = {};
if (object && object.ref) {
let payload = object.ref;
if (typeof payload === 'string' && payload.match(BASE64_REGEX)) {
payload = Buffer.from(payload, 'base64').toString('utf8');
}
process = { payload };
}
return parseActionPayload(process);
}
_processPayload (object = {}, getData = false) {
if (getData) {
const { data } = parseActionPayload(object, true);
return data;
}
const { action } = parseActionPayload(object, true);
return action;
}
/**
* @returns {string[]}
*/
expectedEntities () {
const {
// @ts-ignore
_expectedKeywords: exKeywords
} = this.state;
if (exKeywords) {
const entities = exKeywords.reduce((arr, expectedKeyword) => {
const got = Ai.ai.matcher.parseEntitiesFromIntentRule(expectedKeyword.match, true);
arr.push(...got);
return arr;
}, []);
if (entities.length !== 0) {
return entities;
}
}
const localRegexToMatch = this._getLocalPathRegexp();
const entitySet = new Set();
for (const gi of this.globalIntents.values()) {
const pathMatches = localRegexToMatch && localRegexToMatch.exec(gi.action);
if (gi.local && !pathMatches) {
continue;
}
gi.usedEntities.forEach((e) => entitySet.add(e));
}
return Array.from(entitySet.values());
}
get orchestratorClient () {
// eslint-disable-next-line no-console
console.log('req.orchestratorClient is deprecated, use req.orchestrator instead');
return this.orchestrator;
}
get orchestrator () {
if (this._orchestrator) {
return this._orchestrator;
}
const missingProps = ['apiUrl', 'secret', 'appId']
.filter((p) => this._orchestratorClientOptions[p] === null
|| this._orchestratorClientOptions[p] === undefined);
if (missingProps.length > 0) {
throw new Error(
`Missing mandatory properties: ${missingProps.join(',')} which are need to connect to orchestrator!
It looks like the bot isn't connected to class BotApp or the Processor is used without a BotApp`
);
}
if (!this._orchestratorClientOptions.pageId) {
throw new Error(
'Request doesn\'t receive \'pageId\' from Processor!'
);
}
this._orchestrator = new OrchestratorClient(this._orchestratorClientOptions);
return this._orchestrator;
}
static timestamp () {
return makeTimestamp();
}
static createReferral (action, data = {}, timestamp = makeTimestamp()) {
return {
timestamp,
ref: JSON.stringify({
action,
data
}),
source: 'SHORTLINK',
type: 'OPEN_THREAD'
};
}
static postBack (
senderId,
action,
data = {},
refAction = null,
refData = {},
timestamp = makeTimestamp(),
features = [FEATURE_TEXT]
) {
const postback = {
payload: {
action,
data
}
};
if (refAction) {
Object.assign(postback, {
referral: Request.createReferral(refAction, refData, timestamp)
});
}
return {
timestamp,
sender: {
id: senderId
},
postback,
features
};
}
static postBackWithSetState (
senderId,
action,
data = {},
setState = {},
timestamp = makeTimestamp()
) {
const postback = {
payload: {
action,
data,
setState
}
};
return {
timestamp,
sender: {
id: senderId
},
postback
};
}
static campaignPostBack (
senderId,
campaign,
timestamp = makeTimestamp(),
data = null,
taskId = null,
setState = null
) {
const postback = Request.postBack(
senderId,
campaign.action,
data || campaign.data,
null,
{},
timestamp
);
if (setState) {
Object.assign(postback.postback.payload, { setState });
}
return Object.assign(postback, {
campaign,
taskId
});
}
static text (senderId, text, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
text
}
};
}
static textWithSetState (senderId, text, setState, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
text
},
setState
};
}
static intentWithText (senderId, text, intent, score = 1, timestamp = makeTimestamp()) {
const res = Request.text(senderId, text, timestamp);
return Request.addIntentToRequest(res, intent, [], score);
}
static intent (senderId, intent, score = 1, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
intent,
score
};
}
static intentWithSetState (senderId, intent, setState, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
intent,
score: 1,
setState
};
}
static addIntentToRequest (request, intent, entities = [], score = 1) {
Object.assign(request, {
intent, score
});
if (entities.length !== 0) {
Object.assign(request, { entities });
}
return request;
}
static passThread (senderId, newAppId, data = null, timestamp = makeTimestamp()) {
let metadata = data;
if (data !== null && typeof data !== 'string') {
metadata = JSON.stringify(data);
}
return {
timestamp,
sender: {
id: senderId
},
pass_thread_control: {
new_owner_app_id: newAppId,
metadata
}
};
}
static intentWithEntity (
senderId,
text,
intent,
entity,
value,
score = 1,
entityScore = Math.max(score, 0.835),
timestamp = makeTimestamp()
) {
const res = Request.text(senderId, text, timestamp);
return Request.addIntentToRequest(res, intent, [
{ entity, value, score: entityScore }
], score);
}
static quickReply (senderId, action, data = {}, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
text: action,
quick_reply: {
payload: JSON.stringify({
action,
data
})
}
}
};
}
static quickReplyText (senderId, text, payload, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
text,
quick_reply: {
payload
}
}
};
}
static location (senderId, lat, long, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
attachments: [{
type: 'location',
payload: {
coordinates: { lat, long }
}
}]
}
};
}
static referral (senderId, action, data = {}, timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
referral: Request.createReferral(action, data, timestamp)
};
}
static optin (userRef, action, data = {}, timestamp = makeTimestamp()) {
const ref = Buffer.from(JSON.stringify({
action,
data
}));
return {
timestamp,
optin: {
ref: ref.toString('base64'),
user_ref: userRef
}
};
}
static oneTimeOptIn (senderId, token, action, data = {}, type = 'one_time_notif_req', timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
optin: {
type,
payload: JSON.stringify({ action, data }),
one_time_notif_token: token
}
};
}
static fileAttachment (senderId, url, type = 'file', timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
attachments: [{
type,
payload: {
url
}
}]
}
};
}
static sticker (senderId, stickerId, url = '', timestamp = makeTimestamp()) {
return {
timestamp,
sender: {
id: senderId
},
message: {
attachments: [{
type: 'image',
payload: {
url,
sticker_id: stickerId
}
}]
}
};
}
static readEvent (senderId, watermark, timestamp = makeTimestamp()) {
return {
sender: {
id: senderId
},
timestamp,
read: {
watermark
}
};
}
static deliveryEvent (senderId, watermark, timestamp = makeTimestamp()) {
return {
sender: {
id: senderId
},
timestamp,
delivery: {
watermark
}
};
}
}
/**
* @constant {string} FEATURE_VOICE channel supports voice messages
*/
Request.FEATURE_VOICE = FEATURE_VOICE;
/**
* @constant {string} FEATURE_SSML channel supports SSML voice messages
*/
Request.FEATURE_SSML = FEATURE_SSML;
/**
* @constant {string} FEATURE_PHRASES channel supports expected phrases messages
*/
Request.FEATURE_PHRASES = FEATURE_PHRASES;
/**
* @constant {string} FEATURE_TEXT channel supports text communication
*/
Request.FEATURE_TEXT = FEATURE_TEXT;
/**
* @constant {string} FEATURE_TRACKING channel supports tracking protocol
*/
Request.FEATURE_TRACKING = FEATURE_TRACKING;
module.exports = Request;