wingbot
Version:
Enterprise Messaging Bot Conversation Engine
677 lines (599 loc) • 19.9 kB
JavaScript
/*
* @author David Menger
*/
;
const assert = require('assert');
const { inspect } = require('util');
const deepExtend = require('deep-extend');
const Processor = require('./Processor');
const Request = require('./Request');
const { MemoryStateStorage } = require('./tools');
const ReturnSender = require('./ReturnSender');
const { actionMatches, parseActionPayload, tokenize } = require('./utils');
const { asserts } = require('./testTools');
const AnyResponseAssert = require('./testTools/AnyResponseAssert');
const ResponseAssert = require('./testTools/ResponseAssert');
const Router = require('./Router'); // eslint-disable-line no-unused-vars
const ReducerWrapper = require('./ReducerWrapper'); // eslint-disable-line no-unused-vars
const { FEATURE_TEXT } = require('./features');
const LLMMockProvider = require('./LLMMockProvider');
const PromptAssert = require('./testTools/PromptAssert');
/** @typedef {import('./Processor').ProcessorOptions<Router>} ProcessorOptions */
/** @typedef {import('./LLM').PromptInfo} PromptInfo */
/** @typedef {import('./LLM').LLMRole} LLMRole */
/** @typedef {import('./LLM').LLMMessage} LLMMessage */
/**
* Utility for testing requests
*
* @class Tester
*/
class Tester {
/**
* Creates an instance of Tester.
*
* @param {Router|ReducerWrapper} reducer
* @param {string} [senderId=null]
* @param {string} [pageId=null]
* @param {ProcessorOptions} [processorOptions={}] - options for Processor
* @param {MemoryStateStorage} [storage] - place to override the storage
*
* @memberOf Tester
*/
constructor (
reducer,
senderId = null,
pageId = null,
processorOptions = {},
storage = new MemoryStateStorage()
) {
this._sequence = 0;
this._actionsCollector = [];
this._pluginBlocksCollector = [];
this.storage = storage;
this.senderId = senderId || `${Math.random() * 1000}${Date.now()}`;
this.pageId = pageId || `${Math.random() * 1000}${Date.now()}`;
// replace logger (throw instead of log)
const log = {
error: (e, f) => {
let t;
if (e instanceof Error) t = e;
else if (typeof e === 'string' && f instanceof Error) t = new Error(`${e}: ${f.message}`);
else if (f instanceof Error) t = f;
else if (typeof e === 'string') t = new Error(e);
else t = e;
throw t;
},
warn: e => console.warn(e), // eslint-disable-line
log: e => console.log(e), // eslint-disable-line
info: e => console.info(e) // eslint-disable-line
};
this._cachedGiMap = null;
this._listener = (senderIdentifier, action, text, req, prevAction, doNotTrack) => {
const reqAction = req.action();
if (reqAction
&& !this._actionMatches(action, reqAction)
&& this._actionHasGlobalIntent(reqAction)) {
this._actionsCollector.push({
action: reqAction, text, prevAction, doNotTrack, isReqAction: true
});
}
this._actionsCollector.push({
action, text, prevAction, doNotTrack, isReqAction: false
});
};
// @ts-ignore
reducer.on('_action', this._listener);
/** @type {Processor} */
this.processor = new Processor(reducer, ({
stateStorage: this.storage,
log,
// @ts-ignore
loadUsers: false,
llm: {
provider: new LLMMockProvider(),
...processorOptions.llm
},
...processorOptions
}));
// attach the plugin tester
this.processor.plugin({
processMessage: () => ({ status: 204 }),
beforeProcessMessage: (req, res) => {
req.params = {};
Object.assign(res, {
_pluginBlocksCollector: this._pluginBlocksCollector,
run: (blockName) => {
this._pluginBlocksCollector.push(blockName);
return Promise.resolve();
}
});
return true;
}
});
this.pluginBlocks = [];
this.responses = [];
this.actions = [];
/** @type {PromptInfo[]} */
this.prompts = [];
/**
* @prop {object} predefined test data to use
*/
this.testData = {
automatedTesting: true
};
/**
* @prop {boolean} allow tester to process empty responses
*/
this.allowEmptyResponse = false;
/**
* @prop {console} use own loggger
*/
this.senderLogger = undefined;
/**
* @prop {string[]}
*/
this.features = null;
/**
* @prop {string}
*/
this.ATTACHMENT_MOCK_URL = 'http://mock.url/file.txt';
}
_actionHasGlobalIntent (action) {
if (!this.processor.reducer
|| !('globalIntents' in this.processor.reducer)) {
return false;
}
if (this._cachedGiMap === null) {
this._cachedGiMap = new Set();
for (const value of this.processor.reducer.globalIntents.values()) {
this._cachedGiMap.add(value.action);
}
}
return this._cachedGiMap.has(action.replace(/^\/?/, '/'));
}
dealloc () {
this.processor.reducer
.removeListener('_action', this._listener);
this.processor.reducer = null;
this._cachedGiMap = null;
}
/**
* Enable tester to expand random texts
* It joins them into a single sting
*
* @param {boolean} [fixedIndex]
*/
setExpandRandomTexts (fixedIndex) {
Object.assign(this.testData, {
_expandRandomTexts: fixedIndex ? 1 : true
});
}
/**
* Clear acquired responses and data
*/
cleanup () {
this.pluginBlocks = [];
this.responses = [];
this.actions = [];
this._actionsCollector = [];
this._pluginBlocksCollector = [];
this._responsesMock = [];
this.prompts = [];
}
/**
* Set features for all messages
*
* @param {string[]} [features]
*/
setFeatures (features = [FEATURE_TEXT]) {
this.features = features;
}
/**
* Use tester as a connector :)
*
* @param {object} message - wingbot chat event
* @param {string} senderId - chat event sender identifier
* @param {string} pageId - channel/page identifier
* @param {object} [data] - additional data
* @returns {Promise<any>}
*/
async processMessage (message, senderId = this.senderId, pageId = this.pageId, data = {}) {
if (!message.sender && !message.optin) {
Object.assign(message, {
sender: { id: senderId }
});
}
if (this.features) {
Object.assign(message, { features: this.features });
}
const messageSender = new ReturnSender({
dontWaitForDeferredOps: false
}, senderId, message, this.senderLogger);
messageSender.simulatesOptIn = true;
const res = await this.processor
.processMessage(message, pageId, messageSender, { ...data, ...this.testData });
this._acquireResponseActions(res, messageSender);
return res;
}
_acquireResponseActions (res, messageSender) {
if (res.status !== 200
&& !(res.status === 204 && this._pluginBlocksCollector.length > 0)
&& !(res.status === 204 && this.allowEmptyResponse)) {
this.debug();
if (res.status === 204) {
throw Object.assign(new Error(`Bot did not respond (status ${res.status})`), { code: res.status });
}
throw Object.assign(new Error(`Processor failed with status ${res.status}`), { code: res.status });
}
this.responses = messageSender.responses;
this.prompts = messageSender.prompts;
this.pluginBlocks = this._pluginBlocksCollector;
this.actions = this._actionsCollector;
this._actionsCollector = [];
this._pluginBlocksCollector = [];
this._responsesMock = [];
return res;
}
/**
* Returns single response asserter
*
* @param {number} [index=0] - response index
* @returns {ResponseAssert}
*
* @memberOf Tester
*/
res (index = 0) {
if (this.responses.length <= index && index !== -1) {
assert.fail(`Response ${index} does not exists. There are ${this.responses.length} responses`);
}
return new ResponseAssert(this.responses[index]);
}
/**
* Returns any response asserter
*
* @returns {AnyResponseAssert}
*
* @memberOf Tester
*/
any () {
return new AnyResponseAssert(this.responses);
}
/**
* Returns last response asserter
*
* @returns {ResponseAssert}
*
* @memberOf Tester
*/
lastRes () {
if (this.responses.length === 0) {
assert.fail('Theres no response');
}
return new ResponseAssert(this.responses[this.responses.length - 1]);
}
_actionMatches (botAction, path) {
return botAction === path
|| (path === '*' && botAction === '/*')
|| (!botAction.match(/\*/) && actionMatches(botAction, path));
}
_actionsDebug (matchRequestActions = false) {
const set = new Set();
return this.actions
.filter((a) => !a.isReqAction || matchRequestActions)
.map((a) => (a.doNotTrack ? `(system interaction) ${a.action}` : a.action))
.filter((a) => !set.has(a) && set.add(a));
}
/**
* Checks, that request passed an interaction
*
* @param {string} path
* @param {boolean} [matchRequestActions]
* @returns {this}
*
* @memberOf Tester
*/
passedAction (path, matchRequestActions = false) {
const ok = this.actions
.some((action) => (!action.isReqAction || matchRequestActions)
&& this._actionMatches(action.action, path));
let actual;
if (!ok) {
actual = this._actionsDebug(matchRequestActions);
assert.fail(asserts.ex('Interaction was not passed', path, actual));
}
return this;
}
/**
* Checks, that a plugin used a block as a response
*
* @param {string} blockName
* @returns {this}
*
* @memberOf Tester
*/
respondedWithBlock (blockName) {
const ok = this.pluginBlocks.includes(blockName);
const actual = this.pluginBlocks.length === 0
? 'None'
: this.pluginBlocks.map((b) => `"${b}"`).join(', ');
assert.ok(ok, `Expected "${blockName}" to be used as a response. ${actual} blocks was tiggered.`);
return this;
}
/**
* Returns state
*
* @returns {object}
*
* @memberOf Tester
*/
getState () {
return this.storage.getOrCreateStateSync(
this.senderId,
this.pageId,
{ ...this.processor.options.defaultState }
);
}
/**
* Sets state with `Object.assign()`
*
* @param {object} [state={}]
*
* @memberOf Tester
*/
setState (state) {
const stateObj = this.getState();
stateObj.state = { ...stateObj.state, ...state };
this.storage.saveState(stateObj);
}
/**
*
* @returns {PromptAssert}
*/
anyPrompt () {
return new PromptAssert(this.prompts);
}
/**
*
* @returns {PromptAssert}
*/
lastPrompt () {
return new PromptAssert(
this.prompts.length
? [this.prompts[this.prompts.length - 1]]
: []
);
}
/**
*
* @returns {LLMMessage}
*/
getLastPromptResult () {
return this.prompts.length
? this.prompts[this.prompts.length - 1].result
: null;
}
/**
* Assert, that state contains a subset of provided value
*
* @param {object} object
* @param {boolean} [deep]
* @example
*
* t.stateContains({ value: true });
*/
stateContains (object, deep = false) {
const { state } = this.getState();
const clean = Object.fromEntries(
Object.entries(object)
.filter(([k, v]) => {
if (v === null || v === undefined) {
assert.ok(state[k] === null || state[k] === undefined, `Expected state key '${k}' to be empty. Actual: ${JSON.stringify(state[k])}'`);
return false;
}
return true;
})
);
assert.deepEqual(
state,
deep ? deepExtend({}, state, clean) : { ...state, ...clean },
'Conversation state equals'
);
}
/**
* Makes text request
*
* @param {string} text
* @returns {Promise}
* @memberOf Tester
*/
text (text) {
return this.processMessage(Request.text(this.senderId, text));
}
/**
* Sends attachment
*
* @param {'image'|'audio'|'video'|'file'} type
* @param {string} [url]
* @returns {Promise}
* @memberOf Tester
*/
attachment (type = 'file', url = this.ATTACHMENT_MOCK_URL) {
return this.processMessage(Request.fileAttachment(this.senderId, url, type));
}
/**
* Makes recognised AI intent request
*
* @param {string|string[]} intent
* @param {string} [text]
* @param {number} [score]
* @returns {Promise}
*
* @memberOf Tester
*/
intent (intent, text = null, score = undefined) {
if (text) {
return this.processMessage(Request.intentWithText(this.senderId, text, intent, score));
}
return this.processMessage(Request.intent(this.senderId, intent, score));
}
/**
* Makes recognised AI intent request with entity
*
* @param {string} intent
* @param {string} entity
* @param {string} [value]
* @param {string} [text]
* @param {number} [score]
* @param {number} [entityScore]
* @returns {Promise}
*
* @memberOf Tester
*/
intentWithEntity (
intent,
entity,
value = entity,
text = intent,
score = 1,
entityScore = score
) {
return this.processMessage(Request
.intentWithEntity(this.senderId, text, intent, entity, value, score, entityScore));
}
/**
* Makes recognised AI request with entity
*
* @param {string} entity
* @param {string} [value]
* @param {string} [text]
* @param {number} [score]
* @returns {Promise}
*
* @memberOf Tester
*/
entity (entity, value = entity, text = value, score = 1) {
return this.processMessage(Request
.intentWithEntity(this.senderId, text, `random-${Date.now()}`, entity, value, score));
}
/**
* Make optin call
*
* @param {string} action
* @param {object} [data={}]
* @param {string} [userRef] - specific ref string
* @returns {Promise}
*
* @memberOf Tester
*/
optin (action, data = {}, userRef = null) {
let useRef = userRef;
if (useRef === null) {
useRef = `${Date.now()}${Math.floor(Date.now() * Math.random())}`;
}
return this.processMessage(Request.optin(useRef, action, data));
}
/**
* Send quick reply
*
* @param {string} action
* @param {object} [data={}]
* @returns {Promise}
*
* @memberOf Tester
*/
quickReply (action, data = {}) {
if (this.responses.length !== 0) {
const last = this.responses[this.responses.length - 1];
const quickReplys = asserts.getQuickReplies(last);
const res = quickReplys
.filter((reply) => {
const { action: route } = parseActionPayload(reply, true);
return route && actionMatches(route, action);
});
if (res[0]) {
const { title, payload } = res[0];
return this.processMessage(Request.quickReplyText(this.senderId, title, payload));
}
}
return this.processMessage(Request.quickReply(this.senderId, action, data));
}
/**
* Send quick reply if text exactly matches, otherwise throws exception
*
* @param {string} text
* @returns {Promise<boolean>}
*
* @memberOf Tester
*/
async quickReplyText (text) {
let but = 'has not been found.';
if (this.responses.length !== 0) {
const normalize = (t) => `${t}`.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
const normalizedText = normalize(text);
const search = tokenize(normalizedText);
const last = this.responses[this.responses.length - 1];
const quickReplys = asserts.getQuickReplies(last);
let res = quickReplys
.filter(({ title = '', payload }) => title && payload && tokenize(title) === search);
if (res.length > 1) {
res = res
.filter(({ title = '' }) => normalize(title) === normalizedText);
}
if (res.length === 1) {
const { title, payload } = res[0];
await this.processMessage(Request.quickReplyText(this.senderId, title, payload));
return true;
}
if (res.length > 1) {
but = 'found, but there are multiple occurences.';
}
but += quickReplys.length
? ` (found: ${quickReplys.map((q) => q.title).filter((q) => !!q).join(', ')})`
: ' (no quick replies available)';
}
throw new Error(`Quick reply "${text}" ${but}`);
}
/**
* Sends postback, optionally with referrer action
*
* @param {string} action
* @param {object} [data={}]
* @param {string} [refAction=null] - referred action
* @param {object} [refData={}] - referred action data
* @returns {Promise}
* @memberOf Tester
*/
postBack (action, data = {}, refAction = null, refData = {}) {
return this.processMessage(Request
.postBack(this.senderId, action, data, refAction, refData, null));
}
/**
* Prints last conversation turnaround
*
* @param {boolean} [full=false]
* @param {boolean} [showPrivateKeys=false]
*/
debug (full = false, showPrivateKeys = false) {
// eslint-disable-next-line no-console
console.log(
'\n====== state ======\n',
Object.fromEntries(
Object.entries(this.getState().state)
.filter((e) => showPrivateKeys || !e[0].startsWith('_'))
),
'\n------- LLM -------\n',
...PromptAssert.debug(this.prompts, full, true),
'\n---- responses ----\n',
inspect(
this.responses.map(({ messaging_type: m, recipient, ...o }) => o),
false,
null,
true
),
'\n----- actions -----\n',
this._actionsDebug(true),
'\n===================\n'
);
}
}
module.exports = Tester;