diff --git a/.github/actions/main-action/action.yml b/.github/actions/main-action/action.yml index 7e649bc1..b5721ea7 100644 --- a/.github/actions/main-action/action.yml +++ b/.github/actions/main-action/action.yml @@ -67,8 +67,14 @@ inputs: s3-bucket: description: 'S3 bucket name where Aselo documents are stored' required: true - helpline-name: - description: 'The identifier in the format "-" used for this helpline' + helpline-code: + description: 'The short (usually 2 character) upper case code used to identify the helpline internally, e.g. ZA, IN, BR.' + required: true + environment-code: + description: 'The short upper case code used to identify the environment internally, e.g. STG, PROD, DEV' + required: true + environment: + description: The environment to deploy to, e.g. staging, production, development (Yes, this is a duplicate of the above, but it's needed for the workflow to run... for now) required: true send-slack-message: description: 'Specifies if should send a Slack message at the end of successful run. Defaults to true' @@ -81,6 +87,9 @@ inputs: runs: using: 'composite' steps: + - name: Set helpline-name + run: echo "helpline-name=${{ inputs.helpline-code }}_${{ inputs.environment-code }}" >> $GITHUB_ENV + shell: bash # Set any env vars needed from Parameter Store here # Slack env - name: Set GITHUB_ACTIONS_SLACK_BOT_TOKEN @@ -100,6 +109,8 @@ runs: - name: Fill .env run: | cat <> .env + HELPLINE_CODE=${{ inputs.helpline-code }} + ENVIRONMENT=${{ inputs.environment }} TWILIO_WORKSPACE_SID=${{ inputs.workspace-sid }} TWILIO_CHAT_TRANSFER_WORKFLOW_SID=${{ inputs.transfer-workflow-sid }} SYNC_SERVICE_API_KEY=${{ inputs.sync-service-api-key }} @@ -131,7 +142,7 @@ runs: - name: Execute custom action (if any) uses: ./.github/actions/custom-actions with: - helpline-name: ${{ inputs.helpline-name }} + helpline-name: ${{ env.helpline-name }} account-sid: ${{ inputs.account-sid }} # Install dependencies for the twilio functions - name: Install dependencies for the twilio functions @@ -170,6 +181,6 @@ runs: if: ${{ inputs.send-slack-message != 'false' }} with: channel-id: ${{ env.ASELO_DEPLOYS_CHANNEL_ID }} - slack-message: "`[Serverless]` Deployment to `${{ inputs.helpline-name }}` from ${{ github.ref_type }} `${{ github.ref_name }}` requested by `${{ github.triggering_actor }}` completed using workflow '${{ github.workflow }}' with SHA ${{ github.sha }} :rocket:." + slack-message: "`[Serverless]` Deployment to `${{ env.helpline-name }}` from ${{ github.ref_type }} `${{ github.ref_name }}` requested by `${{ github.triggering_actor }}` completed using workflow '${{ github.workflow }}' with SHA ${{ github.sha }} :rocket:." env: SLACK_BOT_TOKEN: ${{ env.GITHUB_ACTIONS_SLACK_BOT_TOKEN }} diff --git a/.github/workflows/custom_helpline.yml b/.github/workflows/custom_helpline.yml index e15a1ae2..1809b063 100644 --- a/.github/workflows/custom_helpline.yml +++ b/.github/workflows/custom_helpline.yml @@ -191,7 +191,9 @@ jobs: aselo-app-secret-key: $ASELO_APP_SECRET_KEY aws-region: $HELPLINE_AWS_REGION s3-bucket: $S3_BUCKET - helpline-name: ${{inputs.helpline_code}}_${{inputs.environment_code}} + helpline-code: ${{inputs.helpline_code}} + environment-code: ${{inputs.environment_code}} + environment: ${{inputs.environment}} # Set 'false' if the target environment is production OR the force_enable_operating_hours override option is checked - otherwise 'true' disable-operating-hours: ${{ (inputs.force_enable_operating_hours || inputs.environment_code == 'PROD') && 'false' || 'true' }} send-slack-message: ${{ inputs.send-slack-message }} diff --git a/README.md b/README.md index 41698931..bcfeb5e1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ For help on twilio-run commands run: | `S3_ENDPOINT` | _local transcripts only_ http://localhost:4566 | | `ASELO_APP_ACCESS_KEY` | AWS_ACCESS_KEY_ID with access to s3 bucket (can be any string for localstack) | | `ASELO_APP_SECRET_KEY` | AWS_SECRET_ACCESS_KEY for ASELO_APP_ACCESS_KEY (can be any string for localstack | +| `HELPLINE_CODE` | The short (usually 2 character) upper case code used to identify the helpline internally, e.g. ZA, IN, BR. | +| `ENVIRONMENT_CODE` | The short upper case code used to identify the environment internally, e.g. STG, PROD, DEV | ## Deployment diff --git a/functions/channelCapture/captureChannelWithBot.protected.ts b/functions/channelCapture/captureChannelWithBot.protected.ts new file mode 100644 index 00000000..d2e0581e --- /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 './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..19a1599b --- /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 './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()['channelCapture/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/channelCapture/chatbotCallback.protected.ts b/functions/channelCapture/chatbotCallback.protected.ts new file mode 100644 index 00000000..ea038392 --- /dev/null +++ b/functions/channelCapture/chatbotCallback.protected.ts @@ -0,0 +1,160 @@ +/** + * 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 { omit } from 'lodash'; +import type { WebhookEvent } from '../helpers/customChannels/flexToCustomChannel.private'; +import type { AWSCredentials, LexClient } from './lexClient.private'; +import type { + CapturedChannelAttributes, + ChannelCaptureHandlers, +} from './channelCaptureHandlers.private'; + +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 & {}; + +export const handler = async ( + context: Context, + event: Body, + callback: ServerlessCallback, +) => { + console.log('===== chatbotCallback handler ====='); + + const response = responseWithCors(); + const resolve = bindResolve(callback)(response); + + try { + const { Body, From, ChannelSid, EventType } = event; + if (!Body) { + resolve(error400('Body')); + return; + } + if (!From) { + resolve(error400('From')); + return; + } + if (!ChannelSid) { + resolve(error400('ChannelSid')); + return; + } + if (!EventType) { + resolve(error400('EventType')); + return; + } + + const client = context.getTwilioClient(); + const channel = await client.chat + .services(context.CHAT_SERVICE_SID) + .channels(ChannelSid) + .fetch(); + + const channelAttributes = JSON.parse(channel.attributes); + + // Send message to bot only if it's from child + if (EventType === 'onMessageSent' && channelAttributes.serviceUserIdentity === From) { + const lexClient = require(Runtime.getFunctions()['channelCapture/lexClient'] + .path) as LexClient; + + const capturedChannelAttributes = + channelAttributes.capturedChannelAttributes as CapturedChannelAttributes; + + const lexResponse = await lexClient.postText(context, { + botName: capturedChannelAttributes.botName, + botAlias: capturedChannelAttributes.botAlias, + userId: capturedChannelAttributes.userId, + inputText: Body, + }); + + // 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, ['capturedChannelAttributes']), + ...(capturedChannelAttributes.memoryAttribute + ? { [capturedChannelAttributes.memoryAttribute]: memory } + : { memory }), + ...(capturedChannelAttributes.releaseFlag && { + [capturedChannelAttributes.releaseFlag]: true, + }), + }; + + 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: capturedChannelAttributes.botName, + botAlias: capturedChannelAttributes.botAlias, + userId: channel.sid, + }), + // Update channel attributes (remove channelCapturedByBot and add memory) + channel.update({ + attributes: JSON.stringify(releasedChannelAttributes), + }), + // Remove this webhook from the channel + channel.webhooks().get(capturedChannelAttributes.chatbotCallbackWebhookSid).remove(), + // Trigger the next step once the channel is released + channelCaptureHandlers.handleChannelRelease( + context, + channel, + capturedChannelAttributes, + memory, + ), + ]); + + console.log('Channel unblocked and bot session deleted'); + } + + await channel.messages().create({ + body: lexResponse.message, + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }); + + resolve(success('All messages sent :)')); + return; + } + + resolve(success('Event ignored')); + } catch (err) { + if (err instanceof Error) resolve(error500(err)); + else resolve(error500(new Error(String(err)))); + } +}; diff --git a/functions/channelCapture/lexClient.private.ts b/functions/channelCapture/lexClient.private.ts new file mode 100644 index 00000000..a9470575 --- /dev/null +++ b/functions/channelCapture/lexClient.private.ts @@ -0,0 +1,94 @@ +/** + * 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 AWS from 'aws-sdk'; + +export type AWSCredentials = { + ASELO_APP_ACCESS_KEY: string; + ASELO_APP_SECRET_KEY: string; + AWS_REGION: string; +}; + +export type BotType = 'pre_survey' | 'post_survey'; + +export type LexMemory = { [q: string]: string | number }; + +export const postText = async ( + credentials: AWSCredentials, + { + botName, + botAlias, + inputText, + userId, + }: { + botName: string; + botAlias: string; + inputText: string; + userId: string; + }, +) => { + const { ASELO_APP_ACCESS_KEY, ASELO_APP_SECRET_KEY, AWS_REGION } = credentials; + AWS.config.update({ + credentials: { + accessKeyId: ASELO_APP_ACCESS_KEY, + secretAccessKey: ASELO_APP_SECRET_KEY, + }, + region: AWS_REGION, + }); + + const Lex = new AWS.LexRuntime(); + + const lexResponse = await Lex.postText({ botName, botAlias, inputText, userId }).promise(); + + return lexResponse; +}; + +export const isEndOfDialog = (dialogState: string | undefined) => + dialogState === 'Fulfilled' || dialogState === 'Failed'; + +export const deleteSession = ( + credentials: AWSCredentials, + { + botName, + botAlias, + userId, + }: { + botName: string; + botAlias: string; + userId: string; + }, +) => { + AWS.config.update({ + credentials: { + accessKeyId: credentials.ASELO_APP_ACCESS_KEY, + secretAccessKey: credentials.ASELO_APP_SECRET_KEY, + }, + region: credentials.AWS_REGION, + }); + + const Lex = new AWS.LexRuntime(); + + return Lex.deleteSession({ + botName, + botAlias, + userId, + }).promise(); +}; + +export type LexClient = { + postText: typeof postText; + isEndOfDialog: typeof isEndOfDialog; + deleteSession: typeof deleteSession; +}; 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/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..06cc555e 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 './channelCapture/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 4055c4a6..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, @@ -44,10 +45,24 @@ type EnvVars = { FLEX_PROXY_SERVICE_SID: string; }; -const isCleanupPostSurvey = (eventType: EventType, taskAttributes: { isSurveyTask?: boolean }) => - (eventType === TASK_CANCELED || eventType === TASK_WRAPUP) && taskAttributes.isSurveyTask; +// This applies to both pre-survey(isChatCaptureControl) and post-survey +const isCleanupBotCapture = ( + eventType: EventType, + taskAttributes: { isChatCaptureControl?: boolean }, +) => { + 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 || @@ -58,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); }; @@ -83,23 +106,23 @@ export const handleEvent = async (context: Context, event: EventFields) const taskAttributes = JSON.parse(taskAttributesString); - if (isCleanupPostSurvey(eventType, taskAttributes)) { - console.log('Handling clean up post-survey...'); + if (isCleanupBotCapture(eventType, taskAttributes)) { 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 post-survey.'); + console.log('Finished handling clean up.'); + return; } 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 e97f1b6d..c97541e8 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 '../channelCapture/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; }; @@ -91,17 +102,26 @@ export const handleEvent = async (context: Context, event: EventFields) const serviceConfig = await client.flexApi.configuration.get().fetch(); const { feature_flags: featureFlags, helplineLanguage } = serviceConfig.attributes; + /** ==================== */ + // 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) { - 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/channelCapture/chatbotCallback.test.ts b/tests/channelCapture/chatbotCallback.test.ts new file mode 100644 index 00000000..6387cb58 --- /dev/null +++ b/tests/channelCapture/chatbotCallback.test.ts @@ -0,0 +1,362 @@ +/* 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/channelCapture/lexClient.private'; +import * as channelCaptureHandlers from '../../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('channelCapture/lexClient', 'functions/channelCapture/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 b9c258bf..be1ea2fc 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(); }); @@ -144,7 +166,7 @@ describe('Post survey init', () => { }, ]).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 +176,13 @@ 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(); - } if (featureFlags && featureFlags.enable_post_survey) { expect(postSurveyInitHandlerSpy).toHaveBeenCalled(); } else { @@ -208,7 +224,7 @@ describe('Post survey init', () => { const postSurveyInitHandlerSpy = jest .spyOn(postSurveyInit, 'postSurveyInitHandler') - .mockImplementationOnce(async () => {}); + .mockImplementationOnce(async () => ({} as any)); await postSurveyListener.handleEvent(context, event);