botium-core
Version:
The Selenium for Chatbots
599 lines (541 loc) • 23.9 kB
JavaScript
const util = require('util')
const async = require('async')
const _ = require('lodash')
const debug = require('debug')('botium-Convo')
const BotiumMockMessage = require('../mocks/BotiumMockMessage')
const Capabilities = require('../Capabilities')
const Events = require('../Events')
const ScriptingMemory = require('./ScriptingMemory')
const { BotiumError, botiumErrorFromErr, botiumErrorFromList } = require('./BotiumError')
const { LOGIC_HOOK_INCLUDE } = require('./logichook/LogicHookConsts')
class ConvoHeader {
constructor (fromJson = {}) {
this.name = fromJson.name
this.sort = fromJson.sort
this.order = fromJson.order
this.description = fromJson.description
}
toString () {
return this.order + ' ' + this.name + (this.description ? ` (${this.description})` : '')
}
}
class ConvoStepAssert {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
}
toString () {
return this.name + '(' + (this.args ? this.args.join(',') : 'no args') + ')'
}
}
class ConvoStepLogicHook {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
}
toString () {
return this.name + '(' + (this.args ? this.args.join(',') : 'no args') + ')'
}
}
class ConvoStepUserInput {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
}
toString () {
return this.name + '(' + (this.args ? this.args.join(',') : 'no args') + ')'
}
}
class ConvoStep {
constructor (fromJson = {}) {
this.sender = fromJson.sender
this.channel = fromJson.channel
this.messageText = fromJson.messageText
this.sourceData = fromJson.sourceData
this.stepTag = fromJson.stepTag
this.not = fromJson.not
this.asserters = _.map(fromJson.asserters, (asserter) => new ConvoStepAssert(asserter))
this.logicHooks = _.map(fromJson.logicHooks, (logicHook) => new ConvoStepLogicHook(logicHook))
this.userInputs = _.map(fromJson.userInputs, (userInput) => new ConvoStepUserInput(userInput))
}
hasInteraction () {
return (this.messageText && this.messageText.length > 0) ||
this.sourceData ||
(this.asserters && this.asserters.length > 0) ||
(this.logicHooks && this.logicHooks.findIndex(l => l.name !== LOGIC_HOOK_INCLUDE) >= 0) ||
(this.userInputs && this.userInputs.length > 0)
}
toString () {
return this.stepTag +
': #' + this.sender +
' - ' + (this.not ? '!' : '') +
this.messageText +
(this.asserters && this.asserters.length > 0 ? ' ' + this.asserters.map(a => a.toString()).join(' ASS: ') : '') +
(this.logicHooks && this.logicHooks.length > 0 ? ' ' + this.logicHooks.map(l => l.toString()).join(' LH: ') : '') +
(this.userInputs && this.userInputs.length > 0 ? ' ' + this.userInputs.map(u => u.toString()).join(' UI: ') : '')
}
}
class Transcript {
constructor ({ steps, scriptingMemory, convoBegin, convoEnd, err }) {
this.steps = steps
this.scriptingMemory = scriptingMemory
this.convoBegin = convoBegin
this.convoEnd = convoEnd
this.err = err
}
}
class TranscriptStep {
constructor ({ expected, not, actual, stepBegin, stepEnd, botBegin, botEnd, err }) {
this.expected = expected
this.not = not
this.actual = actual
this.stepBegin = stepBegin
this.stepEnd = stepEnd
this.botBegin = botBegin
this.botEnd = botEnd
this.err = err
}
}
class TranscriptError extends Error {
constructor (err, transcript) {
super(err.message)
this.name = this.constructor.name
this.transcript = transcript
this.cause = err
Error.captureStackTrace(this, this.constructor)
}
}
class Convo {
constructor (context, fromJson = {}) {
if (fromJson instanceof Convo) {
debug('Illegal state!!! Parameter should be a JSON, but it is a Convo')
} else if (fromJson.beginAsserter) {
// beginAsserter is one of the fields which are lost
debug('Illegal state!!! Parameter should be a native JSON, but looks as a Convo converted to JSON')
}
this.scriptingEvents = context.scriptingEvents
this.context = context
this.header = new ConvoHeader(fromJson.header)
if (fromJson.conversation && _.isArray(fromJson.conversation)) {
this.conversation = _.map(fromJson.conversation, (step) => new ConvoStep(step))
} else {
this.conversation = []
}
this.sourceTag = fromJson.sourceTag
const { beginAsserter, endAsserter } = this.setConvoBeginAndEndAsserter(fromJson)
this.beginAsserter = beginAsserter
this.endAsserter = endAsserter
const { beginLogicHook, endLogicHook } = this.setConvoBeginAndEndLogicHook(fromJson)
this.beginLogicHook = beginLogicHook
this.endLogicHook = endLogicHook
this.effectiveConversation = null
}
setConvoBeginAndEndAsserter (fromJson) {
const beginAsserter = fromJson.conversation
.filter(s => s.sender === 'begin' && s.asserters && s.asserters.length > 0)
.map(s => s.asserters)
.reduce((acc, val) => acc.concat(val), [])
const endAsserter = fromJson.conversation
.filter(s => s.sender === 'end' && s.asserters && s.asserters.length > 0)
.map(s => s.asserters)
.reduce((acc, val) => acc.concat(val), [])
return { beginAsserter, endAsserter }
}
setConvoBeginAndEndLogicHook (fromJson) {
const beginLogicHook = fromJson.conversation
.filter(s => s.sender === 'begin' && s.logicHooks && s.logicHooks.length > 0)
.map(s => s.logicHooks)
.reduce((acc, val) => acc.concat(val), [])
const endLogicHook = fromJson.conversation
.filter(s => s.sender === 'end' && s.logicHooks && s.logicHooks.length > 0)
.map(s => s.logicHooks)
.reduce((acc, val) => acc.concat(val), [])
return { beginLogicHook, endLogicHook }
}
toString () {
return this.header.toString() + (this.sourceTag ? ` (${util.inspect(this.sourceTag)})` : '') + ': ' + this.conversation.map((c) => c.toString()).join(' | ')
}
Run (container) {
return new Promise((resolve, reject) => {
const scriptingMemory = {}
async.waterfall([
// onConvoBegin first or assertConvoBegin? If onConvoBegin, then it is possible to assert it too
(cb) => {
this.scriptingEvents.onConvoBegin({ convo: this, container, scriptingMemory })
.then(() => cb())
.catch((err) => cb(botiumErrorFromErr(`${this.header.name}: error begin handler - ${err.message}`, err)))
},
(cb) => {
this.scriptingEvents.assertConvoBegin({ convo: this, container, scriptingMemory })
.then(() => cb())
.catch((err) => cb(botiumErrorFromErr(`${this.header.name}: error begin handler - ${err.message}`, err)))
},
(cb) => {
this.runConversation(container, scriptingMemory, (transcript) => {
if (transcript.err) {
cb(transcript.err, transcript)
} else {
cb(null, transcript)
}
})
},
// onConvoEnd first or assertConvoEnd? If onConvoEnd, then it is possible to assert it too
(transcript, cb) => {
this.scriptingEvents.onConvoEnd({ convo: this, container, transcript, scriptingMemory: scriptingMemory })
.then(() => cb(null, transcript))
.catch((err) => cb(botiumErrorFromErr(`${this.header.name}: error end handler - ${err.message}`, err), transcript))
},
(transcript, cb) => {
this.scriptingEvents.assertConvoEnd({ convo: this, container, transcript, scriptingMemory: scriptingMemory })
.then(() => cb(null, transcript))
.catch((err) => cb(botiumErrorFromErr(`${this.header.name}: error end asserter - ${err.message}`, err), transcript))
}
],
(err, transcript) => {
container.eventEmitter.emit(Events.MESSAGE_TRANSCRIPT, container, transcript)
if (err) {
return reject(new TranscriptError(err, transcript))
} else {
resolve(transcript)
}
}
)
})
}
runConversation (container, scriptingMemory, cb) {
const transcript = new Transcript({
steps: [],
convoBegin: new Date(),
convoEnd: null,
err: null
})
let lastMeConvoStep = null
let effectiveConversation
try {
effectiveConversation = this._getEffectiveConversation()
} catch (err) {
transcript.err = err
transcript.scriptingMemory = scriptingMemory
transcript.convoEnd = new Date()
cb(transcript)
return
}
return async.mapSeries(effectiveConversation,
(convoStep, convoStepDoneCb) => {
const currentStepIndex = this.conversation.indexOf(convoStep)
const transcriptStep = new TranscriptStep({
expected: new BotiumMockMessage(convoStep),
not: convoStep.not,
actual: null,
stepBegin: new Date(),
stepEnd: null,
botBegin: null,
botEnd: null,
err: null
})
const convoStepDone = (err) => {
transcriptStep.stepEnd = new Date()
transcriptStep.err = err
convoStepDoneCb(err, transcriptStep)
}
if (convoStep.sender === 'begin' || convoStep.sender === 'end') {
convoStepDoneCb()
} else if (convoStep.sender === 'me') {
const meMsg = new BotiumMockMessage(convoStep)
meMsg.messageText = ScriptingMemory.apply(container, scriptingMemory, meMsg.messageText)
convoStep.messageText = meMsg.messageText
transcriptStep.actual = meMsg
return this.scriptingEvents.setUserInput({ convo: this, convoStep, container, scriptingMemory, meMsg })
.then(() => this.scriptingEvents.onMeStart({ convo: this, convoStep, container, scriptingMemory, meMsg }))
.then(() => debug(`${this.header.name}/${convoStep.stepTag}: user says ${JSON.stringify(convoStep, null, 2)}`))
.then(() => new Promise(resolve => {
if (container.caps.SIMULATE_WRITING_SPEED && meMsg.messageText && meMsg.messageText.length) {
setTimeout(() => resolve(), container.caps.SIMULATE_WRITING_SPEED * meMsg.messageText.length)
} else {
resolve()
}
}))
.then(() => {
lastMeConvoStep = convoStep
transcriptStep.botBegin = new Date()
if (!_.isNull(convoStep.messageText) || convoStep.sourceData || (convoStep.userInputs && convoStep.userInputs.length)) {
transcriptStep.botBegin = new Date()
return container.UserSays(Object.assign({ conversation: this.conversation, currentStepIndex, scriptingMemory }, meMsg))
.then(() => {
transcriptStep.botEnd = new Date()
return this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg })
})
.then(() => convoStepDone())
} else {
debug(`${this.header.name}/${convoStep.stepTag}: message not found in #me section, message not sent to container ${util.inspect(convoStep)}`)
return Promise.resolve()
.then(() => {
return this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg })
})
.then(() => convoStepDone())
}
})
.catch((err) => {
transcriptStep.botEnd = new Date()
const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: error sending to bot - ${err.message || err}`, err)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr)
} catch (failErr) {
}
convoStepDone(failErr)
})
} else if (convoStep.sender === 'bot') {
debug(`${this.header.name} wait for bot ${util.inspect(convoStep.channel)}`)
return this.scriptingEvents.onBotStart({ convo: this, convoStep, container, scriptingMemory })
.then(() => {
transcriptStep.botBegin = new Date()
return container.WaitBotSays(convoStep.channel)
}).then((saysmsg) => {
transcriptStep.botEnd = new Date()
transcriptStep.actual = new BotiumMockMessage(saysmsg)
const coreMsg = _.omit(saysmsg, [
'attachments',
'sourceData'
])
debug(`${this.header.name}: bot says (cleaned by attachments and sourceData) ${JSON.stringify(coreMsg, null, 2)}`)
if (!saysmsg || (!saysmsg.messageText && !saysmsg.media && !saysmsg.buttons && !saysmsg.cards && !saysmsg.sourceData && !saysmsg.nlp)) {
const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot says nothing`)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep)
} catch (failErr) {
}
return convoStepDone(failErr)
}
const assertErrors = []
if (convoStep.messageText) {
const response = this._checkNormalizeText(container, saysmsg.messageText)
const messageText = this._checkNormalizeText(container, convoStep.messageText)
ScriptingMemory.fill(container, scriptingMemory, response, messageText, this.scriptingEvents)
const tomatch = this._resolveUtterancesToMatch(container, scriptingMemory, messageText)
if (convoStep.not) {
try {
this.scriptingEvents.assertBotNotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep)
} catch (err) {
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
assertErrors.push(err)
} else {
return convoStepDone(err)
}
}
} else {
try {
this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep)
} catch (err) {
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
assertErrors.push(err)
} else {
return convoStepDone(err)
}
}
}
} else if (convoStep.sourceData) {
try {
this._compareObject(container, scriptingMemory, convoStep, saysmsg.sourceData, convoStep.sourceData)
} catch (err) {
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
assertErrors.push(err)
} else {
return convoStepDone(err)
}
}
}
this.scriptingEvents.assertConvoStep({ convo: this, convoStep, container, scriptingMemory, botMsg: saysmsg })
.then(() => this.scriptingEvents.onBotEnd({ convo: this, convoStep, container, scriptingMemory, botMsg: saysmsg }))
.catch((err) => {
const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: assertion error - ${err.message}`, err)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep)
} catch (failErr) {
}
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS] && err instanceof BotiumError) {
assertErrors.push(err)
} else {
return convoStepDone(failErr)
}
})
.then(() => {
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
if (assertErrors.length === 0) {
convoStepDone()
} else {
return convoStepDone(botiumErrorFromList(assertErrors, {}))
}
} else {
if (!transcriptStep.stepEnd) {
convoStepDone()
}
}
})
}).catch((err) => {
transcriptStep.botEnd = new Date()
const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: error waiting for bot - ${err.message}`, err)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep)
} catch (failErr) {
}
convoStepDone(failErr)
})
} else {
const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: invalid sender - ${util.inspect(convoStep.sender)}`)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr)
} catch (failErr) {
}
convoStepDone(failErr)
}
},
(err, transcriptSteps) => {
transcript.err = err
transcript.steps = transcriptSteps.filter(s => s)
transcript.scriptingMemory = scriptingMemory
transcript.convoEnd = new Date()
cb(transcript)
})
}
_compareObject (container, scriptingMemory, convoStep, result, expected) {
if (expected === null || expected === undefined) return
if (_.isArray(expected)) {
if (!_.isArray(result)) {
throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response expected array, got "${result}"`)
}
if (expected.length !== result.length) {
throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response expected array length ${expected.length}, got ${result.length}`)
}
for (var i = 0; i < expected.length; i++) {
this._compareObject(container, scriptingMemory, convoStep, result[i], expected[i])
}
} else if (_.isObject(expected)) {
_.forOwn(expected, (value, key) => {
if (Object.prototype.hasOwnProperty.call(result, key)) {
this._compareObject(container, scriptingMemory, convoStep, result[key], expected[key])
} else {
throw new BotiumError(`${this.header.name}/${convoStep.stepTag}: bot response "${result}" missing expected property: ${key}`)
}
})
} else {
ScriptingMemory.fill(container, scriptingMemory, result, expected, this.scriptingEvents)
const response = this._checkNormalizeText(container, result)
const tomatch = this._resolveUtterancesToMatch(container, scriptingMemory, expected)
this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`)
}
}
GetScriptingMemoryAllVariables (container) {
const result = this.conversation.reduce((acc, convoStep) => {
return acc.concat(this.GetScriptingMemoryVariables(container, convoStep.messageText))
}, [])
return [...new Set(result)]
}
GetScriptingMemoryVariables (container, utterance) {
if (!utterance || !container.caps[Capabilities.SCRIPTING_ENABLE_MEMORY]) {
return []
}
const utterances = this.scriptingEvents.resolveUtterance({ utterance })
return utterances.reduce((acc, expected) => {
return acc.concat(expected.match(/\$\w+/g) || [])
}, [])
}
_resolveUtterancesToMatch (container, scriptingMemory, utterance) {
const utterances = this.scriptingEvents.resolveUtterance({ utterance })
const normalizedUtterances = utterances.map(str => this._checkNormalizeText(container, str))
const tomatch = normalizedUtterances.map(str => ScriptingMemory.apply(container, scriptingMemory, str))
return tomatch
}
_checkNormalizeText (container, str) {
if (str && _.isArray(str)) {
str = str.join(' ')
} else if (str && !_.isString(str)) {
if (str.toString) {
str = str.toString()
} else {
str = `${str}`
}
}
if (str && container.caps[Capabilities.SCRIPTING_NORMALIZE_TEXT]) {
// remove html tags
str = str.replace(/<p[^>]*>/g, ' ')
str = str.replace(/<\/p>/g, ' ')
str = str.replace(/<br[^>]*>/g, ' ')
str = str.replace(/<[^>]*>/g, '')
/* eslint-disable no-control-regex */
// remove not printable characters
str = str.replace(/[\x00-\x1F\x7F]/g, ' ')
/* eslint-enable no-control-regex */
// replace html entities
str = str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/'/g, '\'')
.replace(/"/g, '"')
// replace two spaces with one
str = str.replace(/\s+/g, ' ')
str = str.split('\n').map(s => s.trim()).join('\n').trim()
}
return str
}
_getEffectiveConversation () {
if (this.effectiveConversation) {
return this.effectiveConversation
}
const _getIncludeLogicHookNames = (convoStep) => {
if (!convoStep.logicHooks) {
return []
}
const result = []
convoStep.logicHooks.forEach((logicHook) => {
if (logicHook.name === LOGIC_HOOK_INCLUDE) {
if (logicHook.args.length !== 1) {
throw Error('Wrong argument for include logic hook!')
}
result.push(logicHook)
}
})
return result.map((hook) => hook.args[0])
}
const _getEffectiveConversationRecursive = (conversation, parentPConvos = [], result = []) => {
conversation.forEach((convoStep) => {
const includeLogicHooks = _getIncludeLogicHookNames(convoStep)
if (includeLogicHooks.length === 0 || convoStep.hasInteraction()) {
// dont put convo name for ConvoSteps on the root.
const steptagPath = parentPConvos.length === 0 ? '' : parentPConvos.join('/') + '/'
result.push(Object.assign(new ConvoStep(), convoStep, { stepTag: `${steptagPath}${convoStep.stepTag}` }))
}
includeLogicHooks.forEach((includeLogicHook) => {
const alreadyThereAt = parentPConvos.indexOf(includeLogicHook)
if (alreadyThereAt >= 0) {
throw new BotiumError(`Partial convos are included circular. "${includeLogicHook}" is referenced by "/${parentPConvos.slice(0, alreadyThereAt).join('/')}" and by "/${parentPConvos.join('/')}" `)
}
const partialConvos = this.context.GetPartialConvos()
if (!partialConvos || Object.keys(partialConvos).length === 0) {
throw new BotiumError(`Cant find partial convo with name ${includeLogicHook} (There are no partial convos)`)
}
const partialConvo = partialConvos[includeLogicHook]
if (!partialConvo) {
throw new BotiumError(`Cant find partial convo with name ${includeLogicHook} (available partial convos: ${Object.keys(partialConvos).join(',')})`)
}
_getEffectiveConversationRecursive(partialConvo.conversation, [...parentPConvos, includeLogicHook], result)
debug(`Partial convo ${includeLogicHook} included`)
})
})
return result
}
this.effectiveConversation = _getEffectiveConversationRecursive(this.conversation)
return this.effectiveConversation
}
}
module
.exports = {
ConvoHeader,
Convo,
ConvoStep
}