botium-core
Version:
The Selenium for Chatbots
918 lines (842 loc) • 40.5 kB
JavaScript
const util = require('util')
const _ = require('lodash')
const debug = require('debug')('botium-core-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 { normalizeText, toString, removeBuffers, splitStringInNonEmptyLines } = require('./helper')
const { LOGIC_HOOK_INCLUDE } = require('./logichook/LogicHookConsts')
class ConvoHeader {
constructor (fromJson = {}) {
this.name = fromJson.name
this.projectname = fromJson.projectname
this.testsessionname = fromJson.testsessionname
this.sort = fromJson.sort
this.order = fromJson.order
this.description = fromJson.description
Object.assign(this, fromJson)
}
toString () {
return this.order + ' ' + this.name + (this.description ? ` (${this.description})` : '')
}
}
class ConvoStepAssert {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
this.not = fromJson.not
this.optional = fromJson.optional
}
toString () {
return (this.optional ? '?' : '') + (this.not ? '!' : '') + this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).join(',') : 'no args') + ')'
}
}
class ConvoStepLogicHook {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
}
toString () {
return this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).join(',') : 'no args') + ')'
}
}
class ConvoStepUserInput {
constructor (fromJson = {}) {
this.name = fromJson.name
this.args = fromJson.args
}
toString () {
return this.name + '(' + (this.args ? this.args.map(a => _.truncate(a, { length: 200 })).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.optional = fromJson.optional
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.stepTag + ': ' : '') +
'#' + this.sender +
' - ' + (this.optional ? '?' : '') + (this.not ? '!' : '') +
(this.messageText || '') +
(this.asserters && this.asserters.length > 0 ? ' ' + this.asserters.map(a => a.toString()).join(' ') : '') +
(this.logicHooks && this.logicHooks.length > 0 ? ' ' + this.logicHooks.map(l => l.toString()).join(' ') : '') +
(this.userInputs && this.userInputs.length > 0 ? ' ' + this.userInputs.map(u => u.toString()).join(' ') : '')
}
}
class Transcript {
constructor ({ steps, attachments, scriptingMemory, convoBegin, convoEnd, err }) {
this.steps = steps
this.attachments = attachments
this.scriptingMemory = scriptingMemory
this.convoBegin = convoBegin
this.convoEnd = convoEnd
this.err = err
}
prettifyActual () {
const prettifiedSteps = this.steps.map(step => {
if (step.actual && step.actual.prettify) {
return step.actual.prettify()
} else {
return '<empty conversation step>'
}
})
return prettifiedSteps.join('\n')
}
}
class TranscriptAttachment {
constructor (fromJson = {}) {
this.name = fromJson.name
this.mimeType = fromJson.mimeType
this.base64 = fromJson.base64
this.href = fromJson.href
}
}
class TranscriptStep {
constructor ({ expected, not, optional, actual, stepBegin, stepEnd, botBegin, botEnd, err }) {
this.expected = expected
this.not = not
this.optional = optional
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(' | ')
}
async Run (container) {
const transcript = new Transcript({
steps: [],
attachments: [],
convoBegin: new Date(),
convoEnd: null,
err: null
})
const scriptingMemory = {
}
container.caps[Capabilities.TESTCASENAME] = this.header.name
try {
try {
// onConvoBegin first or assertConvoBegin? If onConvoBegin, then it is possible to assert it too
await this.scriptingEvents.onConvoBegin({ convo: this, convoStep: { stepTag: '#begin' }, container, transcript, scriptingMemory })
} catch (err) {
throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript)
}
try {
await this.scriptingEvents.assertConvoBegin({ convo: this, convoStep: { stepTag: '#begin' }, container, scriptingMemory })
} catch (err) {
throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript)
}
await this.runConversation(container, scriptingMemory, transcript)
await this._checkBotRepliesConsumed(container)
try {
await this.scriptingEvents.onConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory })
} catch (err) {
throw new TranscriptError(botiumErrorFromErr(`${this.header.name}: ${err.message}`, err), transcript)
}
if (transcript.err && container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
let assertConvoEndErr = null
try {
await this.scriptingEvents.assertConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory })
} catch (err) {
assertConvoEndErr = botiumErrorFromErr(`${this.header.name}: ${err.message}`, err)
}
if (assertConvoEndErr) {
const err = transcript.err
transcript.err = botiumErrorFromList([transcript.err, assertConvoEndErr], {})
transcript.err.context.input = err.context.input
transcript.err.context.transcript = err.context.transcript
}
throw new TranscriptError(transcript.err, transcript)
} else if (transcript.err) {
throw new TranscriptError(transcript.err, transcript)
}
try {
await this.scriptingEvents.assertConvoEnd({ convo: this, convoStep: { stepTag: '#end' }, container, transcript, scriptingMemory })
} catch (err) {
transcript.err = botiumErrorFromErr(`${this.header.name}: ${err.message}`, err)
throw new TranscriptError(transcript.err, transcript)
}
return transcript
} finally {
container.eventEmitter.emit(Events.MESSAGE_TRANSCRIPT, container, transcript)
}
}
async runConversation (container, scriptingMemory, transcript) {
const transcriptSteps = []
transcript.steps = transcriptSteps
try {
let lastMeConvoStep = null
let botMsg = null
let waitForBotSays = true
let skipTranscriptStep = false
let conditionalGroupId = null
let conditionMetInGroup = false
let skipOptionalStep = false
// If there are optional step(s) in the conversation, and the message from the bot fails on each optional bot step(s) and/or mandatory bot step, then we have an unexpected message.
// So in this case an unexpected error should be shown instead of the latest assertion error.
let optionalStepAssertionError = false
let globalConvoStepParameters = container.caps[Capabilities.SCRIPTING_CONVO_STEP_PARAMETERS] || {}
let retryBotMessageTimeoutEnd = null
let retryBotMessageConvoId = null
let retryBotMessageDropBotResponse = false
for (let i = 0; i < this.conversation.length; i = (retryBotMessageDropBotResponse ? i : i + 1)) {
retryBotMessageDropBotResponse = false
const convoStep = this.conversation[i]
if (!convoStep.optional) {
skipOptionalStep = false
}
if (convoStep.optional && skipOptionalStep) {
// If there are multiple optional steps, and the previous optional step was timeout, then the next optional step should be skipped to prevent too long convo run with multiple timeout.
continue
}
const rawConvoStepParameters = convoStep.logicHooks.find(lh => lh.name === 'CONVO_STEP_PARAMETERS')?.args
let convoStepParameters = {}
if (rawConvoStepParameters && rawConvoStepParameters.length) {
let params
if (rawConvoStepParameters[0].trim().startsWith('{')) {
try {
params = JSON.parse(rawConvoStepParameters[0])
} catch (e) {
debug(`${this.header.name}/${convoStep.stepTag}: Failed to parse convo step parameters from JSON ${rawConvoStepParameters[0]}`)
}
}
if (!params || !Object.keys(params).length) {
params = {}
for (const param of rawConvoStepParameters) {
const semicolon = param.indexOf(':')
if (semicolon) {
try {
const name = param.substring(0, semicolon)
const value = param.substring(semicolon + 1)
params[name] = value
} catch (e) {
debug(`${this.header.name}/${convoStep.stepTag}: Failed to parse convo step parameter from arg ${param}`)
}
}
}
}
if (convoStep.sender === 'begin') {
globalConvoStepParameters = Object.assign({}, globalConvoStepParameters || {}, params)
} else {
convoStepParameters = Object.assign({}, globalConvoStepParameters || {}, params)
}
} else {
if (convoStep.sender !== 'begin') {
convoStepParameters = globalConvoStepParameters
}
}
if (Object.keys(convoStepParameters).length) {
debug(`${this.header.name}: using convo step parameters ${JSON.stringify(convoStepParameters)}`)
}
const currentStepIndex = i
container.eventEmitter.emit(Events.CONVO_STEP_NEXT, container, convoStep, i)
skipTranscriptStep = false
const transcriptStep = new TranscriptStep({
expected: new BotiumMockMessage(convoStep),
not: convoStep.not,
optional: convoStep.optional,
actual: null,
stepBegin: new Date(),
stepEnd: null,
botBegin: null,
botEnd: null,
err: null
})
try {
if (convoStep.sender === 'begin' || convoStep.sender === 'end') {
continue
} else if (convoStep.sender === 'me') {
const meMsg = new BotiumMockMessage(convoStep)
meMsg.messageText = ScriptingMemory.apply(container, scriptingMemory, meMsg.messageText, meMsg)
// buggy command is removed, but because sideeffects are possible, it can be reactivated.
// If there are no sideeffects coming up, then row can be deleted permanently.
if (process.env.WORKAROUND_OVERWRITE_JSON_MESSAGE_TEXT) {
// if this line is active, then Random() in me section does not work in performance test
// (first run overwrites the function with the value, and the next run has the value, not the function)
convoStep.messageText = meMsg.messageText
}
transcriptStep.actual = meMsg
try {
await this.scriptingEvents.setUserInput({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps })
await this.scriptingEvents.onMeStart({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps })
await this.scriptingEvents.onMePrepare({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep, transcriptSteps })
await this._checkBotRepliesConsumed(container)
const coreMsg = _.omit(removeBuffers(meMsg), ['sourceData'])
debug(`${this.header.name}/${convoStep.stepTag}: user says (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`)
await new Promise(resolve => {
if (container.caps[Capabilities.SIMULATE_WRITING_SPEED] && meMsg.messageText && meMsg.messageText.length) {
setTimeout(() => resolve(), container.caps[Capabilities.SIMULATE_WRITING_SPEED] * meMsg.messageText.length)
} else {
resolve()
}
})
lastMeConvoStep = convoStep
transcriptStep.botBegin = new Date()
if (!_.isNull(meMsg.messageText) || meMsg.sourceData || (meMsg.userInputs && meMsg.userInputs.length) || (meMsg.logicHooks && meMsg.logicHooks.length)) {
try {
Object.assign(meMsg, { header: this.header, conversation: this.conversation, currentStepIndex, scriptingMemory })
await container.UserSays(meMsg)
} finally {
delete meMsg.header
delete meMsg.conversation
delete meMsg.scriptingMemory
}
transcriptStep.botEnd = new Date()
await this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep })
continue
} else {
debug(`${this.header.name}/${convoStep.stepTag}: message not found in #me section, message not sent to container ${util.inspect(convoStep)}`)
transcriptStep.botEnd = new Date()
await this.scriptingEvents.onMeEnd({ convo: this, convoStep, container, scriptingMemory, meMsg, transcript, transcriptStep })
continue
}
} 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) {
}
throw failErr
}
} else if (convoStep.sender === 'bot') {
if (this.scriptingEvents.executeBotStep) {
const executeBotStepResult = await this.scriptingEvents.executeBotStep({ convo: this, convoStep, container, scriptingMemory, transcript, transcriptStep, transcriptSteps })
if (executeBotStepResult) {
skipTranscriptStep = true
continue
}
}
if (waitForBotSays) {
botMsg = null
} else {
waitForBotSays = true
}
try {
debug(`${this.header.name} wait for bot ${convoStep.channel || ''}`)
await this.scriptingEvents.onBotStart({ convo: this, convoStep, container, scriptingMemory, transcript, transcriptStep })
transcriptStep.botBegin = new Date()
if (!botMsg) {
botMsg = await container.WaitBotSays(convoStep.channel, convoStepParameters?.stepTimeout)
}
transcriptStep.botEnd = new Date()
transcriptStep.actual = new BotiumMockMessage(botMsg)
const coreMsg = _.omit(removeBuffers(botMsg), ['sourceData'])
debug(`${this.header.name}: bot says (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`)
} catch (err) {
transcriptStep.botEnd = new Date()
if (!(err.message.indexOf('Bot did not respond within') < 0) && convoStep.optional) {
skipOptionalStep = true
continue
}
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) {
}
throw failErr
}
try {
const prepared = await this.scriptingEvents.onBotPrepare({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep })
if (prepared) {
transcriptStep.actual = new BotiumMockMessage(botMsg)
const coreMsg = _.omit(removeBuffers(botMsg), ['sourceData'])
debug(`${this.header.name}: onBotPrepare (cleaned by binary and base64 data and sourceData) ${JSON.stringify(coreMsg, null, 2)}`)
}
} catch (err) {
const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: onBotPrepare error - ${err.message || err}`, err)
debug(failErr)
try {
this.scriptingEvents.fail && this.scriptingEvents.fail(failErr, lastMeConvoStep)
} catch (failErr) {
}
throw failErr
}
if (convoStep.conditional) {
waitForBotSays = false
let endOfConditionalGroup = false
conditionalGroupId = convoStep.logicHooks.find(lh => lh.name.startsWith('CONDITIONAL_STEP')).args[1]
const nextConvoStep = this.conversation[i + 1]
if (!nextConvoStep || nextConvoStep.sender !== 'bot' || !nextConvoStep.logicHooks || !nextConvoStep.logicHooks.some(lh => lh.name.toUpperCase().startsWith('CONDITIONAL_STEP'))) {
endOfConditionalGroup = true
} else {
const nextConditionalLogicHook = nextConvoStep.logicHooks.find(lh => lh.name.startsWith('CONDITIONAL_STEP'))
endOfConditionalGroup = conditionalGroupId !== nextConditionalLogicHook.args[1]
}
if (convoStep.conditional.skip || conditionMetInGroup) {
skipTranscriptStep = true
if (endOfConditionalGroup && !conditionMetInGroup && !convoStep.optional) {
const failErr = new BotiumError(`${this.header.name}/${convoStep.stepTag}: Non of the conditions are met in ${conditionalGroupId ? `'${conditionalGroupId}' ` : ''}condition group`)
debug(failErr)
throw failErr
}
if (endOfConditionalGroup) {
waitForBotSays = !convoStep.optional
conditionalGroupId = undefined
conditionMetInGroup = false
}
continue
} else {
conditionMetInGroup = true
if (endOfConditionalGroup) {
waitForBotSays = !convoStep.optional
conditionalGroupId = undefined
conditionMetInGroup = false
}
}
}
if (!botMsg || (!botMsg.messageText && !botMsg.media && !botMsg.buttons && !botMsg.cards && !botMsg.sourceData && !botMsg.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) {
}
throw failErr
}
const isErrorHandledWithOptionConvoStep = (err) => {
const nextConvoStep = this.conversation[i + 1]
const retryConfig = convoStepParameters?.ignoreNotMatchedBotResponses
const retryOn = convoStep.sender === 'bot' && retryConfig && retryConfig.timeout && retryConfig.mainAsserter
if (convoStep.optional && nextConvoStep && nextConvoStep.sender === 'bot') {
if (retryOn) {
debug(`${this.header.name}/${convoStep.stepTag}: Retry failed asserter is ignored on optional convo`)
}
waitForBotSays = false
skipTranscriptStep = true
optionalStepAssertionError = true
return true
} else if (retryOn) {
if (!retryBotMessageTimeoutEnd || retryBotMessageConvoId !== convoStep.stepTag) {
retryBotMessageTimeoutEnd = transcriptStep.stepBegin.getTime() + +retryConfig.timeout
retryBotMessageConvoId = convoStep.stepTag
}
const now = new Date().getTime()
const timeoutRemaining = retryBotMessageTimeoutEnd - now
if (timeoutRemaining > 0) {
debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, timeout remaining: ${timeoutRemaining}, error: "${err.message}"`)
retryBotMessageDropBotResponse = true
return false
} else {
debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, but timeout is over. error: "${err.message}"`)
}
}
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
if (optionalStepAssertionError) {
optionalStepAssertionError = false
assertErrors.push(new BotiumError(`${this.header.name}: Unexpected message.`))
} else {
assertErrors.push(err)
}
return false
} else {
if (optionalStepAssertionError) {
optionalStepAssertionError = false
throw new BotiumError(`${this.header.name}: Unexpected message.`)
}
throw err
}
}
const assertErrors = []
const scriptingMemoryUpdate = {}
if (convoStep.messageText) {
const response = this._checkNormalizeText(container, botMsg.messageText)
const messageText = this._checkNormalizeText(container, convoStep.messageText)
ScriptingMemory.fill(container, scriptingMemoryUpdate, response, messageText, this.scriptingEvents)
const tomatch = this._resolveUtterancesToMatch(container, Object.assign({}, scriptingMemoryUpdate, scriptingMemory), messageText, botMsg)
if (convoStep.not) {
try {
this.scriptingEvents.assertBotNotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep, convoStepParameters)
optionalStepAssertionError = false
} catch (err) {
if (isErrorHandledWithOptionConvoStep(err)) {
continue
}
}
} else {
try {
this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, lastMeConvoStep, convoStepParameters)
optionalStepAssertionError = false
} catch (err) {
if (isErrorHandledWithOptionConvoStep(err)) {
continue
}
}
}
} else if (convoStep.sourceData) {
try {
this._compareObject(container, scriptingMemory, convoStep, botMsg.sourceData, convoStep.sourceData, botMsg, convoStepParameters)
optionalStepAssertionError = false
} catch (err) {
if (isErrorHandledWithOptionConvoStep(err)) {
continue
}
}
}
Object.assign(scriptingMemory, scriptingMemoryUpdate)
try {
await this.scriptingEvents.assertConvoStep({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep, transcriptSteps })
await this.scriptingEvents.onBotEnd({ convo: this, convoStep, container, scriptingMemory, botMsg, transcript, transcriptStep })
optionalStepAssertionError = false
} catch (err) {
const nextConvoStep = this.conversation[i + 1]
if (convoStep.optional && nextConvoStep && nextConvoStep.sender === 'bot') {
waitForBotSays = false
skipTranscriptStep = true
optionalStepAssertionError = true
continue
}
const errors = err.toArray ? err.toArray() : []
const retryConfig = convoStepParameters?.ignoreNotMatchedBotResponses
const retryOn =
convoStep.sender === 'bot' &&
retryConfig &&
retryConfig.timeout &&
errors.length &&
errors.filter(({ type, source, asserter }) => type === 'asserter' && (retryConfig.allAsserters || (retryConfig.asserters && retryConfig.asserters.includes(asserter)))).length
if (retryOn && (!retryBotMessageTimeoutEnd || retryBotMessageConvoId !== convoStep.stepTag)) {
retryBotMessageTimeoutEnd = transcriptStep.stepBegin.getTime() + +retryConfig.timeout
retryBotMessageConvoId = convoStep.stepTag
}
const now = new Date().getTime()
const timeoutRemaining = retryOn && (retryBotMessageTimeoutEnd - now)
if (retryOn && timeoutRemaining > 0) {
debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, timeout remaining: ${timeoutRemaining}, error: "${err.message}"`)
retryBotMessageDropBotResponse = true
} else {
if (retryOn && timeoutRemaining <= 0) {
debug(`${this.header.name}/${convoStep.stepTag}: Convo step retry on, but timeout is over. error: "${err.message}"`)
}
const failErr = botiumErrorFromErr(`${this.header.name}/${convoStep.stepTag}: assertion error - ${err.message || err}`, 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) {
if (optionalStepAssertionError) {
optionalStepAssertionError = false
assertErrors.push(new BotiumError(`${this.header.name}: Unexpected message.`))
} else {
assertErrors.push(err)
}
} else {
if (optionalStepAssertionError) {
optionalStepAssertionError = false
throw new BotiumError(`${this.header.name}: Unexpected message.`)
}
throw failErr
}
}
}
if (container.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) {
if (assertErrors.length > 0) {
// this has no effect, but logically it has to be false
retryBotMessageDropBotResponse = false
throw botiumErrorFromList(assertErrors, {})
}
} else {
if (!transcriptStep.stepEnd) {
continue
}
}
} 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) {
}
throw failErr
}
} catch (err) {
if (lastMeConvoStep) {
if (err instanceof BotiumError && err.context) {
err.context.input = new ConvoStep(lastMeConvoStep)
err.context.transcript = [...transcriptSteps, { ...transcriptStep }]
} else {
err.input = new ConvoStep(lastMeConvoStep)
err.transcript = [...transcriptSteps, { ...transcriptStep }]
}
}
transcriptStep.err = err
if (err instanceof BotiumError && container.caps[Capabilities.SCRIPTING_ENABLE_SKIP_ASSERT_ERRORS]) {
if (!err.isAsserterError()) {
throw err
}
} else {
throw err
}
} finally {
if (convoStep.sender !== 'begin' && convoStep.sender !== 'end' && !skipTranscriptStep) {
transcriptStep.scriptingMemory = Object.assign({}, scriptingMemory)
transcriptStep.stepEnd = new Date()
transcriptSteps.push(transcriptStep)
}
}
}
} catch (err) {
transcript.err = err
} finally {
transcript.steps = transcriptSteps.filter(s => s)
transcript.scriptingMemory = scriptingMemory
transcript.convoEnd = new Date()
if (container.caps[Capabilities.SCRIPTING_ENABLE_SKIP_ASSERT_ERRORS]) {
const transcriptStepErrs = transcript.steps.filter(s => s.err).map(s => s.err)
if (transcriptStepErrs && transcriptStepErrs.length > 0) {
transcript.err = botiumErrorFromList([...transcriptStepErrs.filter(err => err !== transcript.err), transcript.err].filter(e => e), {})
}
}
}
}
_compareObject (container, scriptingMemory, convoStep, result, expected, botMsg, convoStepParameters) {
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 (let i = 0; i < expected.length; i++) {
this._compareObject(container, scriptingMemory, convoStep, result[i], expected[i], null, convoStepParameters)
}
} else if (_.isObject(expected)) {
_.forOwn(expected, (value, key) => {
if (Object.prototype.hasOwnProperty.call(result, key)) {
this._compareObject(container, scriptingMemory, convoStep, result[key], expected[key], null, convoStepParameters)
} 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, botMsg)
this.scriptingEvents.assertBotResponse(response, tomatch, `${this.header.name}/${convoStep.stepTag}`, null, convoStepParameters)
}
}
GetScriptingMemoryAllVariables (container) {
const partialConvos = this.context.GetPartialConvos()
const variableNames = this.conversation.reduce((acc, convoStep) => {
if (convoStep.sender === 'include') {
if (convoStep.channel) {
const partialConvo = partialConvos[convoStep.channel]
if (partialConvo) {
acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)]
}
}
if (convoStep.messageText) {
for (const partialConvoName of splitStringInNonEmptyLines(convoStep.messageText)) {
const partialConvo = partialConvos[partialConvoName]
if (partialConvo) {
acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)]
}
}
}
} else {
acc = [...acc, ...this.GetScriptingMemoryVariables(container, convoStep.messageText)]
const __extractFromArgs = (convoStepItems) => {
let resultInner = []
for (const item of (convoStepItems || [])) {
for (const arg of (item.args || [])) {
resultInner = resultInner.concat(this.GetScriptingMemoryVariables(container, arg))
}
}
return resultInner
}
acc = [...acc, ...__extractFromArgs(convoStep.asserters)]
acc = [...acc, ...__extractFromArgs(convoStep.logicHooks)]
acc = [...acc, ...__extractFromArgs(convoStep.userInputs)]
convoStep.logicHooks.forEach((logicHook) => {
if (logicHook.name === LOGIC_HOOK_INCLUDE) {
const partialConvo = partialConvos[logicHook.args[0]]
if (partialConvo) {
acc = [...acc, ...partialConvo.GetScriptingMemoryAllVariables(container)]
}
}
})
}
return acc
}, [])
return _.uniq(variableNames)
}
GetScriptingMemoryVariables (container, utterance) {
if (!utterance || !container.caps[Capabilities.SCRIPTING_ENABLE_MEMORY]) {
return []
}
const utterances = this.scriptingEvents.resolveUtterance({ utterance })
return utterances.reduce((acc, expected) => {
if (_.isUndefined(expected)) return acc
else return acc.concat(ScriptingMemory.extractVarNames(toString(expected)) || [])
}, [])
}
_checkBotRepliesConsumed (container) {
if (container.caps[Capabilities.SCRIPTING_FORCE_BOT_CONSUMED]) {
const queueLength = container._QueueLength()
if (queueLength === 1) {
throw new Error('There is an unread bot reply in queue')
} else if (queueLength > 1) {
throw new Error(`There are still ${queueLength} unread bot replies in queue`)
}
}
}
_resolveUtterancesToMatch (container, scriptingMemory, utterance, botMsg) {
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, botMsg))
return tomatch
}
_checkNormalizeText (container, str) {
return normalizeText(str, container.caps)
}
expandPartialConvos () {
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 partialConvos = this.context.GetPartialConvos()
const _getEffectiveConversationRecursive = (conversation, parentPConvos = [], result = [], ignoreBeginEnd = true) => {
conversation.forEach((convoStep) => {
let includeLogicHooks = []
if (convoStep.sender === 'include') {
if (convoStep.channel) {
includeLogicHooks.push(convoStep.channel)
}
if (convoStep.messageText) {
includeLogicHooks = includeLogicHooks.concat(splitStringInNonEmptyLines(convoStep.messageText))
}
} else {
includeLogicHooks = _getIncludeLogicHookNames(convoStep)
if (includeLogicHooks.length === 0 || convoStep.hasInteraction()) {
if (!ignoreBeginEnd || (convoStep.sender !== 'begin' && convoStep.sender !== 'end')) {
// 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('/')}" `)
}
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, true)
debug(`Partial convo ${includeLogicHook} included`)
})
})
return result
}
this.conversation = _getEffectiveConversationRecursive(this.conversation, [], [], false)
}
}
module.exports = {
Convo,
ConvoHeader,
ConvoStep,
ConvoStepAssert,
ConvoStepLogicHook,
ConvoStepUserInput,
Transcript,
TranscriptAttachment,
TranscriptStep,
TranscriptError
}