From 0c99980331e35a82c41819580c94ec681672a12f Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Fri, 14 Jul 2023 18:20:27 -0300 Subject: [PATCH 01/13] Modified helpers to support chatbot capture refactor --- .../helpers/hrmDataManipulation.private.ts | 18 +++++++++---- functions/helpers/insightsService.private.ts | 26 +++++++++++-------- functions/helpers/lexClient.private.ts | 4 ++- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/functions/helpers/hrmDataManipulation.private.ts b/functions/helpers/hrmDataManipulation.private.ts index dd5ae222..363f5377 100644 --- a/functions/helpers/hrmDataManipulation.private.ts +++ b/functions/helpers/hrmDataManipulation.private.ts @@ -16,22 +16,26 @@ import { get } from 'lodash'; // eslint-disable-next-line prettier/prettier -import type { BotMemory } from '../postSurveyComplete.protected'; +import type { AutopilotMemory } from '../postSurveyComplete.protected'; +import type { LexMemory } from './lexClient.private'; import type { OneToManyConfigSpec } from './insightsService.private'; export type PostSurveyData = { [question: string]: string | number }; +type BotMemory = AutopilotMemory | LexMemory; + /** * Given a bot's memory returns a function to reduce over an array of OneToManyConfigSpec. * The function returned will grab all the answers to the questions defined in the OneToManyConfigSpecs * and return a flattened object of type PostSurveyData */ const flattenOneToMany = - (memory: BotMemory) => (accum: PostSurveyData, curr: OneToManyConfigSpec) => { + (memory: BotMemory, pathBuilder: (question: string) => string) => + (accum: PostSurveyData, curr: OneToManyConfigSpec) => { const paths = curr.questions.map( (question) => ({ question, - path: `twilio.collected_data.collect_survey.answers.${question}.answer`, + path: pathBuilder(question), }), // Path where the answer for each question should be in bot memory ); @@ -46,8 +50,12 @@ const flattenOneToMany = /** * Given the config for the post survey and the bot's memory, returns the collected answers in the fomat it's stored in HRM. */ -export const buildDataObject = (oneToManyConfigSpecs: OneToManyConfigSpec[], memory: BotMemory) => { - const reducerFunction = flattenOneToMany(memory); +export const buildDataObject = ( + oneToManyConfigSpecs: OneToManyConfigSpec[], + memory: BotMemory, + pathBuilder: (question: string) => string = (q) => q, +) => { + const reducerFunction = flattenOneToMany(memory, pathBuilder); return oneToManyConfigSpecs.reduce(reducerFunction, {}); }; diff --git a/functions/helpers/insightsService.private.ts b/functions/helpers/insightsService.private.ts index 76f2b7b6..131ce63a 100644 --- a/functions/helpers/insightsService.private.ts +++ b/functions/helpers/insightsService.private.ts @@ -15,8 +15,10 @@ */ import { get } from 'lodash'; -// eslint-disable-next-line prettier/prettier -import type { BotMemory } from '../postSurveyComplete.protected'; +import type { AutopilotMemory } from '../postSurveyComplete.protected'; +import type { LexMemory } from './lexClient.private'; + +type BotMemory = AutopilotMemory | LexMemory; type InsightsAttributes = { conversations?: { [key: string]: string | number }; @@ -54,11 +56,12 @@ const mergeAttributes = ( }); const applyCustomUpdate = - (customUpdate: OneToManyConfigSpec): SurveyInsightsUpdateFunction => + ( + customUpdate: OneToManyConfigSpec, + pathBuilder: (question: string) => string, + ): SurveyInsightsUpdateFunction => (memory) => { - const updatePaths = customUpdate.questions.map( - (question) => `twilio.collected_data.collect_survey.answers.${question}.answer`, // Path where the answer for each question should be in bot memory - ); + const updatePaths = customUpdate.questions.map(pathBuilder); // concatenate the values, taken from dataSource using paths (e.g. 'contactForm.childInformation.province') const value = updatePaths.map((path) => get(memory, path, '')).join(delimiter); @@ -71,8 +74,11 @@ const applyCustomUpdate = const bindApplyCustomUpdates = ( oneToManyConfigSpecs: OneToManyConfigSpec[], + pathBuilder: (question: string) => string, ): SurveyInsightsUpdateFunction[] => { - const customUpdatesFuns = oneToManyConfigSpecs.map(applyCustomUpdate); + const customUpdatesFuns = oneToManyConfigSpecs.map((spec) => + applyCustomUpdate(spec, pathBuilder), + ); return customUpdatesFuns; }; @@ -81,11 +87,9 @@ export const buildSurveyInsightsData = ( oneToManyConfigSpecs: OneToManyConfigSpec[], taskAttributes: TaskAttributes, memory: BotMemory, + pathBuilder: (question: string) => string = (q) => q, ) => { - // NOTE: I assume that if surveys are enabled this is not needed, right? - // if (!shouldSendInsightsData(task)) return previousAttributes; - - const applyCustomUpdates = bindApplyCustomUpdates(oneToManyConfigSpecs); + const applyCustomUpdates = bindApplyCustomUpdates(oneToManyConfigSpecs, pathBuilder); const finalAttributes: TaskAttributes = applyCustomUpdates .map((f) => f(memory)) diff --git a/functions/helpers/lexClient.private.ts b/functions/helpers/lexClient.private.ts index c2269987..a9470575 100644 --- a/functions/helpers/lexClient.private.ts +++ b/functions/helpers/lexClient.private.ts @@ -15,7 +15,7 @@ */ import AWS from 'aws-sdk'; -type AWSCredentials = { +export type AWSCredentials = { ASELO_APP_ACCESS_KEY: string; ASELO_APP_SECRET_KEY: string; AWS_REGION: string; @@ -23,6 +23,8 @@ type AWSCredentials = { export type BotType = 'pre_survey' | 'post_survey'; +export type LexMemory = { [q: string]: string | number }; + export const postText = async ( credentials: AWSCredentials, { From de6db1ce342ba2d6a4dfbf27647d811b2193c805 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Fri, 14 Jul 2023 18:21:42 -0300 Subject: [PATCH 02/13] Added module channelCapture where all the code related is grouped (logic, http handler and webhook) --- functions/captureChannelWithBot.protected.ts | 174 ------ .../captureChannelWithBot.protected.ts | 112 ++++ .../channelCaptureHandlers.private.ts | 519 ++++++++++++++++++ .../chatbotCallback.protected.ts | 74 +-- 4 files changed, 669 insertions(+), 210 deletions(-) delete mode 100644 functions/captureChannelWithBot.protected.ts create mode 100644 functions/channelCapture/captureChannelWithBot.protected.ts create mode 100644 functions/channelCapture/channelCaptureHandlers.private.ts rename functions/{webhooks => channelCapture}/chatbotCallback.protected.ts (69%) diff --git a/functions/captureChannelWithBot.protected.ts b/functions/captureChannelWithBot.protected.ts deleted file mode 100644 index 607b08cb..00000000 --- a/functions/captureChannelWithBot.protected.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* eslint-disable import/no-dynamic-require */ -/** - * Copyright (C) 2021-2023 Technology Matters - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -/* eslint-disable global-require */ -/* eslint-disable import/no-dynamic-require */ -import '@twilio-labs/serverless-runtime-types'; -import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, -} from '@tech-matters/serverless-helpers'; -import { LexClient } from './helpers/lexClient.private'; - -type EnvVars = { - HELPLINE_CODE: string; - ENVIRONMENT: string; - CHAT_SERVICE_SID: string; - ASELO_APP_ACCESS_KEY: string; - ASELO_APP_SECRET_KEY: string; - AWS_REGION: string; - TWILIO_WORKSPACE_SID: string; - SURVEY_WORKFLOW_SID: string; -}; - -export type Body = { - channelSid: string; // (in Studio Flow, flow.channel.address) The channel to capture - message: string; // (in Studio Flow, trigger.message.Body) The triggering message - fromServiceUser: string; // (in Studio Flow, trigger.message.From) The service user unique name - studioFlowSid: string; // (in Studio Flow, flow.flow_sid) The Studio Flow sid. Needed to trigger an API type execution once the channel is released. - language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} ) - type: 'pre_survey' | 'post_survey'; // (hardcoded in Studio Flow) -}; - -export const handler = async ( - context: Context, - event: Body, - callback: ServerlessCallback, -) => { - console.log('===== captureChannelWithBot handler ====='); - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - try { - const { channelSid, message, fromServiceUser, studioFlowSid, language, type } = event; - - if (!channelSid) { - resolve(error400('channelSid')); - return; - } - if (!message) { - resolve(error400('message')); - return; - } - if (!fromServiceUser) { - resolve(error400('fromServiceUser')); - return; - } - if (!studioFlowSid) { - resolve(error400('studioFlowSid')); - return; - } - if (!type) { - resolve(error400('type')); - return; - } - if (!language) { - resolve(error400('language')); - return; - } - - const channel = await context - .getTwilioClient() - .chat.v2.services(context.CHAT_SERVICE_SID) - .channels(channelSid) - .fetch(); - - const channelAttributes = JSON.parse(channel.attributes); - - const channelWebhooks = await context - .getTwilioClient() - .chat.services(context.CHAT_SERVICE_SID) - .channels(channelSid) - .webhooks.list(); - - // Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions - await Promise.all( - channelWebhooks.map(async (w) => { - if (w.type === 'studio') { - await w.remove(); - } - }), - ); - - const { ENVIRONMENT, HELPLINE_CODE } = context; - const languageSanitized = language.replace('-', '_'); // Lex doesn't accept '-' - const botName = `${ENVIRONMENT}_${HELPLINE_CODE.toLowerCase()}_${languageSanitized}_${type}`; - - const chatbotCallbackWebhook = await channel.webhooks().create({ - type: 'webhook', - configuration: { - filters: ['onMessageSent'], - method: 'POST', - url: `https://${context.DOMAIN_NAME}/webhooks/chatbotCallback`, - }, - }); - - const updated = await channel.update({ - attributes: JSON.stringify({ - ...channelAttributes, - fromServiceUser, // Save this in the outer scope so it's persisted for later chatbots - // All of this can be passed as url params to the webhook instead - channelCapturedByBot: { - botName, - botAlias: 'latest', // assume we always use the latest published version - studioFlowSid, - chatbotCallbackWebhookSid: chatbotCallbackWebhook.sid, - }, - }), - }); - - const updatedChannelAttributes = JSON.parse(updated.attributes); - - await context - .getTwilioClient() - .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) - .tasks.create({ - workflowSid: context.SURVEY_WORKFLOW_SID, - attributes: JSON.stringify({ - isChatCaptureControl: true, - channelSid, - }), - taskChannel: 'survey', - timeout: 45600, // 720 minutes or 12 hours - }); - - const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; - const lexClient = require(handlerPath) as LexClient; - - const lexResponse = await lexClient.postText(context, { - botName: updatedChannelAttributes.channelCapturedByBot.botName, - botAlias: updatedChannelAttributes.channelCapturedByBot.botAlias, - inputText: message, - userId: channel.sid, - }); - - await channel.messages().create({ - body: lexResponse.message, - from: 'Bot', - xTwilioWebhookEnabled: 'true', - }); - - resolve(success('Channel captured by bot =)')); - } catch (err) { - if (err instanceof Error) resolve(error500(err)); - else resolve(error500(new Error(String(err)))); - } -}; diff --git a/functions/channelCapture/captureChannelWithBot.protected.ts b/functions/channelCapture/captureChannelWithBot.protected.ts new file mode 100644 index 00000000..92836353 --- /dev/null +++ b/functions/channelCapture/captureChannelWithBot.protected.ts @@ -0,0 +1,112 @@ +/* eslint-disable import/no-dynamic-require */ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +/* eslint-disable global-require */ +/* eslint-disable import/no-dynamic-require */ +import '@twilio-labs/serverless-runtime-types'; +import { + responseWithCors, + bindResolve, + error400, + error500, + success, +} from '@tech-matters/serverless-helpers'; +import type { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; +import type { + ChannelCaptureHandlers, + ReleaseTypes, + TriggerTypes, +} from './channelCaptureHandlers.private'; +import type { AWSCredentials } from '../helpers/lexClient.private'; + +type EnvVars = { + HELPLINE_CODE: string; + ENVIRONMENT: string; + CHAT_SERVICE_SID: string; + TWILIO_WORKSPACE_SID: string; + SURVEY_WORKFLOW_SID: string; + HRM_STATIC_KEY: string; +} & AWSCredentials; + +export type Body = { + channelSid: string; // (in Studio Flow, flow.channel.address) The channel to capture + message: string; // (in Studio Flow, trigger.message.Body) The triggering message + language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} ) + botSuffix: string; // (hardcoded in Studio Flow) + triggerType: TriggerTypes; + releaseType: ReleaseTypes; + studioFlowSid?: string; // (in Studio Flow, flow.flow_sid) The Studio Flow sid. Needed to trigger an API type execution once the channel is released. + memoryAttribute?: string; // where in the task attributes we want to save the bot's memory (allows compatibility for multiple bots) + releaseFlag?: string; // the flag we want to set true when the channel is released + additionControlTaskAttributes?: string; // optional attributes to include in the control task, in the string representation of a JSON + controlTaskTTL?: number; +}; + +export const handler = async ( + context: Context, + event: Body, + callback: ServerlessCallback, +) => { + console.log('===== captureChannelWithBot handler ====='); + const response = responseWithCors(); + const resolve = bindResolve(callback)(response); + + try { + const { + channelSid, + message, + triggerType, + releaseType, + studioFlowSid, + language, + botSuffix, + additionControlTaskAttributes, + controlTaskTTL, + memoryAttribute, + releaseFlag, + } = event; + + const handlerPath = Runtime.getFunctions()['channelCapture/channelCaptureHandlers'].path; + const channelCaptureHandlers = require(handlerPath) as ChannelCaptureHandlers; + + const result = await channelCaptureHandlers.handleChannelCapture(context, { + channelSid, + message, + language, + botSuffix, + triggerType, + releaseType, + studioFlowSid, + memoryAttribute, + releaseFlag, + additionControlTaskAttributes, + controlTaskTTL, + }); + + if (result.status === 'failure' && result.validationResult.status === 'invalid') { + resolve(error400(result.validationResult.error)); + return; + } + + resolve(success('Channel captured by bot =)')); + } catch (err) { + if (err instanceof Error) resolve(error500(err)); + else resolve(error500(new Error(String(err)))); + } +}; + +export type CaptureChannelWithBot = { handler: typeof handler }; diff --git a/functions/channelCapture/channelCaptureHandlers.private.ts b/functions/channelCapture/channelCaptureHandlers.private.ts new file mode 100644 index 00000000..75b7ce59 --- /dev/null +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -0,0 +1,519 @@ +/* eslint-disable global-require */ +/* eslint-disable import/no-dynamic-require */ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +import axios from 'axios'; +import type { Context } from '@twilio-labs/serverless-runtime-types/types'; +import type { ChannelInstance } from 'twilio/lib/rest/chat/v2/service/channel'; +import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; +import type { AWSCredentials, LexClient, LexMemory } from '../helpers/lexClient.private'; +import type { BuildDataObject, PostSurveyData } from '../helpers/hrmDataManipulation.private'; +import type { + BuildSurveyInsightsData, + OneToManyConfigSpec, +} from '../helpers/insightsService.private'; + +type EnvVars = AWSCredentials & { + TWILIO_WORKSPACE_SID: string; + CHAT_SERVICE_SID: string; + HRM_STATIC_KEY: string; + HELPLINE_CODE: string; + ENVIRONMENT: string; + SURVEY_WORKFLOW_SID: string; +}; + +const triggerTypes = ['redirectMessage', 'onNextMessage'] as const; +export type TriggerTypes = typeof triggerTypes[number]; + +const releaseTypes = ['triggerStudioFlow', 'postSurveyComplete'] as const; +export type ReleaseTypes = typeof releaseTypes[number]; + +export type CapturedChannelAttributes = { + userId: string; + botName: string; + botAlias: string; + controlTaskSid: string; + releaseType: ReleaseTypes; + studioFlowSid?: string; + memoryAttribute?: string; + releaseFlag?: string; + chatbotCallbackWebhookSid: string; +}; + +export const isChatCaptureControlTask = (taskAttributes: { isChatCaptureControl?: boolean }) => + Boolean(taskAttributes.isChatCaptureControl); + +/** + * The following sections captures all the required logic to "handle channel capture" (starting a capture on a chat channel) + * Capture handlers wrap the logic needed for capturing a channel: updating it's attributes, creating a control task, triggering a chatbot, etc + */ + +const updateChannelWithCapture = ( + channel: ChannelInstance, + attributes: CapturedChannelAttributes, +) => { + const { + userId, + botName, + botAlias, + controlTaskSid, + chatbotCallbackWebhookSid, + releaseType, + studioFlowSid, + memoryAttribute, + releaseFlag, + } = attributes; + const channelAttributes = JSON.parse(channel.attributes); + + return channel.update({ + attributes: JSON.stringify({ + ...channelAttributes, + // All of this can be passed as url params to the webhook instead + capturedChannelAttributes: { + userId, + botName, + botAlias, + controlTaskSid, + chatbotCallbackWebhookSid, + releaseType, + ...(studioFlowSid && { studioFlowSid }), + ...(releaseFlag && { releaseFlag }), + ...(memoryAttribute && { memoryAttribute }), + }, + }), + }); +}; + +type CaptureChannelOptions = { + botName: string; + botAlias: string; + inputText: string; + userId: string; + controlTaskSid: string; + releaseType: ReleaseTypes; + studioFlowSid?: string; // (in Studio Flow, flow.flow_sid) The Studio Flow sid. Needed to trigger an API type execution once the channel is released. + memoryAttribute?: string; // where in the task attributes we want to save the bot's memory (allows compatibility for multiple bots) + releaseFlag?: string; // the flag we want to set true when the channel is released +}; + +/** + * Trigger a chatbot execution by redirecting a message that already exists in the channel (used to trigger executions from service user messages) + */ +const triggerRedirectingMessage = async ( + context: Context, + channel: ChannelInstance, + { + userId, + botName, + botAlias, + inputText, + controlTaskSid, + releaseType, + studioFlowSid, + releaseFlag, + memoryAttribute, + }: CaptureChannelOptions, +) => { + const chatbotCallbackWebhook = await channel.webhooks().create({ + type: 'webhook', + configuration: { + filters: ['onMessageSent'], + method: 'POST', + url: `https://${context.DOMAIN_NAME}/channelCapture/chatbotCallback`, + }, + }); + + // const updated = + await updateChannelWithCapture(channel, { + userId, + botName, + botAlias, + controlTaskSid, + releaseType, + studioFlowSid, + releaseFlag, + memoryAttribute, + chatbotCallbackWebhookSid: chatbotCallbackWebhook.sid, + }); + + const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; + const lexClient = require(handlerPath) as LexClient; + + const lexResponse = await lexClient.postText(context, { + botName, + botAlias, + userId, + inputText, + }); + + // Send message to trigger the recently created chatbot integration + await channel.messages().create({ + body: lexResponse.message, + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }); +}; + +/** + * Send a message to the channel and add the chatbot after, so it will get triggered on the next response from the service user (used to trigger executions from system, like post surveys) + */ +const triggerOnNextMessage = async ( + context: Context, + channel: ChannelInstance, + { + userId, + botName, + botAlias, + inputText, + controlTaskSid, + releaseType, + studioFlowSid, + releaseFlag, + memoryAttribute, + }: CaptureChannelOptions, +) => { + /** const messageResult = */ + await channel.messages().create({ + body: inputText, + xTwilioWebhookEnabled: 'true', + }); + + const chatbotCallbackWebhook = await channel.webhooks().create({ + type: 'webhook', + configuration: { + filters: ['onMessageSent'], + method: 'POST', + url: `https://${context.DOMAIN_NAME}/webhooks/chatbotCallback`, + }, + }); + + // const updated = + await updateChannelWithCapture(channel, { + userId, + botName, + botAlias, + controlTaskSid, + releaseType, + studioFlowSid, + releaseFlag, + memoryAttribute, + chatbotCallbackWebhookSid: chatbotCallbackWebhook.sid, + }); +}; + +type HandleChannelCaptureParams = { + channelSid: string; // The channel to capture (in Studio Flow, flow.channel.address) + message: string; // The triggering message (in Studio Flow, trigger.message.Body) + language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} ) + botSuffix: string; + triggerType: TriggerTypes; + releaseType: ReleaseTypes; + studioFlowSid?: string; // The Studio Flow sid. Needed to trigger an API type execution once the channel is released. (in Studio Flow, flow.flow_sid) + memoryAttribute?: string; // Where in the channel attributes we want to save the bot's memory (allows usage of multiple bots in same channel) + releaseFlag?: string; // The flag we want to set true in the channel attributes when the channel is released + additionControlTaskAttributes?: string; // Optional attributes to include in the control task, in the string representation of a JSON + controlTaskTTL?: number; +}; + +type ValidationResult = { status: 'valid' } | { status: 'invalid'; error: string }; + +const createValidationError = (error: string): ValidationResult => ({ status: 'invalid', error }); + +const validateHandleChannelCaptureParams = (params: Partial) => { + if (!params.channelSid) { + return createValidationError('Missing channelSid'); + } + if (!params.message) { + return createValidationError('Missing message'); + } + if (!params.triggerType) { + return createValidationError('Missing triggerType'); + } + if (!triggerTypes.includes(params.triggerType)) { + return createValidationError(`triggerType must be one of: ${triggerTypes.join(', ')}`); + } + if (!params.releaseType) { + return createValidationError('Missing releaseType'); + } + if (!releaseTypes.includes(params.releaseType)) { + return createValidationError(`releaseType must be one of: ${releaseTypes.join(', ')}`); + } + if (params.releaseType === 'triggerStudioFlow' && !params.studioFlowSid) { + return createValidationError( + 'studioFlowSid must provided when releaseType is triggerStudioFlow', + ); + } + if (!params.botSuffix) { + return createValidationError('botSuffix'); + } + if (!params.language) { + return createValidationError('language'); + } + + return { status: 'valid' } as const; +}; + +export const handleChannelCapture = async ( + context: Context, + params: HandleChannelCaptureParams, +) => { + const { + channelSid, + message, + language, + botSuffix, + triggerType, + releaseType, + studioFlowSid, + memoryAttribute, + releaseFlag, + additionControlTaskAttributes, + controlTaskTTL, + } = params; + const validationResult = validateHandleChannelCaptureParams(params); + if (validationResult.status === 'invalid') { + return { status: 'failure', validationResult } as const; + } + + const parsedAdditionalControlTaskAttributes = additionControlTaskAttributes + ? JSON.parse(additionControlTaskAttributes) + : {}; + + const channel = await context + .getTwilioClient() + .chat.v2.services(context.CHAT_SERVICE_SID) + .channels(channelSid) + .fetch(); + + const channelWebhooks = await context + .getTwilioClient() + .chat.services(context.CHAT_SERVICE_SID) + .channels(channelSid) + .webhooks.list(); + + // Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions + await Promise.all( + channelWebhooks.map(async (w) => { + if (w.type === 'studio') { + await w.remove(); + } + }), + ); + + const { ENVIRONMENT, HELPLINE_CODE } = context; + const languageSanitized = language.replace('-', '_'); // Lex doesn't accept '-' + const botName = `${ENVIRONMENT}_${HELPLINE_CODE.toLowerCase()}_${languageSanitized}_${botSuffix}`; + + // Create control task to prevent channel going stale + const controlTask = await context + .getTwilioClient() + .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) + .tasks.create({ + workflowSid: context.SURVEY_WORKFLOW_SID, + taskChannel: 'survey', + attributes: JSON.stringify({ + isChatCaptureControl: true, + channelSid, + ...parsedAdditionalControlTaskAttributes, + }), + timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours + }); + + // If there's no service user, find which is the first one and add it channel attributes (only occurs on first capture) + const channelAttributes = JSON.parse(channel.attributes); + if (!channelAttributes.fromServiceUser) { + const members = await channel.members().list(); + const firstMember = members.sort((a, b) => (a.dateCreated > b.dateCreated ? 1 : -1))[0]; + await channel.update({ + attributes: { ...channelAttributes, fromServiceUser: firstMember.identity }, + }); + } + + const options: CaptureChannelOptions = { + botName, + botAlias: 'latest', // Assume we always use the latest published version + releaseType, + studioFlowSid, + memoryAttribute, + releaseFlag, + inputText: message, + userId: channel.sid, + controlTaskSid: controlTask.sid, + }; + + if (triggerType === 'redirectMessage') { + await triggerRedirectingMessage(context, channel, options); + } + + if (triggerType === 'onNextMessage') { + await triggerOnNextMessage(context, channel, options); + } + + return { status: 'success' } as const; +}; + +/** + * The following sections captures all the required logic to "handle channel release" (releasing a chat channel that was captured) + * Release handlers wrap the logic needed for releasing a channel: updating it's attributes, removing the control task, redirecting a channel into a Studio Flow, saving data gathered by the bot in HRM/insights, etc + */ + +const createStudioFlowTrigger = async ( + channel: ChannelInstance, + capturedChannelAttributes: CapturedChannelAttributes, + controlTask: TaskInstance, +) => { + // Canceling tasks triggers janitor (see functions/taskrouterListeners/janitorListener.private.ts), so we remove this one since is not needed + controlTask.remove(); + + return channel.webhooks().create({ + type: 'studio', + configuration: { + flowSid: capturedChannelAttributes.studioFlowSid, + }, + }); +}; + +type PostSurveyBody = { + contactTaskId: string; + taskId: string; + data: PostSurveyData; +}; + +const saveSurveyInInsights = async ( + postSurveyConfigJson: OneToManyConfigSpec[], + memory: LexMemory, + controlTask: TaskInstance, + controlTaskAttributes: any, +) => { + const handlerPath = Runtime.getFunctions()['helpers/insightsService'].path; + const buildSurveyInsightsData = require(handlerPath) + .buildSurveyInsightsData as BuildSurveyInsightsData; + + const finalAttributes = buildSurveyInsightsData( + postSurveyConfigJson, + controlTaskAttributes, + memory, + ); + + await controlTask.update({ attributes: JSON.stringify(finalAttributes) }); +}; + +const saveSurveyInHRM = async ( + postSurveyConfigJson: OneToManyConfigSpec[], + memory: LexMemory, + controlTask: TaskInstance, + controlTaskAttributes: any, + hrmBaseUrl: string, + hrmStaticKey: string, +) => { + const handlerPath = Runtime.getFunctions()['helpers/hrmDataManipulation'].path; + const buildDataObject = require(handlerPath).buildDataObject as BuildDataObject; + + const data = buildDataObject(postSurveyConfigJson, memory); + + const body: PostSurveyBody = { + contactTaskId: controlTaskAttributes.contactTaskId, + taskId: controlTask.sid, + data, + }; + + await axios({ + url: `${hrmBaseUrl}/postSurveys`, + method: 'POST', + data: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${hrmStaticKey}`, + }, + }); +}; + +const handlePostSurveyComplete = async ( + context: Context, + memory: LexMemory, + controlTask: TaskInstance, +) => { + const client = context.getTwilioClient(); + + // get the postSurvey definition + const serviceConfig = await client.flexApi.configuration.get().fetch(); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { definitionVersion, hrm_base_url, hrm_api_version } = serviceConfig.attributes; + const postSurveyConfigJson = + Runtime.getAssets()[`/formDefinitions/${definitionVersion}/insights/postSurvey.json`]; + const hrmBaseUrl = `${hrm_base_url}/${hrm_api_version}/accounts/${serviceConfig.accountSid}`; + + if (definitionVersion && postSurveyConfigJson && postSurveyConfigJson.open) { + const postSurveyConfigSpecs = JSON.parse(postSurveyConfigJson.open()) as OneToManyConfigSpec[]; + + const controlTaskAttributes = JSON.parse(controlTask.attributes); + + // parallel execution to save survey collected data in insights and hrm + await Promise.all([ + saveSurveyInInsights(postSurveyConfigSpecs, memory, controlTask, controlTaskAttributes), + saveSurveyInHRM( + postSurveyConfigSpecs, + memory, + controlTask, + controlTaskAttributes, + hrmBaseUrl, + context.HRM_STATIC_KEY, + ), + ]); + + // As survey tasks will never be assigned to a worker, they'll be kept in pending state. A pending can't transition to completed state, so we cancel them here to raise a task.canceled taskrouter event (see functions/taskrouterListeners/janitorListener.private.ts) + // This needs to be the last step so the new task attributes from saveSurveyInInsights make it to insights + await controlTask.update({ assignmentStatus: 'canceled' }); + } else { + const errorMEssage = + // eslint-disable-next-line no-nested-ternary + !definitionVersion + ? 'Current definitionVersion is missing in service configuration.' + : !postSurveyConfigJson + ? `No postSurveyConfigJson found for definitionVersion ${definitionVersion}.` + : `postSurveyConfigJson for definitionVersion ${definitionVersion} is not a Twilio asset as expected`; // This should removed when if we move definition versions to an external source. + console.error(`Error accessing to the post survey form definitions: ${errorMEssage}`); + } +}; + +export const handleChannelRelease = async ( + context: Context, + channel: ChannelInstance, + capturedChannelAttributes: CapturedChannelAttributes, + memory: LexMemory, +) => { + // get the control task + const controlTask = await context + .getTwilioClient() + .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) + .tasks(capturedChannelAttributes.controlTaskSid) + .fetch(); + + if (capturedChannelAttributes.releaseType === 'triggerStudioFlow') { + await createStudioFlowTrigger(channel, capturedChannelAttributes, controlTask); + } + + if (capturedChannelAttributes.releaseType === 'postSurveyComplete') { + await handlePostSurveyComplete(context, memory, controlTask); + } +}; + +export type ChannelCaptureHandlers = { + isChatCaptureControlTask: typeof isChatCaptureControlTask; + handleChannelCapture: typeof handleChannelCapture; + handleChannelRelease: typeof handleChannelRelease; +}; diff --git a/functions/webhooks/chatbotCallback.protected.ts b/functions/channelCapture/chatbotCallback.protected.ts similarity index 69% rename from functions/webhooks/chatbotCallback.protected.ts rename to functions/channelCapture/chatbotCallback.protected.ts index 6b70c7d5..ef0e4f85 100644 --- a/functions/webhooks/chatbotCallback.protected.ts +++ b/functions/channelCapture/chatbotCallback.protected.ts @@ -27,14 +27,22 @@ import { } from '@tech-matters/serverless-helpers'; import { omit } from 'lodash'; import type { WebhookEvent } from '../helpers/customChannels/flexToCustomChannel.private'; -import { LexClient } from '../helpers/lexClient.private'; +import type { AWSCredentials, LexClient } from '../helpers/lexClient.private'; +import type { + CapturedChannelAttributes, + ChannelCaptureHandlers, +} from './channelCaptureHandlers.private'; -type EnvVars = { +type EnvVars = AWSCredentials & { CHAT_SERVICE_SID: string; ASELO_APP_ACCESS_KEY: string; ASELO_APP_SECRET_KEY: string; AWS_REGION: string; TWILIO_WORKSPACE_SID: string; + HRM_STATIC_KEY: string; + HELPLINE_CODE: string; + ENVIRONMENT: string; + SURVEY_WORKFLOW_SID: string; }; export type Body = Partial & {}; @@ -78,62 +86,56 @@ export const handler = async ( // Send message to bot only if it's from child if (EventType === 'onMessageSent' && channelAttributes.fromServiceUser === From) { - const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; - const lexClient = require(handlerPath) as LexClient; + const lexClient = require(Runtime.getFunctions()['helpers/lexClient'].path) as LexClient; + + const capturedChannelAttributes = + channelAttributes.capturedChannelAttributes as CapturedChannelAttributes; const lexResponse = await lexClient.postText(context, { - botName: channelAttributes.channelCapturedByBot.botName, - botAlias: channelAttributes.channelCapturedByBot.botAlias, + botName: capturedChannelAttributes.botName, + botAlias: capturedChannelAttributes.botAlias, + userId: capturedChannelAttributes.userId, inputText: Body, - userId: channel.sid, }); // If the session ended, we should unlock the channel to continue the Studio Flow if (lexClient.isEndOfDialog(lexResponse.dialogState)) { + const memory = lexResponse.slots || {}; + const releasedChannelAttributes = { - ...omit(channelAttributes, 'channelCapturedByBot'), - memory: lexResponse.slots, - preSurveyComplete: true, + ...omit(channelAttributes, ['capturedChannelAttributes']), + ...(capturedChannelAttributes.memoryAttribute + ? { [capturedChannelAttributes.memoryAttribute]: memory } + : { memory }), + ...(capturedChannelAttributes.releaseFlag && { + [capturedChannelAttributes.releaseFlag]: true, + }), }; - // TODO: This is now only assuming pre-survey bot. We should have a way to specify what's the next step after the bot execution is ended - const nextAction = () => - channel.webhooks().create({ - type: 'studio', - configuration: { - flowSid: channelAttributes.channelCapturedByBot.studioFlowSid, - }, - }); + const channelCaptureHandlers = require(Runtime.getFunctions()[ + 'channelCapture/channelCaptureHandlers' + ].path) as ChannelCaptureHandlers; await Promise.all([ // Delete Lex session. This is not really needed as the session will expire, but that depends on the config of Lex. lexClient.deleteSession(context, { - botName: channelAttributes.channelCapturedByBot.botName, - botAlias: channelAttributes.channelCapturedByBot.botAlias, + botName: capturedChannelAttributes.botName, + botAlias: capturedChannelAttributes.botAlias, userId: channel.sid, }), // Update channel attributes (remove channelCapturedByBot and add memory) channel.update({ attributes: JSON.stringify(releasedChannelAttributes), }), - // Move control task to complete state - (async () => { - try { - await client.taskrouter.v1 - .workspaces(context.TWILIO_WORKSPACE_SID) - .tasks(channelAttributes.controlTaskSid) - .update({ assignmentStatus: 'completed' }); - } catch (err) { - console.log(err); - } - })(), // Remove this webhook from the channel - channel - .webhooks() - .get(channelAttributes.channelCapturedByBot.chatbotCallbackWebhookSid) - .remove(), + channel.webhooks().get(capturedChannelAttributes.chatbotCallbackWebhookSid).remove(), // Trigger the next step once the channel is released - nextAction(), + channelCaptureHandlers.handleChannelRelease( + context, + channel, + capturedChannelAttributes, + memory, + ), ]); console.log('Channel unblocked and bot session deleted'); From c65e28afb693124d7a1149cbc07f0a4ef8ea6b96 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Fri, 14 Jul 2023 18:22:46 -0300 Subject: [PATCH 03/13] Adapted post survey init to new format, support changes in legacy post survey complete --- functions/postSurveyComplete.protected.ts | 13 +++--- functions/postSurveyInit.ts | 55 +++++++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/functions/postSurveyComplete.protected.ts b/functions/postSurveyComplete.protected.ts index 4e38cc4a..add22215 100644 --- a/functions/postSurveyComplete.protected.ts +++ b/functions/postSurveyComplete.protected.ts @@ -24,7 +24,6 @@ import { } from '@twilio-labs/serverless-runtime-types/types'; // We use axios instead of node-fetch in this repo because the later one raises a run time error when trying to import it. The error is related to how JS modules are loaded. import axios from 'axios'; -// eslint-disable-next-line prettier/prettier import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; import type { BuildSurveyInsightsData, @@ -32,7 +31,7 @@ import type { } from './helpers/insightsService.private'; import type { BuildDataObject, PostSurveyData } from './helpers/hrmDataManipulation.private'; -export type BotMemory = { +export type AutopilotMemory = { memory: { twilio: { collected_data: { collect_survey: { [question: string]: string | number } } }; }; @@ -57,9 +56,12 @@ type EnvVars = { HRM_STATIC_KEY: string; }; +const pathBuilder = (question: string) => + `twilio.collected_data.collect_survey.answers.${question}.answer`; + const saveSurveyInInsights = async ( postSurveyConfigJson: OneToManyConfigSpec[], - memory: BotMemory, + memory: AutopilotMemory, surveyTask: TaskInstance, surveyTaskAttributes: any, ) => { @@ -71,6 +73,7 @@ const saveSurveyInInsights = async ( postSurveyConfigJson, surveyTaskAttributes, memory, + pathBuilder, ); await surveyTask.update({ attributes: JSON.stringify(finalAttributes) }); @@ -78,7 +81,7 @@ const saveSurveyInInsights = async ( const saveSurveyInHRM = async ( postSurveyConfigJson: OneToManyConfigSpec[], - memory: BotMemory, + memory: AutopilotMemory, surveyTask: TaskInstance, surveyTaskAttributes: any, hrmBaseUrl: string, @@ -87,7 +90,7 @@ const saveSurveyInHRM = async ( const handlerPath = Runtime.getFunctions()['helpers/hrmDataManipulation'].path; const buildDataObject = require(handlerPath).buildDataObject as BuildDataObject; - const data = buildDataObject(postSurveyConfigJson, memory); + const data = buildDataObject(postSurveyConfigJson, memory, pathBuilder); const body: PostSurveyBody = { contactTaskId: surveyTaskAttributes.contactTaskId, diff --git a/functions/postSurveyInit.ts b/functions/postSurveyInit.ts index f182844a..78138fd1 100644 --- a/functions/postSurveyInit.ts +++ b/functions/postSurveyInit.ts @@ -25,12 +25,17 @@ import { functionValidator as TokenValidator, } from '@tech-matters/serverless-helpers'; import axios from 'axios'; +import type { ChannelCaptureHandlers } from './channelCapture/channelCaptureHandlers.private'; +import type { AWSCredentials } from './helpers/lexClient.private'; -type EnvVars = { +type EnvVars = AWSCredentials & { CHAT_SERVICE_SID: string; TWILIO_WORKSPACE_SID: string; SURVEY_WORKFLOW_SID: string; POST_SURVEY_BOT_CHAT_URL: string; + HRM_STATIC_KEY: string; + HELPLINE_CODE: string; + ENVIRONMENT: string; }; export type Body = { @@ -127,14 +132,48 @@ const getTriggerMessage = async ( export const postSurveyInitHandler = async ( context: Context, - event: Required> & Pick, + event: Required>, ) => { const { channelSid, taskSid, taskLanguage } = event; const triggerMessage = await getTriggerMessage(event, context); + const serviceConfig = await context.getTwilioClient().flexApi.configuration.get().fetch(); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { enable_lex } = serviceConfig.attributes.feature_flags; + + if (enable_lex) { + // eslint-disable-next-line import/no-dynamic-require, global-require + const channelCaptureHandlers = require(Runtime.getFunctions()[ + 'channelCapture/channelCaptureHandlers' + ].path) as ChannelCaptureHandlers; + + const result = await channelCaptureHandlers.handleChannelCapture(context, { + channelSid, + message: triggerMessage, + language: taskLanguage, + botSuffix: 'post_survey', + triggerType: 'onNextMessage', + releaseType: 'postSurveyComplete', + // studioFlowSid, + memoryAttribute: 'postSurvey', + releaseFlag: 'postSuveyComplete', + additionControlTaskAttributes: JSON.stringify({ + isSurveyTask: true, + contactTaskId: taskSid, + conversations: { conversation_id: taskSid }, + language: taskLanguage, // if there's a task language, attach it to the post survey task + }), + controlTaskTTL: 3600, + }); + + return result; + } + + // Else, use legacy post survey await createSurveyTask(context, { channelSid, taskSid, taskLanguage }); await triggerPostSurveyFlow(context, channelSid, triggerMessage); + return { status: 'success' } as const; }; export type PostSurveyInitHandler = typeof postSurveyInitHandler; @@ -152,7 +191,17 @@ export const handler = TokenValidator( if (channelSid === undefined) return resolve(error400('channelSid')); if (taskSid === undefined) return resolve(error400('taskSid')); - await postSurveyInitHandler(context, { channelSid, taskSid, taskLanguage }); + const result = await postSurveyInitHandler(context, { + channelSid, + taskSid, + taskLanguage: taskLanguage || 'en-US', + }); + + if (result.status === 'failure' && result.validationResult.status === 'invalid') { + resolve(error400(result.validationResult.error)); + // eslint-disable-next-line consistent-return + return; + } return resolve(success(JSON.stringify({ message: 'Post survey init OK!' }))); } catch (err: any) { From 48299a791584b866405668db466c0f69a1aa6de0 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Fri, 14 Jul 2023 18:23:29 -0300 Subject: [PATCH 04/13] Modified janitorListener to avoid shutting down channel because of capture control tasks --- .../janitorListener.private.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/functions/taskrouterListeners/janitorListener.private.ts b/functions/taskrouterListeners/janitorListener.private.ts index ae586505..6e63562d 100644 --- a/functions/taskrouterListeners/janitorListener.private.ts +++ b/functions/taskrouterListeners/janitorListener.private.ts @@ -31,6 +31,7 @@ import { import type { ChatChannelJanitor } from '../helpers/chatChannelJanitor.private'; import type { ChannelToFlex } from '../helpers/customChannels/customChannelToFlex.private'; +import { ChannelCaptureHandlers } from '../channelCapture/channelCaptureHandlers.private'; export const eventTypes: EventType[] = [ TASK_CANCELED, @@ -47,18 +48,21 @@ type EnvVars = { // This applies to both pre-survey(isChatCaptureControl) and post-survey const isCleanupBotCapture = ( eventType: EventType, - taskAttributes: { isSurveyTask?: boolean; isChatCaptureControl?: boolean }, + taskAttributes: { isChatCaptureControl?: boolean }, ) => { - if (taskAttributes.isSurveyTask) { - return eventType === TASK_CANCELED || eventType === TASK_WRAPUP; - } - if (taskAttributes.isChatCaptureControl) { - return eventType === TASK_CANCELED; + if (eventType === TASK_CANCELED) { + const channelCaptureHandlers = require(Runtime.getFunctions()[ + 'channelCapture/channelCaptureHandlers.private.ts' + ].path) as ChannelCaptureHandlers; + return channelCaptureHandlers.isChatCaptureControlTask(taskAttributes); } return false; }; -const isCleanupCustomChannel = (eventType: EventType, taskAttributes: { channelType?: string }) => { +const isCleanupCustomChannel = ( + eventType: EventType, + taskAttributes: { channelType?: string; isChatCaptureControl?: boolean }, +) => { if ( !( eventType === TASK_DELETED || @@ -69,8 +73,16 @@ const isCleanupCustomChannel = (eventType: EventType, taskAttributes: { channelT return false; } - const handlerPath = Runtime.getFunctions()['helpers/customChannels/customChannelToFlex'].path; - const channelToFlex = require(handlerPath) as ChannelToFlex; + const channelCaptureHandlers = require(Runtime.getFunctions()[ + 'channelCapture/channelCaptureHandlers.private.ts' + ].path) as ChannelCaptureHandlers; + + if (channelCaptureHandlers.isChatCaptureControlTask(taskAttributes)) { + return false; + } + + const channelToFlex = require(Runtime.getFunctions()['helpers/customChannels/customChannelToFlex'] + .path) as ChannelToFlex; return channelToFlex.isAseloCustomChannel(taskAttributes.channelType); }; @@ -95,15 +107,13 @@ export const handleEvent = async (context: Context, event: EventFields) const taskAttributes = JSON.parse(taskAttributesString); if (isCleanupBotCapture(eventType, taskAttributes)) { - const cleanupType = taskAttributes.isChatCaptureControl ? 'pre-survey' : 'post-survey'; - await wait(3000); // wait 3 seconds just in case some bot message is pending - const handlerPath = Runtime.getFunctions()['helpers/chatChannelJanitor'].path; - const chatChannelJanitor = require(handlerPath).chatChannelJanitor as ChatChannelJanitor; + const chatChannelJanitor = require(Runtime.getFunctions()['helpers/chatChannelJanitor'].path) + .chatChannelJanitor as ChatChannelJanitor; await chatChannelJanitor(context, { channelSid: taskAttributes.channelSid }); - console.log(`Finished handling clean up for ${cleanupType}.`); + console.log('Finished handling clean up.'); return; } @@ -111,8 +121,8 @@ export const handleEvent = async (context: Context, event: EventFields) if (isCleanupCustomChannel(eventType, taskAttributes)) { console.log('Handling clean up custom channel...'); - const handlerPath = Runtime.getFunctions()['helpers/chatChannelJanitor'].path; - const chatChannelJanitor = require(handlerPath).chatChannelJanitor as ChatChannelJanitor; + const chatChannelJanitor = require(Runtime.getFunctions()['helpers/chatChannelJanitor'].path) + .chatChannelJanitor as ChatChannelJanitor; await chatChannelJanitor(context, { channelSid: taskAttributes.channelSid }); console.log('Finished handling clean up custom channel.'); From 49d43280339ad857a25919ae8a4bfc51f6111c69 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 18 Jul 2023 19:22:37 -0300 Subject: [PATCH 05/13] Moved service user addition to a single channel update, fixed issues --- .../channelCaptureHandlers.private.ts | 94 +++++++++++-------- .../chatbotCallback.protected.ts | 2 +- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/functions/channelCapture/channelCaptureHandlers.private.ts b/functions/channelCapture/channelCaptureHandlers.private.ts index 75b7ce59..e90c20ca 100644 --- a/functions/channelCapture/channelCaptureHandlers.private.ts +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -19,6 +19,7 @@ import axios from 'axios'; import type { Context } from '@twilio-labs/serverless-runtime-types/types'; import type { ChannelInstance } from 'twilio/lib/rest/chat/v2/service/channel'; import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; +import { MemberInstance } from 'twilio/lib/rest/ipMessaging/v2/service/channel/member'; import type { AWSCredentials, LexClient, LexMemory } from '../helpers/lexClient.private'; import type { BuildDataObject, PostSurveyData } from '../helpers/hrmDataManipulation.private'; import type { @@ -61,7 +62,24 @@ export const isChatCaptureControlTask = (taskAttributes: { isChatCaptureControl? * Capture handlers wrap the logic needed for capturing a channel: updating it's attributes, creating a control task, triggering a chatbot, etc */ -const updateChannelWithCapture = ( +const getServiceUserIdentity = async ( + channel: ChannelInstance, + channelAttributes: { [k: string]: string }, +): Promise => { + // If there's no service user, find which is the first one and add it channel attributes (only occurs on first capture) + if (!channelAttributes.serviceUserIdentity) { + console.log('Setting serviceUserIdentity'); + const members = await channel.members().list(); + console.log('members: ', JSON.stringify(members)); + const firstMember = members.sort((a, b) => (a.dateCreated > b.dateCreated ? 1 : -1))[0]; + console.log('firstMember: ', JSON.stringify(firstMember)); + return firstMember.identity; + } + + return channelAttributes.serviceUserIdentity; +}; + +const updateChannelWithCapture = async ( channel: ChannelInstance, attributes: CapturedChannelAttributes, ) => { @@ -76,11 +94,15 @@ const updateChannelWithCapture = ( memoryAttribute, releaseFlag, } = attributes; + const channelAttributes = JSON.parse(channel.attributes); + const serviceUserIdentity = await getServiceUserIdentity(channel, channelAttributes); + return channel.update({ attributes: JSON.stringify({ ...channelAttributes, + serviceUserIdentity, // All of this can be passed as url params to the webhook instead capturedChannelAttributes: { userId, @@ -196,7 +218,7 @@ const triggerOnNextMessage = async ( configuration: { filters: ['onMessageSent'], method: 'POST', - url: `https://${context.DOMAIN_NAME}/webhooks/chatbotCallback`, + url: `https://${context.DOMAIN_NAME}/channelCapture/chatbotCallback`, }, }); @@ -298,50 +320,40 @@ export const handleChannelCapture = async ( .channels(channelSid) .fetch(); - const channelWebhooks = await context - .getTwilioClient() - .chat.services(context.CHAT_SERVICE_SID) - .channels(channelSid) - .webhooks.list(); - - // Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions - await Promise.all( - channelWebhooks.map(async (w) => { - if (w.type === 'studio') { - await w.remove(); - } - }), - ); + const [, controlTask] = await Promise.all([ + // Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions + context + .getTwilioClient() + .chat.services(context.CHAT_SERVICE_SID) + .channels(channelSid) + .webhooks.list() + .then((channelWebhooks) => + channelWebhooks.map(async (w) => { + if (w.type === 'studio') { + await w.remove(); + } + }), + ), + // Create control task to prevent channel going stale + context + .getTwilioClient() + .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) + .tasks.create({ + workflowSid: context.SURVEY_WORKFLOW_SID, + taskChannel: 'survey', + attributes: JSON.stringify({ + isChatCaptureControl: true, + channelSid, + ...parsedAdditionalControlTaskAttributes, + }), + timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours + }), + ]); const { ENVIRONMENT, HELPLINE_CODE } = context; const languageSanitized = language.replace('-', '_'); // Lex doesn't accept '-' const botName = `${ENVIRONMENT}_${HELPLINE_CODE.toLowerCase()}_${languageSanitized}_${botSuffix}`; - // Create control task to prevent channel going stale - const controlTask = await context - .getTwilioClient() - .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) - .tasks.create({ - workflowSid: context.SURVEY_WORKFLOW_SID, - taskChannel: 'survey', - attributes: JSON.stringify({ - isChatCaptureControl: true, - channelSid, - ...parsedAdditionalControlTaskAttributes, - }), - timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours - }); - - // If there's no service user, find which is the first one and add it channel attributes (only occurs on first capture) - const channelAttributes = JSON.parse(channel.attributes); - if (!channelAttributes.fromServiceUser) { - const members = await channel.members().list(); - const firstMember = members.sort((a, b) => (a.dateCreated > b.dateCreated ? 1 : -1))[0]; - await channel.update({ - attributes: { ...channelAttributes, fromServiceUser: firstMember.identity }, - }); - } - const options: CaptureChannelOptions = { botName, botAlias: 'latest', // Assume we always use the latest published version diff --git a/functions/channelCapture/chatbotCallback.protected.ts b/functions/channelCapture/chatbotCallback.protected.ts index ef0e4f85..e59ee367 100644 --- a/functions/channelCapture/chatbotCallback.protected.ts +++ b/functions/channelCapture/chatbotCallback.protected.ts @@ -85,7 +85,7 @@ export const handler = async ( const channelAttributes = JSON.parse(channel.attributes); // Send message to bot only if it's from child - if (EventType === 'onMessageSent' && channelAttributes.fromServiceUser === From) { + if (EventType === 'onMessageSent' && channelAttributes.serviceUserIdentity === From) { const lexClient = require(Runtime.getFunctions()['helpers/lexClient'].path) as LexClient; const capturedChannelAttributes = From a93cc57b92235349831a6350290110460aa2d40a Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 18 Jul 2023 19:24:26 -0300 Subject: [PATCH 06/13] Avoid firing post survey from control tasks, fixed references errors --- functions/postSurveyInit.ts | 1 - .../janitorListener.private.ts | 4 ++-- .../postSurveyListener.private.ts | 24 +++++++++++++------ 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/functions/postSurveyInit.ts b/functions/postSurveyInit.ts index 78138fd1..d51b46e8 100644 --- a/functions/postSurveyInit.ts +++ b/functions/postSurveyInit.ts @@ -155,7 +155,6 @@ export const postSurveyInitHandler = async ( botSuffix: 'post_survey', triggerType: 'onNextMessage', releaseType: 'postSurveyComplete', - // studioFlowSid, memoryAttribute: 'postSurvey', releaseFlag: 'postSuveyComplete', additionControlTaskAttributes: JSON.stringify({ diff --git a/functions/taskrouterListeners/janitorListener.private.ts b/functions/taskrouterListeners/janitorListener.private.ts index 6e63562d..60f84a9a 100644 --- a/functions/taskrouterListeners/janitorListener.private.ts +++ b/functions/taskrouterListeners/janitorListener.private.ts @@ -52,7 +52,7 @@ const isCleanupBotCapture = ( ) => { if (eventType === TASK_CANCELED) { const channelCaptureHandlers = require(Runtime.getFunctions()[ - 'channelCapture/channelCaptureHandlers.private.ts' + 'channelCapture/channelCaptureHandlers' ].path) as ChannelCaptureHandlers; return channelCaptureHandlers.isChatCaptureControlTask(taskAttributes); } @@ -74,7 +74,7 @@ const isCleanupCustomChannel = ( } const channelCaptureHandlers = require(Runtime.getFunctions()[ - 'channelCapture/channelCaptureHandlers.private.ts' + 'channelCapture/channelCaptureHandlers' ].path) as ChannelCaptureHandlers; if (channelCaptureHandlers.isChatCaptureControlTask(taskAttributes)) { diff --git a/functions/taskrouterListeners/postSurveyListener.private.ts b/functions/taskrouterListeners/postSurveyListener.private.ts index 600d56b4..29d8b00e 100644 --- a/functions/taskrouterListeners/postSurveyListener.private.ts +++ b/functions/taskrouterListeners/postSurveyListener.private.ts @@ -25,17 +25,21 @@ import { EventType, TASK_WRAPUP, } from '@tech-matters/serverless-helpers/taskrouter'; -import type { ChannelToFlex } from '../helpers/customChannels/customChannelToFlex.private'; import type { TransferMeta } from './transfersListener.private'; import type { PostSurveyInitHandler } from '../postSurveyInit'; +import type { AWSCredentials } from '../helpers/lexClient.private'; +import type { ChannelCaptureHandlers } from '../channelCapture/channelCaptureHandlers.private'; export const eventTypes: EventType[] = [TASK_WRAPUP]; -export type EnvVars = { +export type EnvVars = AWSCredentials & { CHAT_SERVICE_SID: string; TWILIO_WORKSPACE_SID: string; SURVEY_WORKFLOW_SID: string; POST_SURVEY_BOT_CHAT_URL: string; + HRM_STATIC_KEY: string; + HELPLINE_CODE: string; + ENVIRONMENT: string; }; // ================== // @@ -48,18 +52,24 @@ const getTaskLanguage = (helplineLanguage: string) => (taskAttributes: { languag const isTriggerPostSurvey = ( eventType: EventType, taskChannelUniqueName: string, - taskAttributes: { channelType?: string; transferMeta?: TransferMeta }, + taskAttributes: { + channelType?: string; + transferMeta?: TransferMeta; + isChatCaptureControl?: boolean; + }, ) => { if (eventType !== TASK_WRAPUP) return false; // Post survey is for chat tasks only. This will change when we introduce voice based post surveys if (taskChannelUniqueName !== 'chat') return false; - // Post survey does not plays well with custom channels (autopilot) - const handlerPath = Runtime.getFunctions()['helpers/customChannels/customChannelToFlex'].path; - const channelToFlex = require(handlerPath) as ChannelToFlex; + const channelCaptureHandlers = require(Runtime.getFunctions()[ + 'channelCapture/channelCaptureHandlers' + ].path) as ChannelCaptureHandlers; - if (channelToFlex.isAseloCustomChannel(taskAttributes.channelType)) return false; + if (channelCaptureHandlers.isChatCaptureControlTask(taskAttributes)) { + return false; + } return true; }; From 796e23bd678f87de7b12b3f25912574869c59f2c Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Wed, 19 Jul 2023 21:47:00 -0300 Subject: [PATCH 07/13] Renamed trigger types --- .../channelCaptureHandlers.private.ts | 14 +++++++------- functions/postSurveyInit.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/functions/channelCapture/channelCaptureHandlers.private.ts b/functions/channelCapture/channelCaptureHandlers.private.ts index e90c20ca..3c6de662 100644 --- a/functions/channelCapture/channelCaptureHandlers.private.ts +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -36,7 +36,7 @@ type EnvVars = AWSCredentials & { SURVEY_WORKFLOW_SID: string; }; -const triggerTypes = ['redirectMessage', 'onNextMessage'] as const; +const triggerTypes = ['withUserMessage', 'withNextMessage'] as const; export type TriggerTypes = typeof triggerTypes[number]; const releaseTypes = ['triggerStudioFlow', 'postSurveyComplete'] as const; @@ -134,7 +134,7 @@ type CaptureChannelOptions = { /** * Trigger a chatbot execution by redirecting a message that already exists in the channel (used to trigger executions from service user messages) */ -const triggerRedirectingMessage = async ( +const triggerWithUserMessage = async ( context: Context, channel: ChannelInstance, { @@ -192,7 +192,7 @@ const triggerRedirectingMessage = async ( /** * Send a message to the channel and add the chatbot after, so it will get triggered on the next response from the service user (used to trigger executions from system, like post surveys) */ -const triggerOnNextMessage = async ( +const triggerWithNextMessage = async ( context: Context, channel: ChannelInstance, { @@ -366,12 +366,12 @@ export const handleChannelCapture = async ( controlTaskSid: controlTask.sid, }; - if (triggerType === 'redirectMessage') { - await triggerRedirectingMessage(context, channel, options); + if (triggerType === 'withUserMessage') { + await triggerWithUserMessage(context, channel, options); } - if (triggerType === 'onNextMessage') { - await triggerOnNextMessage(context, channel, options); + if (triggerType === 'withNextMessage') { + await triggerWithNextMessage(context, channel, options); } return { status: 'success' } as const; diff --git a/functions/postSurveyInit.ts b/functions/postSurveyInit.ts index d51b46e8..da153e75 100644 --- a/functions/postSurveyInit.ts +++ b/functions/postSurveyInit.ts @@ -153,7 +153,7 @@ export const postSurveyInitHandler = async ( message: triggerMessage, language: taskLanguage, botSuffix: 'post_survey', - triggerType: 'onNextMessage', + triggerType: 'withNextMessage', releaseType: 'postSurveyComplete', memoryAttribute: 'postSurvey', releaseFlag: 'postSuveyComplete', From 3a085149d32b83d5d988a3a5a27ad3072811d132 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Mon, 24 Jul 2023 17:28:07 -0300 Subject: [PATCH 08/13] Added tests for chatbotCallback --- tests/channelCapture/chatbotCallback.test.ts | 366 +++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/channelCapture/chatbotCallback.test.ts diff --git a/tests/channelCapture/chatbotCallback.test.ts b/tests/channelCapture/chatbotCallback.test.ts new file mode 100644 index 00000000..cb2b4c14 --- /dev/null +++ b/tests/channelCapture/chatbotCallback.test.ts @@ -0,0 +1,366 @@ +/* eslint-disable no-underscore-dangle */ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +import each from 'jest-each'; +import type { ChannelInstance } from 'twilio/lib/rest/chat/v2/service/channel'; +import { + handler as chatbotCallback, + Body, +} from '../../functions/channelCapture/chatbotCallback.protected'; +import helpers from '../helpers'; +import * as lexClient from '../../functions/helpers/lexClient.private'; +import * as channelCaptureHandlers from '../../functions/channelCapture/channelCaptureHandlers.private'; + +// jest.mock('../../functions/helpers/lexClient.private') +// jest.mock('../../functions/channelCapture/channelCaptureHandlers.private'); + +const mockCreateMessage = jest.fn(); +const mockRemoveWebhook = jest.fn(); + +// Mocked before each test +let mockedChannel: any; +const defaultChannel = { + sid: 'CH123', + attributes: JSON.stringify({ + channelSid: 'CH123', + serviceUserIdentity: 'serviceUserIdentity', + capturedChannelAttributes: { + botName: 'botName', + botAlias: 'latest', + localeId: 'en_US', + userId: 'CH123', + controlTaskSid: 'WT123', + releaseType: 'triggerStudioFlow', + studioFlowSid: 'SF123', + chatbotCallbackWebhookSid: 'WH123', + // memoryAttribute: , + // releaseFlag: , + }, + }), + messages: () => ({ + create: mockCreateMessage, + }), + update: ({ attributes }: { attributes: string }) => ({ + ...mockedChannel, + attributes, + }), + webhooks: () => ({ + get: () => ({ + remove: mockRemoveWebhook, + }), + // create: jest.fn(), + }), +}; + +const context = { + getTwilioClient: () => ({ + chat: { + services: () => ({ + channels: () => ({ + fetch: () => mockedChannel, + }), + }), + }, + taskrouter: { + v1: { + workspaces: () => ({ + tasks: () => ({ + update: jest.fn(), + }), + }), + }, + }, + }), + DOMAIN_NAME: 'DOMAIN_NAME', + PATH: 'PATH', + SERVICE_SID: 'SERVICE_SID', + ENVIRONMENT_SID: 'ENVIRONMENT_SID', + CHAT_SERVICE_SID: 'CHAT_SERVICE_SID', + ASELO_APP_ACCESS_KEY: 'ASELO_APP_ACCESS_KEY', + ASELO_APP_SECRET_KEY: 'ASELO_APP_SECRET_KEY', + AWS_REGION: 'us-east-1', + TWILIO_WORKSPACE_SID: 'TWILIO_WORKSPACE_SID', + HRM_STATIC_KEY: 'HRM_STATIC_KEY', + HELPLINE_CODE: 'HELPLINE_CODE', + ENVIRONMENT: 'ENVIRONMENT', + SURVEY_WORKFLOW_SID: 'SURVEY_WORKFLOW_SID', +}; + +beforeAll(() => { + const runtime = new helpers.MockRuntime(context); + runtime._addFunction('helpers/lexClient', 'functions/helpers/lexClient.private'); + runtime._addFunction( + 'channelCapture/channelCaptureHandlers', + 'functions/channelCapture/channelCaptureHandlers.private', + ); + helpers.setup({}, runtime); +}); +beforeEach(() => { + mockedChannel = defaultChannel; +}); +afterAll(() => { + helpers.teardown(); +}); +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('chatbotCallback', () => { + each([ + { + event: { + Body: 'Test body', + From: 'serviceUserIdentity', + ChannelSid: 'CH123', + EventType: 'onSomeOtherEvent', + }, + whenDescription: 'EventType is not onMessageSent', + }, + { + event: { + Body: 'Test body', + From: 'someOtherUser', + ChannelSid: 'CH123', + EventType: 'onMessageSent', + }, + whenDescription: 'From is not serviceUserIdentity', + }, + ]).test('$whenDescription, ignore the event', async ({ event }) => { + const postTextSpy = jest.spyOn(lexClient, 'postText'); + const updateChannelSpy = jest.spyOn(mockedChannel, 'update'); + + await chatbotCallback(context as any, event, () => {}); + + expect(postTextSpy).not.toHaveBeenCalled(); + expect(updateChannelSpy).not.toHaveBeenCalled(); + expect(mockRemoveWebhook).not.toHaveBeenCalled(); + expect(mockCreateMessage).not.toHaveBeenCalled(); + }); + + test('when Lex response is not end of dialog, only redirect message to the channel', async () => { + const event: Body = { + Body: 'Test body', + From: 'serviceUserIdentity', + ChannelSid: 'CH123', + EventType: 'onMessageSent', + }; + + const postTextSpy = jest.spyOn(lexClient, 'postText').mockImplementation( + async () => + ({ + dialogState: 'ElicitIntent', + message: 'Some response from Lex', + } as any), + ); + const updateChannelSpy = jest.spyOn(mockedChannel, 'update'); + + await chatbotCallback(context as any, event, () => {}); + + expect(postTextSpy).toHaveBeenCalledWith(context, { + botName: 'botName', + botAlias: 'latest', + userId: 'CH123', + inputText: event.Body, + }); + expect(updateChannelSpy).not.toHaveBeenCalled(); + expect(mockRemoveWebhook).not.toHaveBeenCalled(); + expect(mockCreateMessage).toHaveBeenCalledWith({ + body: 'Some response from Lex', + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }); + }); + + each([ + { + dialogState: 'Fulfilled', + }, + { + dialogState: 'Failed', + }, + ]).test( + 'when Lex response is $dialogState, redirect message and run release channel handlers', + async ({ dialogState }) => { + const event: Body = { + Body: 'Test body', + From: 'serviceUserIdentity', + ChannelSid: 'CH123', + EventType: 'onMessageSent', + }; + + const memory = { + attribute1: 'attribute1', + attribute2: 'attribute2', + }; + + const { capturedChannelAttributes, ...channelAttributes } = JSON.parse( + mockedChannel.attributes, + ); + + const postTextSpy = jest.spyOn(lexClient, 'postText').mockImplementation( + async () => + ({ + dialogState, + message: 'Some response from Lex', + slots: memory, + } as any), + ); + const deleteSessionSpy = jest + .spyOn(lexClient, 'deleteSession') + .mockImplementation(() => Promise.resolve() as any); + const updateChannelSpy = jest.spyOn(mockedChannel, 'update'); + const handleChannelReleaseSpy = jest + .spyOn(channelCaptureHandlers, 'handleChannelRelease') + .mockImplementation(() => Promise.resolve()); + + await chatbotCallback(context as any, event, () => {}); + + expect(postTextSpy).toHaveBeenCalledWith(context, { + botName: 'botName', + botAlias: 'latest', + userId: 'CH123', + inputText: event.Body, + }); + expect(deleteSessionSpy).toHaveBeenCalledWith(context, { + botName: 'botName', + botAlias: 'latest', + userId: 'CH123', + }); + expect(updateChannelSpy).toHaveBeenCalledWith({ + attributes: JSON.stringify({ + ...channelAttributes, + memory, + }), + }); + expect(mockRemoveWebhook).toHaveBeenCalled(); + expect(handleChannelReleaseSpy).toHaveBeenCalledWith( + context, + mockedChannel, + capturedChannelAttributes, + memory, + ); + expect(mockCreateMessage).toHaveBeenCalledWith({ + body: 'Some response from Lex', + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }); + }, + ); + + test('when releaseFlag is set, channel attributes contain "releaseFlag: true"', async () => { + const event: Body = { + Body: 'Test body', + From: 'serviceUserIdentity', + ChannelSid: 'CH123', + EventType: 'onMessageSent', + }; + + const memory = { + attribute1: 'attribute1', + attribute2: 'attribute2', + }; + + const { capturedChannelAttributes, ...channelAttributes } = JSON.parse( + mockedChannel.attributes, + ); + + mockedChannel = { + ...defaultChannel, + attributes: JSON.stringify({ + ...channelAttributes, + capturedChannelAttributes: { ...capturedChannelAttributes, releaseFlag: 'releaseFlag' }, + }), + }; + + const updateChannelSpy = jest.spyOn(mockedChannel, 'update'); + + jest.spyOn(lexClient, 'postText').mockImplementation( + async () => + ({ + dialogState: 'Fulfilled', + message: 'Some response from Lex', + slots: memory, + } as any), + ); + jest.spyOn(lexClient, 'deleteSession').mockImplementation(() => Promise.resolve() as any); + jest + .spyOn(channelCaptureHandlers, 'handleChannelRelease') + .mockImplementation(() => Promise.resolve()); + + await chatbotCallback(context as any, event, () => {}); + + expect(updateChannelSpy).toHaveBeenCalledWith({ + attributes: JSON.stringify({ + ...channelAttributes, + memory, + releaseFlag: true, + }), + }); + }); + + test('when memoryAttribute is set, channel attributes contain "[memoryAttribute]: memory"', async () => { + const event: Body = { + Body: 'Test body', + From: 'serviceUserIdentity', + ChannelSid: 'CH123', + EventType: 'onMessageSent', + }; + + const memory = { + attribute1: 'attribute1', + attribute2: 'attribute2', + }; + + const { capturedChannelAttributes, ...channelAttributes } = JSON.parse( + mockedChannel.attributes, + ); + + mockedChannel = { + ...defaultChannel, + attributes: JSON.stringify({ + ...channelAttributes, + capturedChannelAttributes: { + ...capturedChannelAttributes, + memoryAttribute: 'memoryAttribute', + }, + }), + }; + + const updateChannelSpy = jest.spyOn(mockedChannel, 'update'); + + jest.spyOn(lexClient, 'postText').mockImplementation( + async () => + ({ + dialogState: 'Fulfilled', + message: 'Some response from Lex', + slots: memory, + } as any), + ); + jest.spyOn(lexClient, 'deleteSession').mockImplementation(() => Promise.resolve() as any); + jest + .spyOn(channelCaptureHandlers, 'handleChannelRelease') + .mockImplementation(() => Promise.resolve()); + + await chatbotCallback(context as any, event, () => {}); + + expect(updateChannelSpy).toHaveBeenCalledWith({ + attributes: JSON.stringify({ + ...channelAttributes, + memoryAttribute: memory, + }), + }); + }); +}); From 5b01393d8d5d51c05a2c88a951d3fe34d45d58bb Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Mon, 24 Jul 2023 17:28:37 -0300 Subject: [PATCH 09/13] Removed old tests --- tests/webhooks/chatbotCallback.test.ts | 238 ------------------------- 1 file changed, 238 deletions(-) delete mode 100644 tests/webhooks/chatbotCallback.test.ts diff --git a/tests/webhooks/chatbotCallback.test.ts b/tests/webhooks/chatbotCallback.test.ts deleted file mode 100644 index 45494bf7..00000000 --- a/tests/webhooks/chatbotCallback.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Copyright (C) 2021-2023 Technology Matters - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -import { - handler as chatbotCallback, - Body, -} from '../../functions/webhooks/chatbotCallback.protected'; -import helpers from '../helpers'; -import { LexClient } from '../../functions/helpers/lexClient.private'; - -// eslint-disable-next-line global-require -const lexClient = require('../../functions/helpers/lexClient.private') as LexClient; - -jest.mock('../../functions/helpers/lexClient.private', () => ({ - postText: jest.fn(), - isEndOfDialog: jest.fn(), - deleteSession: jest.fn(), -})); - -const context = { - getTwilioClient: jest.fn().mockReturnValue({ - chat: { - services: jest.fn().mockReturnValue({ - channels: jest.fn().mockReturnValue({ - fetch: jest.fn().mockResolvedValue({ - attributes: JSON.stringify({ - channelSid: 'SID123xxx09sa', - message: 'Message sent', - fromServiceUser: 'channelAttributes', - studioFlowSid: 'FL0123xxdew', - botName: 'C6HUSTIFBR', - channelCapturedByBot: { - botName: 'C6HUSTIFBR', - botAlias: 'TSTALIASID', - studioFlowSid: 'FL0123xxdew', - localeId: 'en_US', - }, - }), - messages: jest.fn().mockReturnValue({ - create: jest.fn().mockReturnValue({ - body: 'lexResponse', - from: 'Bot', - xTwilioWebhookEnabled: 'true', - }), - }), - update: jest.fn().mockReturnValue({ - attributes: JSON.stringify({ - channelCapturedByBot: { - botName: 'C6HUSTIFBR', - botAlias: 'TSTALIASID', - localeId: 'en_US', - }, - }), - }), - webhooks: jest.fn().mockReturnValue({ - get: jest.fn().mockReturnValue({ - remove: jest.fn().mockReturnValue({}), - }), - create: jest.fn(), - }), - }), - messages: jest.fn().mockReturnValue({ - create: jest.fn().mockResolvedValue({}), - }), - }), - }), - }, - studio: { - v2: { - flows: jest.fn().mockReturnValue({ - executions: { - create: jest.fn().mockResolvedValue({}), - }, - }), - }, - }, - taskrouter: { - v1: { - workspaces: jest.fn().mockReturnValue({ - tasks: jest.fn().mockReturnValue({ - update: jest.fn().mockResolvedValue({}), - }), - }), - }, - }, - }), - - DOMAIN_NAME: 'string', - PATH: 'string', - SERVICE_SID: 'string', - ENVIRONMENT_SID: 'string', - CHAT_SERVICE_SID: 'Ws2xxxxxx', - ASELO_APP_ACCESS_KEY: 'AW12xx2', - ASELO_APP_SECRET_KEY: 'KA23xxx09i', - AWS_REGION: 'us-east-1', - TWILIO_WORKSPACE_SID: 'Waer3xxx98', -}; - -const mockCallback = jest.fn(); -const lexResponse = { - message: 'Lex response message', - dialogState: 'dialogState response state', - deleteSession: {}, -}; - -beforeAll(() => { - const runtime = new helpers.MockRuntime(context); - // eslint-disable-next-line no-underscore-dangle - runtime._addFunction('webhooks/chatbotCallback', 'functions/webhooks/chatbotCallback.protected'); - helpers.setup({}, runtime); -}); - -beforeEach(() => { - const functions = { - 'helpers/lexClient': { - path: '../../functions/helpers/lexClient.private.ts', - }, - }; - - const getFunctionsMock = jest.fn().mockReturnValue(functions); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - global.Runtime.getFunctions = () => getFunctionsMock(); - - lexClient.postText = jest.fn().mockResolvedValue(lexResponse); - lexClient.isEndOfDialog = jest.fn().mockResolvedValue(lexResponse); - lexClient.deleteSession = jest.fn().mockResolvedValue(lexResponse); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('chatbotCallback', () => { - test('should return lexResonse, update channel, and resolve with succes', async () => { - const event: Body = { - Body: 'Test body', - From: 'channelAttributes', - ChannelSid: 'Test channelSid', - EventType: 'onMessageSent', - }; - - await chatbotCallback(context, event, mockCallback); - - // Assert that the necessary functions were called with the correct arguments - expect(context.getTwilioClient).toHaveBeenCalled(); - expect(context.getTwilioClient().chat.services).toHaveBeenCalledWith(context.CHAT_SERVICE_SID); - expect(context.getTwilioClient().chat.services().channels).toHaveBeenCalledWith( - event.ChannelSid, - ); - expect(context.getTwilioClient().chat.services().channels().fetch).toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: 'All messages sent :)', - _statusCode: 200, - }), - ); - }); - - test('should handle the event and ignore it', async () => { - const event: Body = { - Body: 'Test body', - From: 'Test from', - ChannelSid: 'WA23xxx0ie', - EventType: 'onMessageSent', - }; - - await chatbotCallback(context, event, mockCallback); - - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: 'Event ignored', - _statusCode: 200, - }), - ); - }); - - test('should resolve with error message when event is empty', async () => { - const event = {}; - - await chatbotCallback(context, event, mockCallback); - - // Assert that the necessary functions were called with the correct arguments - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Error: Body parameter not provided', - status: 400, - }), - _statusCode: 400, - }), - ); - }); - - test('should handle errors', async () => { - const event: Body = { - Body: 'Test body', - From: 'Test from', - ChannelSid: 'Test channelSid', - EventType: 'onMessageSent', - }; - - const error = new Error('Test error'); - context.getTwilioClient().chat.services().channels().fetch.mockRejectedValue(error); - - await chatbotCallback(context, event, mockCallback); - - // Assert that the necessary functions were called with the correct arguments - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Test error', - }), - _statusCode: 500, - }), - ); - }); -}); From 249986367e0c8f7eaeecff54f934ff9f141433e1 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Mon, 24 Jul 2023 18:47:40 -0300 Subject: [PATCH 10/13] Fixed/Added janitorListener tests --- tests/captureChannelWithBot.test.ts | 236 ------------------ tests/channelCapture/chatbotCallback.test.ts | 1 - .../janitorListener.test.ts | 203 +++++++-------- 3 files changed, 88 insertions(+), 352 deletions(-) delete mode 100644 tests/captureChannelWithBot.test.ts diff --git a/tests/captureChannelWithBot.test.ts b/tests/captureChannelWithBot.test.ts deleted file mode 100644 index 15610376..00000000 --- a/tests/captureChannelWithBot.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Copyright (C) 2021-2023 Technology Matters - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ -import '@twilio-labs/serverless-runtime-types'; -import { - handler as captureChannelWithBot, - Body, -} from '../functions/captureChannelWithBot.protected'; -import helpers from './helpers'; -import { LexClient } from '../functions/helpers/lexClient.private'; - -// eslint-disable-next-line global-require -const lexClient = require('../functions/helpers/lexClient.private') as LexClient; - -jest.mock('../functions/helpers/lexClient.private', () => ({ - postText: jest.fn(), -})); - -const fetch = jest.fn().mockReturnValue({ - attributes: JSON.stringify({ - channelCapturedByBot: { - botId: 'C6HUSTIFBR', - botAliasId: 'TSTALIASID', - localeId: 'en_US', - }, - }), - webhooks: jest.fn().mockReturnValue({ - create: jest.fn().mockReturnValue({}), - }), - update: jest.fn().mockReturnValue({ - attributes: JSON.stringify({ - channelCapturedByBot: { - botName: 'C6HUSTIFBR', - botAlias: 'TSTALIASID', - localeId: 'en_US', - }, - }), - }), - messages: jest.fn().mockReturnValue({ - create: jest.fn().mockReturnValue({ - body: 'lexResponse', - from: 'Bot', - xTwilioWebhookEnabled: 'true', - }), - }), -}); - -const mockContext = { - getTwilioClient: jest.fn().mockImplementation(() => ({ - chat: { - v2: { - services: jest.fn().mockReturnValue({ - channels: jest.fn().mockReturnValue({ - fetch, - }), - }), - }, - services: jest.fn().mockReturnValue({ - channels: jest.fn().mockReturnValue({ - webhooks: { - list: jest.fn().mockReturnValue([]), - }, - }), - }), - }, - taskrouter: { - workspaces: jest.fn().mockReturnValue({ - tasks: { - create: jest.fn().mockReturnValue({}), - }, - }), - }, - })), - DOMAIN_NAME: 'domain.com', - PATH: 'string', - SERVICE_SID: 'string', - ENVIRONMENT_SID: 'string', - CHAT_SERVICE_SID: 'Ws2xxxxxx', - ASELO_APP_ACCESS_KEY: 'AW12xx2', - ASELO_APP_SECRET_KEY: 'KA23xxx09i', - AWS_REGION: 'us-east-1', - TWILIO_WORKSPACE_SID: 'WE23xxx0orre', - SURVEY_WORKFLOW_SID: 'AZexxx903esd', - HELPLINE_CODE: 'AS', - ENVIRONMENT: 'development', -}; - -const mockEvent: Body = { - channelSid: 'SID123xxx09sa', - message: 'Message sent', - fromServiceUser: 'Test User', - studioFlowSid: 'FL0123xxdew', - language: 'en_US', - type: 'pre_survey', -}; - -const mockCallback = jest.fn(); -const lexResponse = { message: 'Lex response message' }; - -beforeAll(() => { - const runtime = new helpers.MockRuntime(mockContext); - // eslint-disable-next-line no-underscore-dangle - runtime._addFunction('captureChannelWithBot', 'functions/captureChannelWithBot.protected'); - helpers.setup({}, runtime); -}); - -beforeEach(() => { - const functions = { - 'helpers/lexClient': { - path: '../functions/helpers/lexClient.private.ts', - }, - }; - - const getFunctionsMock = jest.fn().mockReturnValue(functions); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - global.Runtime.getFunctions = () => getFunctionsMock(); - - lexClient.postText = jest.fn().mockResolvedValue(lexResponse); -}); - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('captureChannelWithBot', () => { - test('should return lexResonse, update channel, and resolve with succes', async () => { - const event: Body = { - channelSid: 'SID123xxx09sa', - message: 'Message sent', - fromServiceUser: 'Test User', - studioFlowSid: 'FL0123xxdew', - language: 'en_US', - type: 'pre_survey', - }; - await captureChannelWithBot(mockContext, event, mockCallback); - - expect(mockCallback).toHaveBeenCalledWith( - null, - expect.objectContaining({ - _body: 'Channel captured by bot =)', - _statusCode: 200, - }), - ); - }); - // We need to ignore the typescript error since channelSid is required. - // Same apply to others - - test('should resolve with error message when channelSid is missing', async () => { - const event = { ...mockEvent, channelSid: undefined }; - - // @ts-ignore - await captureChannelWithBot(mockContext, event, mockCallback); - - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Error: channelSid parameter not provided', - status: 400, - }), - _statusCode: 400, - }), - ); - }); - - test('should resolve with error message when message is missing', async () => { - const event = { ...mockEvent, message: undefined }; - - // @ts-ignore - await captureChannelWithBot(mockContext, event, mockCallback); - - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Error: message parameter not provided', - status: 400, - }), - _statusCode: 400, - }), - ); - }); - - test('should resolve with error message when fromServiceUser is missing', async () => { - const event = { ...mockEvent, fromServiceUser: undefined }; - - // @ts-ignore - await captureChannelWithBot(mockContext, event, mockCallback); - - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Error: fromServiceUser parameter not provided', - status: 400, - }), - _statusCode: 400, - }), - ); - }); - - test('should resolve with error message when studioFlowSid is missing', async () => { - const event = { ...mockEvent, studioFlowSid: undefined }; - - // @ts-ignore - await captureChannelWithBot(mockContext, event, mockCallback); - - expect(lexClient.postText).not.toHaveBeenCalled(); - expect(mockCallback.mock.calls[0][0]).toBeNull(); - expect(mockCallback.mock.calls[0][1]).toEqual( - expect.objectContaining({ - _body: expect.objectContaining({ - message: 'Error: studioFlowSid parameter not provided', - status: 400, - }), - _statusCode: 400, - }), - ); - }); -}); diff --git a/tests/channelCapture/chatbotCallback.test.ts b/tests/channelCapture/chatbotCallback.test.ts index cb2b4c14..31306bbe 100644 --- a/tests/channelCapture/chatbotCallback.test.ts +++ b/tests/channelCapture/chatbotCallback.test.ts @@ -15,7 +15,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ import each from 'jest-each'; -import type { ChannelInstance } from 'twilio/lib/rest/chat/v2/service/channel'; import { handler as chatbotCallback, Body, diff --git a/tests/taskrouterListeners/janitorListener.test.ts b/tests/taskrouterListeners/janitorListener.test.ts index 1e308152..a8cc2374 100644 --- a/tests/taskrouterListeners/janitorListener.test.ts +++ b/tests/taskrouterListeners/janitorListener.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ /** * Copyright (C) 2021-2023 Technology Matters * This program is free software: you can redistribute it and/or modify @@ -17,7 +18,6 @@ import { EventFields, EventType, - TASK_CREATED, TASK_WRAPUP, TASK_CANCELED, TASK_DELETED, @@ -26,25 +26,22 @@ import { import { Context } from '@twilio-labs/serverless-runtime-types/types'; import { mock } from 'jest-mock-extended'; +import each from 'jest-each'; import * as janitorListener from '../../functions/taskrouterListeners/janitorListener.private'; +import { AseloCustomChannels } from '../../functions/helpers/customChannels/customChannelToFlex.private'; +import helpers from '../helpers'; -const functions = { - 'helpers/chatChannelJanitor': { - path: 'helpers/chatChannelJanitor', - }, - 'helpers/customChannels/customChannelToFlex': { - path: 'helpers/customChannels/customChannelToFlex', - }, -}; -global.Runtime.getFunctions = () => functions; +const mockChannelJanitor = jest.fn(); +jest.mock('../../functions/helpers/chatChannelJanitor.private', () => ({ + chatChannelJanitor: mockChannelJanitor, +})); -const postSurveyTaskAttributes = { - isSurveyTask: true, +const captureControlTaskAttributes = { + isChatCaptureControl: true, channelSid: 'channelSid', }; const nonPostSurveyTaskAttributes = { - isSurveyTask: false, channelSid: 'channelSid', }; @@ -69,127 +66,103 @@ const context = { FLEX_PROXY_SERVICE_SID: 'KCxxx', }; -const channelJanitorMock = jest.fn(); - -beforeEach(() => { - const channelJanitorModule = { - chatChannelJanitor: channelJanitorMock, - }; - jest.doMock('helpers/chatChannelJanitor', () => channelJanitorModule, { virtual: true }); - - jest.doMock( +beforeAll(() => { + const runtime = new helpers.MockRuntime(context); + runtime._addFunction( + 'helpers/chatChannelJanitor', + 'functions/helpers/chatChannelJanitor.private', + ); + runtime._addFunction( 'helpers/customChannels/customChannelToFlex', - () => ({ - isAseloCustomChannel: (channelType: string) => { - if (channelType === customChannelTaskAttributes.channelType) { - return true; - } - return false; - }, - }), - { - virtual: true, - }, + 'functions/helpers/customChannels/customChannelToFlex.private', + ); + runtime._addFunction( + 'channelCapture/channelCaptureHandlers', + 'functions/channelCapture/channelCaptureHandlers.private', ); + helpers.setup({}, runtime); +}); +afterAll(() => { + helpers.teardown(); }); afterEach(() => { jest.clearAllMocks(); }); -describe('Post-survey cleanup', () => { - test('task wrapup', async () => { - const event = { - ...mock(), - EventType: TASK_WRAPUP as EventType, - TaskAttributes: JSON.stringify(postSurveyTaskAttributes), - }; - await janitorListener.handleEvent(context, event); +describe('isCleanupBotCapture', () => { + each(['web', ...Object.values(AseloCustomChannels)]).test( + 'capture control task canceled with channelType $channelType, should trigger janitor', + async ({ channelType }) => { + const event = { + ...mock(), + EventType: TASK_CANCELED as EventType, + TaskAttributes: JSON.stringify({ ...captureControlTaskAttributes, channelType }), + }; + await janitorListener.handleEvent(context, event); + + const { channelSid } = captureControlTaskAttributes; + expect(mockChannelJanitor).toHaveBeenCalledWith(context, { channelSid }); + }, + ); - const { channelSid } = postSurveyTaskAttributes; - expect(channelJanitorMock).toHaveBeenCalledWith(context, { channelSid }); - }); + each([TASK_WRAPUP, TASK_DELETED, TASK_SYSTEM_DELETED].map((eventType) => ({ eventType }))).test( + 'not task canceled ($eventType), shouldnt trigger janitor', + async ({ eventType }) => { + const event = { + ...mock(), + EventType: eventType, + TaskAttributes: JSON.stringify(captureControlTaskAttributes), + }; + await janitorListener.handleEvent(context, event); + + expect(mockChannelJanitor).not.toHaveBeenCalled(); + }, + ); - test('task canceled', async () => { + test('non isCleanupBotCapture task cancel, shouldnt trigger janitor', async () => { const event = { ...mock(), EventType: TASK_CANCELED as EventType, - TaskAttributes: JSON.stringify(postSurveyTaskAttributes), - }; - await janitorListener.handleEvent(context, event); - - const { channelSid } = postSurveyTaskAttributes; - expect(channelJanitorMock).toHaveBeenCalledWith(context, { channelSid }); - }); - - test('not task wrapup/created', async () => { - const event = { - ...mock(), - EventType: TASK_CREATED as EventType, - TaskAttributes: JSON.stringify(postSurveyTaskAttributes), - }; - await janitorListener.handleEvent(context, event); - - expect(channelJanitorMock).not.toHaveBeenCalled(); - }); - - test('non post-survey task wrapup', async () => { - const event = { - ...mock(), - EventType: TASK_WRAPUP as EventType, TaskAttributes: JSON.stringify(nonPostSurveyTaskAttributes), }; await janitorListener.handleEvent(context, event); - expect(channelJanitorMock).not.toHaveBeenCalled(); + expect(mockChannelJanitor).not.toHaveBeenCalled(); }); }); -describe('Custom channel cleanup', () => { - test('task deleted', async () => { - const event = { - ...mock(), - EventType: TASK_DELETED as EventType, - TaskAttributes: JSON.stringify(customChannelTaskAttributes), - }; - await janitorListener.handleEvent(context, event); - - const { channelSid } = customChannelTaskAttributes; - expect(channelJanitorMock).toHaveBeenCalledWith(context, { channelSid }); - }); - - test('task system deleted', async () => { - const event = { - ...mock(), - EventType: TASK_SYSTEM_DELETED as EventType, - TaskAttributes: JSON.stringify(customChannelTaskAttributes), - }; - await janitorListener.handleEvent(context, event); - - const { channelSid } = customChannelTaskAttributes; - expect(channelJanitorMock).toHaveBeenCalledWith(context, { channelSid }); - }); - - test('task system deleted', async () => { - const event = { - ...mock(), - EventType: TASK_CANCELED as EventType, - TaskAttributes: JSON.stringify(customChannelTaskAttributes), - }; - await janitorListener.handleEvent(context, event); - - const { channelSid } = customChannelTaskAttributes; - expect(channelJanitorMock).toHaveBeenCalledWith(context, { channelSid }); - }); - - test('non custom channel task deleted', async () => { - const event = { - ...mock(), - EventType: TASK_DELETED as EventType, - TaskAttributes: JSON.stringify(nonCustomChannelTaskAttributes), - }; - await janitorListener.handleEvent(context, event); +describe('isCleanupCustomChannel', () => { + each( + [TASK_DELETED, TASK_SYSTEM_DELETED, TASK_CANCELED].flatMap((eventType) => + Object.values(AseloCustomChannels).map((channelType) => ({ channelType, eventType })), + ), + ).test( + 'eventType $eventType with channelType $channelType, should trigger janitor', + async ({ channelType, eventType }) => { + const event = { + ...mock(), + EventType: eventType as EventType, + TaskAttributes: JSON.stringify({ ...customChannelTaskAttributes, channelType }), + }; + await janitorListener.handleEvent(context, event); + + const { channelSid } = customChannelTaskAttributes; + expect(mockChannelJanitor).toHaveBeenCalledWith(context, { channelSid }); + }, + ); - expect(channelJanitorMock).not.toHaveBeenCalled(); - }); + each([TASK_DELETED, TASK_SYSTEM_DELETED, TASK_CANCELED].map((eventType) => ({ eventType }))).test( + 'eventType $eventType with non custom channel, should not trigger janitor', + async ({ eventType }) => { + const event = { + ...mock(), + EventType: eventType as EventType, + TaskAttributes: JSON.stringify(nonCustomChannelTaskAttributes), + }; + await janitorListener.handleEvent(context, event); + + expect(mockChannelJanitor).not.toHaveBeenCalled(); + }, + ); }); From e82e569a086dd278e64ef9917b7dbe5f623c91dd Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 25 Jul 2023 16:41:20 -0300 Subject: [PATCH 11/13] Fixed post survey listener tests --- .../postSurveyListener.private.ts | 20 ++++-- .../postSurveyListener.test.ts | 61 +++++++++++-------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/functions/taskrouterListeners/postSurveyListener.private.ts b/functions/taskrouterListeners/postSurveyListener.private.ts index 29d8b00e..75b2de3d 100644 --- a/functions/taskrouterListeners/postSurveyListener.private.ts +++ b/functions/taskrouterListeners/postSurveyListener.private.ts @@ -29,6 +29,7 @@ import type { TransferMeta } from './transfersListener.private'; import type { PostSurveyInitHandler } from '../postSurveyInit'; import type { AWSCredentials } from '../helpers/lexClient.private'; import type { ChannelCaptureHandlers } from '../channelCapture/channelCaptureHandlers.private'; +import type { ChannelToFlex } from '../helpers/customChannels/customChannelToFlex.private'; export const eventTypes: EventType[] = [TASK_WRAPUP]; @@ -104,16 +105,23 @@ export const handleEvent = async (context: Context, event: EventFields) /** ==================== */ // TODO: Once all accounts are ready to manage triggering post survey on task wrap within taskRouterCallback, the check on post_survey_serverless_handled can be removed if (featureFlags.enable_post_survey && featureFlags.post_survey_serverless_handled) { - const { channelSid } = taskAttributes; + const channelToFlex = require(Runtime.getFunctions()[ + 'helpers/customChannels/customChannelToFlex' + ].path) as ChannelToFlex; - const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes); + // TODO: Remove this once all accounts are migrated to Lex + // Only trigger post survey if handled by Lex or if is not a custom channel + if (featureFlags.enable_lex || !channelToFlex.isAseloCustomChannel(taskAttributes)) { + const { channelSid } = taskAttributes; - const handlerPath = Runtime.getFunctions().postSurveyInit.path; - const postSurveyInitHandler = require(handlerPath) - .postSurveyInitHandler as PostSurveyInitHandler; + const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes); - await postSurveyInitHandler(context, { channelSid, taskSid, taskLanguage }); + const handlerPath = Runtime.getFunctions().postSurveyInit.path; + const postSurveyInitHandler = require(handlerPath) + .postSurveyInitHandler as PostSurveyInitHandler; + await postSurveyInitHandler(context, { channelSid, taskSid, taskLanguage }); + } console.log('Finished handling post survey trigger.'); } } diff --git a/tests/taskrouterListeners/postSurveyListener.test.ts b/tests/taskrouterListeners/postSurveyListener.test.ts index a53f9d1d..d9a843b4 100644 --- a/tests/taskrouterListeners/postSurveyListener.test.ts +++ b/tests/taskrouterListeners/postSurveyListener.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ /** * Copyright (C) 2021-2023 Technology Matters * This program is free software: you can redistribute it and/or modify @@ -32,21 +33,12 @@ import each from 'jest-each'; import * as postSurveyListener from '../../functions/taskrouterListeners/postSurveyListener.private'; import * as postSurveyInit from '../../functions/postSurveyInit'; import { AseloCustomChannels } from '../../functions/helpers/customChannels/customChannelToFlex.private'; - -const functions = { - postSurveyInit: { - path: '../postSurveyInit', - }, - 'helpers/customChannels/customChannelToFlex': { - path: '../helpers/customChannels/customChannelToFlex.private.ts', - }, -}; -global.Runtime.getFunctions = () => functions; +import helpers from '../helpers'; jest.mock('../../functions/postSurveyInit'); -// const mockFeatureFlags = {}; -// const mockFetchConfig = jest.fn(() => ({ attributes: { feature_flags: mockFeatureFlags } })); +const defaultFeatureFlags = { enable_lex: false }; // Just give compatibility for legacy tests for now. TODO: add tests for the new schema + const mockFetchConfig = jest.fn(); const context = { ...mock>(), @@ -59,9 +51,39 @@ const context = { }, }, }), + flexApi: { + configuration: () => ({ + get: () => ({ + fetch: () => ({ + serviceConfig: { + attributes: { + feature_flags: defaultFeatureFlags, + }, + }, + }), + }), + }), + }, TWILIO_WORKSPACE_SID: 'WSxxx', }; +beforeAll(() => { + const runtime = new helpers.MockRuntime(context); + runtime._addFunction('postSurveyInit', 'functions/postSurveyInit'); + runtime._addFunction( + 'helpers/customChannels/customChannelToFlex', + 'functions/helpers/customChannels/customChannelToFlex.private', + ); + runtime._addFunction( + 'channelCapture/channelCaptureHandlers', + 'functions/channelCapture/channelCaptureHandlers.private', + ); + helpers.setup({}, runtime); +}); +afterAll(() => { + helpers.teardown(); +}); + afterEach(() => { jest.clearAllMocks(); }); @@ -132,19 +154,17 @@ describe('Post survey init', () => { })), { task: nonTrasferred, - isCandidate: true, featureFlags: { enable_post_survey: true, post_survey_serverless_handled: false }, rejectReason: 'is candidate but post_survey_serverless_handled === false', }, { task: nonTrasferred, - isCandidate: true, featureFlags: { enable_post_survey: false, post_survey_serverless_handled: true }, rejectReason: 'is candidate but enable_post_survey === false', }, ]).test( 'Task should not trigger post survey because $rejectReason', - async ({ task, featureFlags, isCandidate }) => { + async ({ task, featureFlags }) => { const event = { ...mock(), EventType: TASK_WRAPUP as EventType, @@ -154,19 +174,12 @@ describe('Post survey init', () => { }; mockFetchConfig.mockReturnValue({ - attributes: { feature_flags: featureFlags || {} }, + attributes: { feature_flags: { ...defaultFeatureFlags, ...(featureFlags || {}) } }, }); const postSurveyInitHandlerSpy = jest.spyOn(postSurveyInit, 'postSurveyInitHandler'); await postSurveyListener.handleEvent(context, event); - - // If isCandidate, it will reach service config checks - if (isCandidate) { - expect(mockFetchConfig).toHaveBeenCalled(); - } else { - expect(mockFetchConfig).not.toHaveBeenCalled(); - } expect(postSurveyInitHandlerSpy).not.toHaveBeenCalled(); }, ); @@ -204,7 +217,7 @@ describe('Post survey init', () => { const postSurveyInitHandlerSpy = jest .spyOn(postSurveyInit, 'postSurveyInitHandler') - .mockImplementationOnce(async () => {}); + .mockImplementationOnce(async () => ({} as any)); await postSurveyListener.handleEvent(context, event); From 5bcc246c674dfd021fc6d9076a71c5a4984c32a9 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 25 Jul 2023 17:24:40 -0300 Subject: [PATCH 12/13] PR feedback --- .../captureChannelWithBot.protected.ts | 17 ++--------------- .../channelCaptureHandlers.private.ts | 4 ++-- functions/postSurveyInit.ts | 3 ++- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/functions/channelCapture/captureChannelWithBot.protected.ts b/functions/channelCapture/captureChannelWithBot.protected.ts index 92836353..0eaa3c90 100644 --- a/functions/channelCapture/captureChannelWithBot.protected.ts +++ b/functions/channelCapture/captureChannelWithBot.protected.ts @@ -28,8 +28,7 @@ import { import type { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; import type { ChannelCaptureHandlers, - ReleaseTypes, - TriggerTypes, + HandleChannelCaptureParams, } from './channelCaptureHandlers.private'; import type { AWSCredentials } from '../helpers/lexClient.private'; @@ -42,19 +41,7 @@ type EnvVars = { HRM_STATIC_KEY: string; } & AWSCredentials; -export type Body = { - channelSid: string; // (in Studio Flow, flow.channel.address) The channel to capture - message: string; // (in Studio Flow, trigger.message.Body) The triggering message - language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} ) - botSuffix: string; // (hardcoded in Studio Flow) - triggerType: TriggerTypes; - releaseType: ReleaseTypes; - studioFlowSid?: string; // (in Studio Flow, flow.flow_sid) The Studio Flow sid. Needed to trigger an API type execution once the channel is released. - memoryAttribute?: string; // where in the task attributes we want to save the bot's memory (allows compatibility for multiple bots) - releaseFlag?: string; // the flag we want to set true when the channel is released - additionControlTaskAttributes?: string; // optional attributes to include in the control task, in the string representation of a JSON - controlTaskTTL?: number; -}; +export type Body = Partial; export const handler = async ( context: Context, diff --git a/functions/channelCapture/channelCaptureHandlers.private.ts b/functions/channelCapture/channelCaptureHandlers.private.ts index 3c6de662..b159ea68 100644 --- a/functions/channelCapture/channelCaptureHandlers.private.ts +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -236,7 +236,7 @@ const triggerWithNextMessage = async ( }); }; -type HandleChannelCaptureParams = { +export type HandleChannelCaptureParams = { channelSid: string; // The channel to capture (in Studio Flow, flow.channel.address) message: string; // The triggering message (in Studio Flow, trigger.message.Body) language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} ) @@ -290,7 +290,7 @@ const validateHandleChannelCaptureParams = (params: Partial, - params: HandleChannelCaptureParams, + params: Partial, ) => { const { channelSid, diff --git a/functions/postSurveyInit.ts b/functions/postSurveyInit.ts index da153e75..bd211980 100644 --- a/functions/postSurveyInit.ts +++ b/functions/postSurveyInit.ts @@ -189,11 +189,12 @@ export const handler = TokenValidator( if (channelSid === undefined) return resolve(error400('channelSid')); if (taskSid === undefined) return resolve(error400('taskSid')); + if (taskLanguage === undefined) return resolve(error400('taskLanguage')); const result = await postSurveyInitHandler(context, { channelSid, taskSid, - taskLanguage: taskLanguage || 'en-US', + taskLanguage, }); if (result.status === 'failure' && result.validationResult.status === 'invalid') { From a34fba5f817a3c5ad0e84f809f8d4a967d93b6b2 Mon Sep 17 00:00:00 2001 From: Gianfranco Paoloni Date: Tue, 25 Jul 2023 17:31:42 -0300 Subject: [PATCH 13/13] Fixed TS error --- .../channelCapture/channelCaptureHandlers.private.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/functions/channelCapture/channelCaptureHandlers.private.ts b/functions/channelCapture/channelCaptureHandlers.private.ts index b159ea68..2a9a3ae9 100644 --- a/functions/channelCapture/channelCaptureHandlers.private.ts +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -292,6 +292,11 @@ export const handleChannelCapture = async ( context: Context, params: Partial, ) => { + const validationResult = validateHandleChannelCaptureParams(params); + if (validationResult.status === 'invalid') { + return { status: 'failure', validationResult } as const; + } + const { channelSid, message, @@ -304,11 +309,7 @@ export const handleChannelCapture = async ( releaseFlag, additionControlTaskAttributes, controlTaskTTL, - } = params; - const validationResult = validateHandleChannelCaptureParams(params); - if (validationResult.status === 'invalid') { - return { status: 'failure', validationResult } as const; - } + } = params as HandleChannelCaptureParams; const parsedAdditionalControlTaskAttributes = additionControlTaskAttributes ? JSON.parse(additionControlTaskAttributes)