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..0eaa3c90 --- /dev/null +++ b/functions/channelCapture/captureChannelWithBot.protected.ts @@ -0,0 +1,99 @@ +/* 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, + HandleChannelCaptureParams, +} 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 = Partial; + +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..2a9a3ae9 --- /dev/null +++ b/functions/channelCapture/channelCaptureHandlers.private.ts @@ -0,0 +1,532 @@ +/* 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 { 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 { + 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 = ['withUserMessage', 'withNextMessage'] 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 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, +) => { + const { + userId, + botName, + botAlias, + controlTaskSid, + chatbotCallbackWebhookSid, + releaseType, + studioFlowSid, + 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, + 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 triggerWithUserMessage = 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 triggerWithNextMessage = 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}/channelCapture/chatbotCallback`, + }, + }); + + // const updated = + await updateChannelWithCapture(channel, { + userId, + botName, + botAlias, + controlTaskSid, + releaseType, + studioFlowSid, + releaseFlag, + memoryAttribute, + chatbotCallbackWebhookSid: chatbotCallbackWebhook.sid, + }); +}; + +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'}} ) + 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: Partial, +) => { + const validationResult = validateHandleChannelCaptureParams(params); + if (validationResult.status === 'invalid') { + return { status: 'failure', validationResult } as const; + } + + const { + channelSid, + message, + language, + botSuffix, + triggerType, + releaseType, + studioFlowSid, + memoryAttribute, + releaseFlag, + additionControlTaskAttributes, + controlTaskTTL, + } = params as HandleChannelCaptureParams; + + const parsedAdditionalControlTaskAttributes = additionControlTaskAttributes + ? JSON.parse(additionControlTaskAttributes) + : {}; + + const channel = await context + .getTwilioClient() + .chat.v2.services(context.CHAT_SERVICE_SID) + .channels(channelSid) + .fetch(); + + 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}`; + + 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 === 'withUserMessage') { + await triggerWithUserMessage(context, channel, options); + } + + if (triggerType === 'withNextMessage') { + await triggerWithNextMessage(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 67% rename from functions/webhooks/chatbotCallback.protected.ts rename to functions/channelCapture/chatbotCallback.protected.ts index 6b70c7d5..e59ee367 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 & {}; @@ -77,63 +85,57 @@ 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) { - const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; - const lexClient = require(handlerPath) as LexClient; + if (EventType === 'onMessageSent' && channelAttributes.serviceUserIdentity === From) { + 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'); 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, { 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..bd211980 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,47 @@ 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: 'withNextMessage', + releaseType: 'postSurveyComplete', + 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; @@ -151,8 +189,19 @@ export const handler = TokenValidator( if (channelSid === undefined) return resolve(error400('channelSid')); if (taskSid === undefined) return resolve(error400('taskSid')); - - await postSurveyInitHandler(context, { channelSid, taskSid, taskLanguage }); + if (taskLanguage === undefined) return resolve(error400('taskLanguage')); + + const result = await postSurveyInitHandler(context, { + channelSid, + taskSid, + taskLanguage, + }); + + 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) { diff --git a/functions/taskrouterListeners/janitorListener.private.ts b/functions/taskrouterListeners/janitorListener.private.ts index ae586505..60f84a9a 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' + ].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' + ].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.'); diff --git a/functions/taskrouterListeners/postSurveyListener.private.ts b/functions/taskrouterListeners/postSurveyListener.private.ts index 600d56b4..75b2de3d 100644 --- a/functions/taskrouterListeners/postSurveyListener.private.ts +++ b/functions/taskrouterListeners/postSurveyListener.private.ts @@ -25,17 +25,22 @@ 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'; +import type { ChannelToFlex } from '../helpers/customChannels/customChannelToFlex.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 +53,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; }; @@ -94,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/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 new file mode 100644 index 00000000..31306bbe --- /dev/null +++ b/tests/channelCapture/chatbotCallback.test.ts @@ -0,0 +1,365 @@ +/* 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 { + 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, + }), + }); + }); +}); 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(); + }, + ); }); 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); 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, - }), - ); - }); -});