diff --git a/aselo-webchat-react-app/public/index.html b/aselo-webchat-react-app/public/index.html index d66af14d98..600fda8932 100644 --- a/aselo-webchat-react-app/public/index.html +++ b/aselo-webchat-react-app/public/index.html @@ -27,7 +27,6 @@ --> Twilio Webchat React App - diff --git a/lambdas/account-scoped/src/albResponses.ts b/lambdas/account-scoped/src/albResponses.ts index 2880a3b7a6..f13f586f83 100644 --- a/lambdas/account-scoped/src/albResponses.ts +++ b/lambdas/account-scoped/src/albResponses.ts @@ -34,6 +34,15 @@ export const okJsonResponse = (body: any = {}): ALBResult => ({ body: JSON.stringify(body), }); +export const okXmlResponse = (body: string = ''): ALBResult => ({ + headers: { + ...CORS_HEADERS, + 'Content-Type': 'application/xml', + }, + statusCode: 200, + body, +}); + export const notFoundResponse = (event: ALBEvent): ALBResult => ({ statusCode: 404, headers: { diff --git a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts index 24cd8a8acd..c03b7b919e 100644 --- a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts +++ b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts @@ -18,15 +18,11 @@ import { ConversationInstance } from 'twilio/lib/rest/conversations/v1/conversat import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; import type { MemberInstance } from 'twilio/lib/rest/ipMessaging/v2/service/channel/member'; import { LexClient, LexMemory } from './lexClient'; -import { PostSurveyData, buildDataObject } from './hrmDataManipulation'; -import { buildSurveyInsightsData } from './insightsService'; import { isErr, newErr, newOk, Result } from '../Result'; import { Twilio } from 'twilio'; -import { postToInternalHrmEndpoint } from '../hrm/internalHrmRequest'; import { ROUTE_PREFIX } from '../router'; import { AccountSID } from '@tech-matters/twilio-types'; -import { getCurrentDefinitionVersion } from '../hrm/formDefinitionsCache'; -import { LegacyOneToManyConfigSpec } from '@tech-matters/hrm-form-definitions'; +import { savePostSurvey } from '../hrm/savePostSurvey'; const triggerTypes = ['withUserMessage', 'withNextMessage'] as const; export type TriggerTypes = (typeof triggerTypes)[number]; @@ -613,100 +609,6 @@ const createStudioFlowTrigger = async ( }); }; -type PostSurveyBody = { - contactTaskId: string; - taskId: string; - data: PostSurveyData; -}; - -const saveSurveyInInsights = async ( - postSurveyConfigJson: LegacyOneToManyConfigSpec[], - memory: LexMemory, - controlTask: TaskInstance, - controlTaskAttributes: any, -) => { - const finalAttributes = buildSurveyInsightsData( - postSurveyConfigJson, - controlTaskAttributes, - memory, - ); - - await controlTask.update({ attributes: JSON.stringify(finalAttributes) }); -}; - -const saveSurveyInHRM = async ({ - accountSid, - controlTask, - controlTaskAttributes, - hrmApiVersion, - memory, - postSurveyConfigSpecs, -}: { - postSurveyConfigSpecs: LegacyOneToManyConfigSpec[]; - memory: LexMemory; - controlTask: TaskInstance; - controlTaskAttributes: any; - accountSid: AccountSID; - hrmApiVersion: string; -}) => { - const data = buildDataObject(postSurveyConfigSpecs, memory); - - const body: PostSurveyBody = { - contactTaskId: controlTaskAttributes.contactTaskId, - taskId: controlTask.sid, - data, - }; - - await postToInternalHrmEndpoint(accountSid, hrmApiVersion, 'postSurveys', body); -}; - -const handlePostSurveyComplete = async ({ - accountSid, - controlTask, - memory, - twilioClient, -}: { - accountSid: AccountSID; - twilioClient: Twilio; - memory: LexMemory; - controlTask: TaskInstance; -}) => { - const serviceConfig = await twilioClient.flexApi.v1.configuration.get().fetch(); - - const { hrm_api_version: hrmApiVersion } = serviceConfig.attributes; - const definition = await getCurrentDefinitionVersion({ accountSid }); - const postSurveyConfigSpecs = definition?.insights?.postSurveySpecs; - - if (postSurveyConfigSpecs?.length) { - 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, - accountSid, - hrmApiVersion, - }), - ]); - - // 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.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 = `No defined or invalid postSurveyConfigJson found for account ${accountSid}.`; - console.info(`Error accessing to the post survey form definitions: ${errorMEssage}`); - } -}; - export const handleChannelRelease = async ({ accountSid, capturedChannelAttributes, @@ -724,10 +626,11 @@ export const handleChannelRelease = async ({ }) => { try { // get the control task - const controlTask = await twilioClient.taskrouter.v1 + const controlTaskContext = twilioClient.taskrouter.v1 .workspaces(twilioWorkspaceSid) - .tasks(capturedChannelAttributes.controlTaskSid) - .fetch(); + .tasks(capturedChannelAttributes.controlTaskSid); + // get the control task + const controlTask = await controlTaskContext.fetch(); if (capturedChannelAttributes.releaseType === 'triggerStudioFlow') { await createStudioFlowTrigger( @@ -738,7 +641,13 @@ export const handleChannelRelease = async ({ } if (capturedChannelAttributes.releaseType === 'postSurveyComplete') { - await handlePostSurveyComplete({ memory, controlTask, accountSid, twilioClient }); + await savePostSurvey({ + postSurveyAnswers: memory, + controlTask, + accountSid, + twilioClient, + }); + await controlTaskContext.update({ assignmentStatus: 'canceled' }); } return newOk({}); diff --git a/lambdas/account-scoped/src/channelCapture/hrmDataManipulation.ts b/lambdas/account-scoped/src/channelCapture/hrmDataManipulation.ts deleted file mode 100644 index 1d29616fc0..0000000000 --- a/lambdas/account-scoped/src/channelCapture/hrmDataManipulation.ts +++ /dev/null @@ -1,61 +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 { get } from 'lodash'; -// eslint-disable-next-line prettier/prettier -import type { LexMemory } from './lexClient'; -import { LegacyOneToManyConfigSpec } from '@tech-matters/hrm-form-definitions'; - -export type PostSurveyData = { [question: string]: string | number }; - -type BotMemory = 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, pathBuilder: (question: string) => string) => - (accum: PostSurveyData, curr: LegacyOneToManyConfigSpec) => { - const paths = curr.questions.map( - question => ({ - question, - path: pathBuilder(question), - }), // Path where the answer for each question should be in bot memory - ); - - const values: PostSurveyData = {}; - paths.forEach(p => { - values[p.question] = get(memory, p.path, ''); - }); - - return { ...accum, ...values }; - }; - -/** - * 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: LegacyOneToManyConfigSpec[], - memory: BotMemory, - pathBuilder: (question: string) => string = q => q, -) => { - const reducerFunction = flattenOneToMany(memory, pathBuilder); - return oneToManyConfigSpecs.reduce(reducerFunction, {}); -}; - -export type BuildDataObject = typeof buildDataObject; diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts new file mode 100644 index 0000000000..648bf3c86d --- /dev/null +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -0,0 +1,153 @@ +/** + * 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 { + registerTaskRouterEventHandler, + TaskRouterEventHandler, +} from '../taskrouter/taskrouterEventHandler'; +import { AccountSID } from '@tech-matters/twilio-types'; +import TwilioSDK, { Twilio } from 'twilio'; +import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; +import { EventFields } from '../taskrouter'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; +import { isChatCaptureControlTask } from './channelCaptureHandlers'; +import VoiceResponse = TwilioSDK.twiml.VoiceResponse; + +// TODO: factor out +type TransferMeta = { + mode: 'COLD' | 'WARM'; + transferStatus: 'transferring' | 'accepted' | 'rejected'; + sidWithTaskControl: string; +}; + +const isTriggerPostStudioFlow = ({ + taskAttributes, +}: { + eventType: EventType; + taskChannelUniqueName: string; + taskAttributes: { + transferMeta?: TransferMeta; + isChatCaptureControl?: boolean; + }; +}) => { + return !isChatCaptureControlTask(taskAttributes); +}; + +const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( + event: EventFields, + accountSid: AccountSID, + client: Twilio, +) => { + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]:`; + try { + const { + EventType: eventType, + TaskChannelUniqueName: taskChannelUniqueName, + TaskAttributes: taskAttributesString, + TaskSid: taskSid, + } = event; + + const taskAttributes = JSON.parse(taskAttributesString); + + if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { + console.info(`${logPrefix} Handling post studio flow trigger...`); + console.debug('[SENSITIVE] taskAttributes', taskAttributes); + + // This task is a candidate to trigger post survey. Check feature flags for the account. + const serviceConfigAttributes = + await retrieveServiceConfigurationAttributes(client); + const { postStudioFlows } = serviceConfigAttributes; + const studioFlowIdentifier = postStudioFlows?.[taskChannelUniqueName] ?? ''; + + if (typeof studioFlowIdentifier === 'object') { + const { sipEndpoint, sipUsername, sipPassword } = studioFlowIdentifier; + const { conference, contactId } = taskAttributes; + if (taskChannelUniqueName === 'voice' && conference) { + const conferenceContext = client.conferences.get(conference.sid); + // 1. Fetch all active participants in the conference + const allParticipants = await conferenceContext.participants.list(); + console.debug( + `${logPrefix} ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + allParticipants, + ); + const connectedParticipants = allParticipants.filter( + p => p.status === 'connected', + ); + console.debug( + `${logPrefix} ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + connectedParticipants, + ); + if (connectedParticipants.length === 1) { + const [participant] = connectedParticipants; + try { + await participant.update({ + hold: true, + }); + console.debug( + `${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, + ); + const twiml = new VoiceResponse(); + const dial = twiml.dial(); + dial.sip( + { + username: sipUsername, + password: sipPassword, + }, + `${sipEndpoint}?x-contactId=${contactId}&x-taskSid=${taskSid}`, + ); + await client.calls.get(participant.callSid).update({ + twiml, + }); + console.debug( + `${logPrefix} Dialed ${studioFlowIdentifier} passing ${contactId} and ${taskSid} to start post survey.`, + ); + } catch (err) { + await participant.remove(); + console.debug( + `${logPrefix} Removed participant ${participant.callSid} from conference ${conference.sid}.`, + ); + console.error( + `${logPrefix} triggerPostStudioFlowTaskRouterListener for participant ${participant.callSid} failed`, + err, + ); + } + } else { + console.debug( + `${logPrefix} Only valid for redirecting to studio flow if there is only one connected participant on the conference`, + ); + } + } else { + console.warn( + `${logPrefix} Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, + `taskChannelUniqueName: ${taskChannelUniqueName}`, + `conference: ${conference}`, + ); + } + + console.info(`${logPrefix} Finished handling post studio flow trigger.`); + } else { + console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); + } + } + } catch (err) { + console.error( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: triggerPostStudioFlowTaskRouterListener failed`, + err, + ); + } +}; + +registerTaskRouterEventHandler([TASK_WRAPUP], triggerPostStudioFlowTaskRouterListener); diff --git a/lambdas/account-scoped/src/conference/conferenceStatusCallback.ts b/lambdas/account-scoped/src/conference/conferenceStatusCallback.ts index 96eac93e67..6b67db0abf 100644 --- a/lambdas/account-scoped/src/conference/conferenceStatusCallback.ts +++ b/lambdas/account-scoped/src/conference/conferenceStatusCallback.ts @@ -86,7 +86,7 @@ export type ConferenceStatusEventHandler = ( const eventHandlers: Record = {}; -export const registerTaskRouterEventHandler = ( +export const registerConferenceStatusEventHandler = ( eventTypes: EventType[], handler: ConferenceStatusEventHandler, ) => { @@ -105,7 +105,7 @@ export const conferenceStatusCallbackHandler: AccountScopedHandler = async ( const conferenceEvent = body as ConferenceEvent; const handlers = eventHandlers[conferenceEvent.StatusCallbackEvent] ?? []; console.info( - `Handling task router event: ${conferenceEvent.StatusCallbackEvent} for account: ${accountSid} - executing ${handlers.length} registered handlers.`, + `Handling conference event: ${conferenceEvent.StatusCallbackEvent} for account: ${accountSid}, conference: ${conferenceEvent.ConferenceSid} - executing ${handlers.length} registered handlers.`, ); console.debug(`Event`, conferenceEvent); diff --git a/lambdas/account-scoped/src/conference/isAgentInConference.ts b/lambdas/account-scoped/src/conference/isAgentInConference.ts new file mode 100644 index 0000000000..76905df2e6 --- /dev/null +++ b/lambdas/account-scoped/src/conference/isAgentInConference.ts @@ -0,0 +1,60 @@ +/** + * 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 type { ParticipantInstance } from 'twilio/lib/rest/api/v2010/account/conference/participant'; + +export const isAgentInConference = ({ + callSid, + customerCallSid, + participant, +}: { + callSid: string; + customerCallSid: string; + participant: ParticipantInstance; +}): boolean => { + console.debug('Checking if participant is an agent:', participant); + + if ( + participant.label?.startsWith('External party') || + participant.label === 'external party' + ) { + // This was added via our addParticipant function + console.debug( + `Participant ${participant.label} (${participant.callSid}) identified as external party`, + ); + return false; + } + + if (participant.callSid === callSid) { + // This is the participant firing the event + console.warn( + `Participant ${participant.label} (${participant.callSid}) still in conference, despite leave event for them`, + ); + return false; + } + + // TODO: Detect caller vs agent + if (participant.callSid === customerCallSid) { + console.debug( + `Participant ${participant.label} (${participant.callSid}) identified as service user, because their call sid is the customer call sid`, + ); + return false; + } + + console.debug( + `Participant ${participant.label} (${participant.callSid}) not identified as the service user or an external party, so must be an agent, keep recording`, + ); + return true; +}; diff --git a/lambdas/account-scoped/src/conference/setEndConferenceOnExit.ts b/lambdas/account-scoped/src/conference/setEndConferenceOnExit.ts new file mode 100644 index 0000000000..f700066b86 --- /dev/null +++ b/lambdas/account-scoped/src/conference/setEndConferenceOnExit.ts @@ -0,0 +1,57 @@ +/** + * 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 { + ConferenceStatusEventHandler, + registerConferenceStatusEventHandler, +} from './conferenceStatusCallback'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; + +import { isAgentInConference } from './isAgentInConference'; + +const handler: ConferenceStatusEventHandler = async (event, accountSid, client) => { + if (event.StatusCallbackEvent !== 'participant-join') { + return; + } + const { + ConferenceSid: conferenceSid, + CallSid: callSid, + CustomerCallSid: customerCallSid, + } = event; + // This is NOT the service user joining the call + const { postStudioFlows = {} } = await retrieveServiceConfigurationAttributes(client); + if (postStudioFlows.voice) { + // A post studio flow is set up so don't end the call when the agent leaves + const participant = await client.conferences + .get(conferenceSid) + .participants.get(callSid) + .fetch(); + if ( + isAgentInConference({ callSid, customerCallSid, participant }) && + participant.endConferenceOnExit + ) { + console.info( + `Post studio flows set up for voice on ${accountSid}, and ${callSid} is an agent in ${conferenceSid} with endConferenceOnExit set true so setting endConferenceOnExit to false for them`, + ); + await participant.update({ endConferenceOnExit: false }); + } else { + } + } else { + console.debug(`No post studio flows set up for voice on ${accountSid}`); + } +}; + +registerConferenceStatusEventHandler(['participant-join'], handler); diff --git a/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts b/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts index 5855cb0b60..423a191dbe 100644 --- a/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts +++ b/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts @@ -14,57 +14,13 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import type { ParticipantInstance } from 'twilio/lib/rest/api/v2010/account/conference/participant'; import { ConferenceStatusEventHandler, - registerTaskRouterEventHandler, + registerConferenceStatusEventHandler, } from './conferenceStatusCallback'; import type RestException from 'twilio/lib/base/RestException'; import { hasTaskControl } from '../transfer/hasTaskControl'; - -const isAgentInConference = ({ - callSid, - customerCallSid, - participant, -}: { - callSid: string; - customerCallSid: string; - participant: ParticipantInstance; -}): boolean => { - console.debug('Remaining participant', participant); - - if ( - participant.label?.startsWith('External party') || - participant.label === 'external party' - ) { - // This was added via our addParticipant function - console.debug( - `Participant ${participant.label} (${participant.callSid}) identified as external party`, - ); - return false; - } - - if (participant.callSid === callSid) { - // This is the participant firing the event - console.warn( - `Participant ${participant.label} (${participant.callSid}) still in conference, despite leave event for them`, - ); - return false; - } - - // TODO: Detect caller vs agent - if (participant.callSid === customerCallSid) { - console.debug( - `Participant ${participant.label} (${participant.callSid}) identified as service user, because their call sid is the customer call sid`, - ); - return false; - } - - console.debug( - `Participant ${participant.label} (${participant.callSid}) not identified as the service user or an external party, so must be an agent, keep recording`, - ); - return true; -}; +import { isAgentInConference } from './isAgentInConference'; const handler: ConferenceStatusEventHandler = async (event, _accountSid, client) => { if (event.StatusCallbackEvent !== 'participant-leave') { @@ -146,4 +102,4 @@ const handler: ConferenceStatusEventHandler = async (event, _accountSid, client) ); }; -registerTaskRouterEventHandler(['participant-leave'], handler); +registerConferenceStatusEventHandler(['participant-leave'], handler); diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts new file mode 100644 index 0000000000..4a2ff068e9 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -0,0 +1,150 @@ +/** + * 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 { LegacyOneToManyConfigSpec } from '@tech-matters/hrm-form-definitions'; +import { LexMemory } from '../channelCapture/lexClient'; +import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; +import { buildSurveyInsightsData } from '../channelCapture/insightsService'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { Twilio } from 'twilio'; +import { getCurrentDefinitionVersion } from './formDefinitionsCache'; +import { postToInternalHrmEndpoint } from './internalHrmRequest'; +import { get } from 'lodash'; + +export type PostSurveyData = { [question: string]: string | number }; +/** + * 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: PostSurveyData, pathBuilder: (question: string) => string) => + (accum: PostSurveyData, curr: LegacyOneToManyConfigSpec) => { + const paths = curr.questions.map( + question => ({ + question, + path: pathBuilder(question), + }), // Path where the answer for each question should be in bot memory + ); + + const values: PostSurveyData = {}; + paths.forEach(p => { + values[p.question] = get(memory, p.path, ''); + }); + + return { ...accum, ...values }; + }; +/** + * 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: LegacyOneToManyConfigSpec[], + memory: PostSurveyData, + pathBuilder: (question: string) => string = q => q, +) => { + const reducerFunction = flattenOneToMany(memory, pathBuilder); + return oneToManyConfigSpecs.reduce(reducerFunction, {}); +}; + +type PostSurveyBody = { + contactTaskId: string; + taskId: string; + data: PostSurveyData; +}; + +const saveSurveyInInsights = async ( + postSurveyConfigJson: LegacyOneToManyConfigSpec[], + memory: LexMemory, + controlTask: TaskInstance, + controlTaskAttributes: any, +) => { + const finalAttributes = buildSurveyInsightsData( + postSurveyConfigJson, + controlTaskAttributes, + memory, + ); + + await controlTask.update({ attributes: JSON.stringify(finalAttributes) }); +}; + +const saveSurveyInHRM = async ({ + accountSid, + controlTask, + controlTaskAttributes, + hrmApiVersion, + postSurveyAnswers, + postSurveyConfigSpecs, +}: { + postSurveyConfigSpecs: LegacyOneToManyConfigSpec[]; + postSurveyAnswers: PostSurveyData; + controlTask: TaskInstance; + controlTaskAttributes: any; + accountSid: AccountSID; + hrmApiVersion: string; +}) => { + const data = buildDataObject(postSurveyConfigSpecs, postSurveyAnswers); + + const body: PostSurveyBody = { + contactTaskId: controlTaskAttributes.contactTaskId, + taskId: controlTask.sid, + data, + }; + + await postToInternalHrmEndpoint(accountSid, hrmApiVersion, 'postSurveys', body); +}; + +export const savePostSurvey = async ({ + accountSid, + controlTask, + postSurveyAnswers, + twilioClient, +}: { + accountSid: AccountSID; + twilioClient: Twilio; + postSurveyAnswers: LexMemory; + controlTask: TaskInstance; +}) => { + const serviceConfig = await twilioClient.flexApi.v1.configuration.get().fetch(); + + const { hrm_api_version: hrmApiVersion } = serviceConfig.attributes; + const definition = await getCurrentDefinitionVersion({ accountSid }); + const postSurveyConfigSpecs = definition?.insights?.postSurveySpecs; + + if (postSurveyConfigSpecs?.length) { + const controlTaskAttributes = JSON.parse(controlTask.attributes); + + // parallel execution to save survey collected data in insights and hrm + await Promise.all([ + saveSurveyInInsights( + postSurveyConfigSpecs, + postSurveyAnswers, + controlTask, + controlTaskAttributes, + ), + saveSurveyInHRM({ + postSurveyConfigSpecs, + postSurveyAnswers, + controlTask, + controlTaskAttributes, + accountSid, + hrmApiVersion, + }), + ]); + } else { + const errorMessage = `No defined or invalid postSurveyConfigJson found for account ${accountSid}.`; + console.info(`Error accessing to the post survey form definitions: ${errorMessage}`); + } +}; diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts new file mode 100644 index 0000000000..9b8c0e8238 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -0,0 +1,200 @@ +/** + * 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 { AccountScopedHandler, HttpError } from '../httpTypes'; +import { + getSurveyWorkflowSid, + getSyncServiceSid, + getTwilioClient, + getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; +import { newOk, Result } from '../Result'; +import { savePostSurvey } from './savePostSurvey'; +import { newMissingParameterResult } from '../httpErrors'; +import TwilioSDK from 'twilio'; +import VoiceResponse = TwilioSDK.twiml.VoiceResponse; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; + +const getPostSurveySyncDocUniqueName = (callerIdentifier: string) => + `post-studio-flow-call-data-${callerIdentifier}`; + +export const savePostSurveyHandler: AccountScopedHandler = async ( + request, + accountSid, +): Promise> => { + const { postSurveyAnswers, contactId, contactTaskSid } = request.body; + const twilioClient = await getTwilioClient(accountSid); + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]:`; + const controlTask = await twilioClient.taskrouter.v1 + .workspaces(await getWorkspaceSid(accountSid)) + .tasks.create({ + attributes: JSON.stringify({ + contactTaskId: contactTaskSid, + contactId, + isSurveyTask: true, + }), + workflowSid: await getSurveyWorkflowSid(accountSid), + taskChannel: 'survey', + }); + + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, + ); + await savePostSurvey({ twilioClient, accountSid, controlTask, postSurveyAnswers }); + // 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.ts) + // This needs to be the last step so the new task attributes from saveSurveyInInsights make it to insights + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, + ); + await controlTask.update({ assignmentStatus: 'canceled' }); + + const successMessage = `${logPrefix} Saved post survey to contact ${contactId} with contact task SID ${contactTaskSid}.`; + return newOk({ message: successMessage }); +}; + +export const voicePostSurveyActionHandler: AccountScopedHandler = async ( + { query, body }, + accountSid, +) => { + const twilioClient = await getTwilioClient(accountSid); + const { contactId, contactTaskSid: taskSid } = query; + const { DialCallSid: clientIdentifier } = body; + if (!clientIdentifier) return newMissingParameterResult('DialCallSid'); + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${taskSid}]:`; + console.debug( + `${logPrefix} DialCallSid call`, + await twilioClient.calls.get(clientIdentifier).fetch(), + ); + const uniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); + console.debug( + `${logPrefix} Dial Action URL called for contact ID ${contactId} and contact task SID ${taskSid}, retrieving post survey data under sync doc ${uniqueName} for use in the post flow.`, + ); + const docContext = twilioClient.sync.v1.services + .get(await getSyncServiceSid(accountSid)) + .documents.get(uniqueName); + const { + data: { postSurveyAnswers }, + } = await docContext.fetch(); + console.debug( + `${logPrefix} Dial Action URL called for contact ID ${contactId} and contact task SID ${taskSid}, retrieved post survey data under sync doc ${uniqueName} for use in the post flow.`, + ); + const controlTask = await twilioClient.taskrouter.v1 + .workspaces(await getWorkspaceSid(accountSid)) + .tasks.create({ + attributes: JSON.stringify({ + contactTaskId: taskSid, + contactId, + isSurveyTask: true, + }), + workflowSid: await getSurveyWorkflowSid(accountSid), + taskChannel: 'survey', + }); + + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, + ); + await savePostSurvey({ twilioClient, accountSid, controlTask, postSurveyAnswers }); + // 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.ts) + // This needs to be the last step so the new task attributes from saveSurveyInInsights make it to insights + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, + ); + await controlTask.update({ assignmentStatus: 'canceled' }); + await docContext.remove(); + + const successMessage = `${logPrefix} Dial Action URL called for contact ID ${contactId} and contact task SID ${taskSid}, retrieved post survey data under sync doc ${uniqueName} and saved it with contact context.`; + return newOk({ message: successMessage }); +}; + +export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( + { query, body }, + accountSid, +) => { + const twilioClient = await getTwilioClient(accountSid); + const { Digits: digits } = body; + const { hrm_base_url: hrmBaseUrl } = + await retrieveServiceConfigurationAttributes(twilioClient); + const { contactId, contactTaskSid: taskSid, answer1, answer2 } = query; + const response = new VoiceResponse(); + const logPrefix = `[Post Survey Studio Flow - ${accountSid}]:`; + if (!digits) { + console.debug(`${logPrefix} No digits, gathering for first answer`); + response.say('Press 1 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + // Query parameters must be alphabetical for webhook validation to work :-/ + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}`, + }); + } else if (!answer1) { + console.debug( + `${logPrefix} Digits supplied but no answers, saving answer1 and gathering for second answer`, + ); + response.say('Press 2 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + // Query parameters must be alphabetical for webhook validation to work :-/ + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?answer1=${digits}&contactId=${contactId}&contactTaskSid=${taskSid}`, + }); + } else if (!answer2) { + console.debug( + `${logPrefix} Digits supplied and answer, saving answer2 and gathering for third answer`, + ); + response.say('Press 3 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + // Query parameters must be alphabetical for webhook validation to work :-/ + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?answer1=${answer1}&answer2=${digits}&contactId=${contactId}&contactTaskSid=${taskSid}`, + }); + } else { + console.debug( + `${logPrefix} Digits supplied and two answers, saving all 3 anwsers to back end`, + ); + const controlTask = await twilioClient.taskrouter.v1 + .workspaces(await getWorkspaceSid(accountSid)) + .tasks.create({ + attributes: JSON.stringify({ + contactTaskId: taskSid, + contactId, + isSurveyTask: true, + }), + workflowSid: await getSurveyWorkflowSid(accountSid), + taskChannel: 'survey', + }); + + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, + ); + await savePostSurvey({ + accountSid, + postSurveyAnswers: { + was_helpful: answer1, + would_recommend: answer2, + answer3: digits, + }, + twilioClient, + controlTask, + }); + + response.say('No go away'); + } + return newOk(response); +}; diff --git a/lambdas/account-scoped/src/httpTypes.ts b/lambdas/account-scoped/src/httpTypes.ts index fe649c5a85..8c864f18d3 100644 --- a/lambdas/account-scoped/src/httpTypes.ts +++ b/lambdas/account-scoped/src/httpTypes.ts @@ -49,6 +49,7 @@ export type HttpRequestPipelineStep = PipelineStep< export type FunctionRoute = { requestPipeline: HttpRequestPipelineStep[]; handler: AccountScopedHandler; + responseType: 'json' | 'text' | 'xml'; }; export type AccountScopedRoute = FunctionRoute & { diff --git a/lambdas/account-scoped/src/index.ts b/lambdas/account-scoped/src/index.ts index 135407caca..dc778534a4 100644 --- a/lambdas/account-scoped/src/index.ts +++ b/lambdas/account-scoped/src/index.ts @@ -22,6 +22,7 @@ import { convertHttpErrorResultToALBResult, notFoundResponse, okJsonResponse, + okXmlResponse, } from './albResponses'; const parseBody = ({ @@ -88,7 +89,14 @@ export const handler = async (event: ALBEvent): Promise => { ); return convertHttpErrorResultToALBResult(result); } - return okJsonResponse(result.unwrap()); + switch (route.responseType) { + case 'json': + return okJsonResponse(result.unwrap()); + case 'text': + return result.unwrap().toString(); + case 'xml': + return okXmlResponse(result.unwrap().toString()); + } } return notFoundResponse(event); } catch (err) { diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index ae6b846509..8cdf297334 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -44,6 +44,7 @@ import { checkBlockListHandler } from './conversation/checkBlockList'; import { transitionAgentParticipantsHandler } from './conversation/transitionAgentParticipants'; import { conferenceStatusCallbackHandler } from './conference/conferenceStatusCallback'; import './conference/stopRecordingWhenLastAgentLeaves'; +import './conference/setEndConferenceOnExit'; import { instagramToFlexHandler } from './customChannels/instagram/instagramToFlex'; import { flexToInstagramHandler } from './customChannels/instagram/flexToInstagram'; import { telegramToFlexHandler } from './customChannels/telegram/telegramToFlex'; @@ -74,6 +75,11 @@ import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRu import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; +import { + savePostSurveyHandler, + voicePostSurveyActionHandler, + voicePostSurveyAnswerHandler, +} from './hrm/voicePostSurvey'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -84,219 +90,320 @@ import { getMediaUrlHandler } from './conversation/getMediaUrl'; */ export const ROUTE_PREFIX = '/lambda/twilio/account-scoped/'; +type RouteParam = Omit< + FunctionRoute | FunctionRoute, + 'responseType' +> & { responseType?: FunctionRoute['responseType'] }; -const INITIAL_PIPELINE = [validateRequestMethod]; +const newRoute = ( + route: RouteParam, +): FunctionRoute | FunctionRoute => ({ + responseType: 'json', + ...route, +}); const ACCOUNTSID_ROUTES: Record< string, FunctionRoute | FunctionRoute > = { - 'webhooks/taskrouterCallback': { - requestPipeline: [validateWebhookRequest], + 'webhooks/taskrouterCallback': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleTaskRouterEvent, - }, - getProfileFlagsForIdentifier: { - requestPipeline: [validateWebhookRequest], + }), + getProfileFlagsForIdentifier: newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleGetProfileFlagsForIdentifier, - }, - 'channelCapture/captureChannelWithBot': { - requestPipeline: [validateWebhookRequest], + }), + 'channelCapture/captureChannelWithBot': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleCaptureChannelWithBot, - }, - 'channelCapture/chatbotCallback': { - requestPipeline: [validateWebhookRequest], + }), + 'channelCapture/chatbotCallback': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallback, - }, - 'channelCapture/chatbotCallbackCleanup': { - requestPipeline: [validateWebhookRequest], + }), + 'channelCapture/chatbotCallbackCleanup': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallbackCleanup, - }, - 'conference/conferenceStatusCallback': { - requestPipeline: [validateWebhookRequest], + }), + 'hrm/savePostSurvey': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: savePostSurveyHandler, + }), + 'hrm/voicePostSurveyAction': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: voicePostSurveyActionHandler, + }), + 'hrm/voicePostSurveyAnswer': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: voicePostSurveyAnswerHandler, + responseType: 'xml', + }), + 'conference/conferenceStatusCallback': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: conferenceStatusCallbackHandler, - }, - 'conference/addParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conference/addParticipant': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: addParticipantHandler, - }, - 'conference/getParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conference/getParticipant': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getParticipantHandler, - }, - 'conference/removeParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conference/removeParticipant': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: removeParticipantHandler, - }, - 'conference/updateParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conference/updateParticipant': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: updateParticipantHandler, - }, - 'conference/participantStatusCallback': { - requestPipeline: [validateWebhookRequest], + }), + 'conference/participantStatusCallback': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: participantStatusCallbackHandler, - }, - 'conversations/serviceScopedConversationEventHandler': { - requestPipeline: [validateWebhookRequest], + }), + 'conversations/serviceScopedConversationEventHandler': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleConversationEvent, - }, - 'conversation/checkBlockList': { - requestPipeline: [validateWebhookRequest], + }), + 'conversation/checkBlockList': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: checkBlockListHandler, - }, - 'conversation/transitionAgentParticipants': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conversation/transitionAgentParticipants': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: transitionAgentParticipantsHandler, - }, - 'customChannels/instagram/instagramToFlex': { - requestPipeline: [], + }), + 'customChannels/instagram/instagramToFlex': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: instagramToFlexHandler, - }, - 'customChannels/instagram/flexToInstagram': { - requestPipeline: [validateWebhookRequest], + }), + 'customChannels/instagram/flexToInstagram': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToInstagramHandler, - }, - 'customChannels/telegram/telegramToFlex': { - requestPipeline: [], + }), + 'customChannels/telegram/telegramToFlex': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: telegramToFlexHandler, - }, - 'customChannels/telegram/flexToTelegram': { - requestPipeline: [validateWebhookRequest], + }), + 'customChannels/telegram/flexToTelegram': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToTelegramHandler, - }, - 'customChannels/modica/modicaToFlex': { - requestPipeline: [], + }), + 'customChannels/modica/modicaToFlex': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: modicaToFlexHandler, - }, - 'customChannels/modica/flexToModica': { - requestPipeline: [validateWebhookRequest], + }), + 'customChannels/modica/flexToModica': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToModicaHandler, - }, - 'customChannels/line/lineToFlex': { - requestPipeline: [], + }), + 'customChannels/line/lineToFlex': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: lineToFlexHandler, - }, - 'customChannels/line/flexToLine': { - requestPipeline: [validateWebhookRequest], + }), + 'customChannels/line/flexToLine': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToLineHandler, - }, - 'webchatAuth/initWebchat': { - requestPipeline: [], + }), + 'webchatAuth/initWebchat': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, - }, - 'webchatAuth/refreshToken': { - requestPipeline: [], + }), + 'webchatAuth/refreshToken': newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: refreshTokenHandler, - }, - toggleSwitchboardQueue: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'supervisor' })], + }), + toggleSwitchboardQueue: newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'supervisor' }), + ], handler: handleToggleSwitchboardQueue, - }, - 'task/assignOfflineContactInit': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/assignOfflineContactInit': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: assignOfflineContactInitHandler, - }, - 'task/assignOfflineContactResolve': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/assignOfflineContactResolve': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: assignOfflineContactResolveHandler, - }, - endChat: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'guest' })], + }), + endChat: newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'guest' }), + ], handler: handleEndChat, - }, - operatingHours: { - requestPipeline: [], + }), + operatingHours: newRoute({ + requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, - }, - 'task/checkTaskAssignment': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/checkTaskAssignment': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: checkTaskAssignmentHandler, - }, - 'task/completeTaskAssignment': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/completeTaskAssignment': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: completeTaskAssignmentHandler, - }, - 'task/cancelOrRemoveTask': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/cancelOrRemoveTask': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: cancelOrRemoveTaskHandler, - }, - 'task/getTaskAndReservations': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'task/getTaskAndReservations': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getTaskAndReservationsHandler, - }, - 'transfer/transferStart': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'transfer/transferStart': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: transferStartHandler, - }, - updateWorkersSkills: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'supervisor' })], + }), + updateWorkersSkills: newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'supervisor' }), + ], handler: handleUpdateWorkersSkills, - }, - 'integrations/iwf/reportToIWF': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'integrations/iwf/reportToIWF': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: reportToIWFHandler, - }, - 'integrations/iwf/selfReportToIWF': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'integrations/iwf/selfReportToIWF': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: selfReportToIWFHandler, - }, - 'conversation/getExternalRecordingS3Location': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conversation/getExternalRecordingS3Location': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getExternalRecordingS3LocationHandler, - }, - 'conversation/getMediaUrl': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conversation/getMediaUrl': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getMediaUrlHandler, - }, - 'worker/populateCounselors': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'worker/populateCounselors': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: populateCounselorsHandler, - }, - 'worker/getWorkerAttributes': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'worker/getWorkerAttributes': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getWorkerAttributesHandler, - }, - 'worker/listWorkerQueues': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'worker/listWorkerQueues': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: listWorkerQueuesHandler, - }, - 'worker/pullTask': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'worker/pullTask': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: pullTaskHandler, - }, - 'conversation/sendSystemMessage': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + 'conversation/sendSystemMessage': newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: sendSystemMessageHandler, - }, - 'conversation/sendStudioMessage': { - requestPipeline: [validateWebhookRequest], + }), + 'conversation/sendStudioMessage': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendStudioMessageHandler, - }, - 'conversation/sendMessageAndRunJanitor': { - requestPipeline: [validateWebhookRequest], + }), + 'conversation/sendMessageAndRunJanitor': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendMessageAndRunJanitorHandler, - }, - issueSyncToken: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + }), + issueSyncToken: newRoute({ + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: issueSyncTokenHandler, - }, + }), }; const ENV_SHORTCODE_ROUTES: Record = { 'webchatAuthentication/initWebchat': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, + responseType: 'json', }, 'webchatAuthentication/refreshToken': { - requestPipeline: [validateRequestWithTwilioJwtToken], + requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: refreshTokenHandler, + responseType: 'json', }, endChat: { - requestPipeline: [validateRequestWithTwilioJwtToken], + requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: handleEndChat, + responseType: 'json', }, operatingHours: { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, + responseType: 'json', }, }; @@ -320,7 +427,7 @@ export const lookupRoute = async ( return { accountSid: accountIdentifier, ...functionRoute, - requestPipeline: [...INITIAL_PIPELINE, ...functionRoute.requestPipeline], + requestPipeline: [...functionRoute.requestPipeline], }; } } else { @@ -336,7 +443,7 @@ export const lookupRoute = async ( return { accountSid: await getAccountSid(accountIdentifier), ...functionRoute, - requestPipeline: [...INITIAL_PIPELINE, ...functionRoute.requestPipeline], + requestPipeline: [...functionRoute.requestPipeline], }; } } diff --git a/lambdas/account-scoped/src/taskrouter/index.ts b/lambdas/account-scoped/src/taskrouter/index.ts index 01da0f1af0..a1c9e65969 100644 --- a/lambdas/account-scoped/src/taskrouter/index.ts +++ b/lambdas/account-scoped/src/taskrouter/index.ts @@ -23,6 +23,7 @@ import '../conversation/addTaskSidToChannelAttributesTaskRouterListener'; import '../conversation/adjustCapacityTaskRouterListener'; import '../conversation/janitorTaskRouterListener'; import '../channelCapture/postSurveyListener'; +import '../channelCapture/postStudioFlowTaskRouterListener'; import '../transfer/transfersTaskRouterListener'; export { handleTaskRouterEvent } from './taskrouterEventHandler'; diff --git a/lambdas/account-scoped/src/validation/method.ts b/lambdas/account-scoped/src/validation/method.ts index 63aaf2d234..754cbe5bbc 100644 --- a/lambdas/account-scoped/src/validation/method.ts +++ b/lambdas/account-scoped/src/validation/method.ts @@ -17,9 +17,10 @@ import { HttpRequestPipelineStep } from '../httpTypes'; import { newErr, newOk } from '../Result'; -export const validateRequestMethod: HttpRequestPipelineStep = async request => { - if (request.method.toUpperCase() !== 'POST') { - return newErr({ message: 'Method not allowed', error: { statusCode: 405 } }); - } - return newOk(request); -}; +export const validateRequestMethod: (method: string) => HttpRequestPipelineStep = + method => async request => { + if (request.method.toUpperCase() !== method.toUpperCase()) { + return newErr({ message: 'Method not allowed', error: { statusCode: 405 } }); + } + return newOk(request); + }; diff --git a/lambdas/account-scoped/src/validation/twilioWebhook.ts b/lambdas/account-scoped/src/validation/twilioWebhook.ts index b5c52b79f9..c7f4776b2f 100644 --- a/lambdas/account-scoped/src/validation/twilioWebhook.ts +++ b/lambdas/account-scoped/src/validation/twilioWebhook.ts @@ -50,7 +50,7 @@ export const validateWebhookRequest: HttpRequestPipelineStep = async ( authToken, twiloSignature, urlForValidation, - bodySHA256 ? [] : body, // Pass in the body to validate the signature if no SHA256 is provided + bodySHA256 ? {} : body, // Pass in the body to validate the signature if no SHA256 is provided ); if (!isValid) { console.warn( diff --git a/lambdas/account-scoped/tests/unit/taskrouter/taskrouterEventHandler.test.ts b/lambdas/account-scoped/tests/unit/taskrouter/taskrouterEventHandler.test.ts index 3557bcf3ef..6af230bacb 100644 --- a/lambdas/account-scoped/tests/unit/taskrouter/taskrouterEventHandler.test.ts +++ b/lambdas/account-scoped/tests/unit/taskrouter/taskrouterEventHandler.test.ts @@ -30,7 +30,13 @@ import { TASK_CANCELED, } from '../../../src/taskrouter/eventTypes'; -jest.mock('twilio', () => jest.fn()); +jest.mock('twilio', () => { + const mockFn = jest.fn() as jest.Mock & { twiml: { VoiceResponse: jest.Mock } }; + mockFn.twiml = { + VoiceResponse: jest.fn(), + }; + return mockFn; +}); const mockTwilio: jest.MockedFunction<(account: string, auth: string) => twilio.Twilio> = twilio as unknown as jest.MockedFunction< (account: string, auth: string) => twilio.Twilio diff --git a/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts b/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts index 588669fc61..2c745be163 100644 --- a/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts +++ b/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts @@ -70,7 +70,10 @@ const captureListeners = () => { beforeEach(() => { jest.clearAllMocks(); Object.keys(listeners).forEach(k => delete listeners[k]); - mockGetHrmConfig.mockReturnValue({ accountScopedLambdaBaseUrl: 'https://lambda.example.com' } as any); + mockGetHrmConfig.mockReturnValue({ + accountScopedLambdaBaseUrl: 'https://lambda.example.com', + postStudioFlows: {}, + } as any); captureListeners(); }); @@ -92,7 +95,7 @@ describe('setUpConferenceActions', () => { 'https://lambda.example.com/conference/conferenceStatusCallback', ); expect(conferenceOptions.conferenceStatusCallbackMethod).toBe('POST'); - expect(conferenceOptions.conferenceStatusCallbackEvent).toBe('leave'); + expect(conferenceOptions.conferenceStatusCallbackEvent).toBe(['leave', 'join'].toString()); }); test('does not throw and does nothing when feature flag is enabled but conferenceOptions is undefined (non-call task)', async () => { diff --git a/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx b/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx index 8d12bcd5b6..c8e929657d 100644 --- a/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx +++ b/plugin-hrm-form/src/components/Conference/ConferenceMonitor/index.tsx @@ -19,6 +19,7 @@ import { Conference } from '@twilio/flex-ui/src/state/Conferences'; import { hasTaskControl, isOriginalReservation, isTransferring } from '../../../transfer/transferTaskState'; import * as conferenceApi from '../../../services/conferenceService'; +import { getHrmConfig } from '../../../hrmConfig'; const isJoinedWithEnd = (p: ConferenceParticipant) => p.status === 'joined' && p.mediaProperties.endConferenceOnExit; const isJoinedWithoutEnd = (p: ConferenceParticipant) => @@ -28,6 +29,7 @@ type Props = TaskContextProps; const ConferenceMonitor: React.FC = ({ conference, task }) => { const [updating, setUpdating] = React.useState(false); + const { postStudioFlows } = getHrmConfig(); const conferenceSource: Partial = conference?.source ?? {}; @@ -38,7 +40,7 @@ const ConferenceMonitor: React.FC = ({ conference, task }) => { thisInstanceShouldMonitor && Boolean(participants && conferenceSid) && status === 'active' && - participants.filter(p => p.status === 'joined').length > 2 && + (participants.filter(p => p.status === 'joined').length > 2 || postStudioFlows.voice) && participants.some(isJoinedWithEnd); const shouldEnableEndConferenceOnExit = ({ participants, conferenceSid, status }: Partial) => @@ -46,7 +48,8 @@ const ConferenceMonitor: React.FC = ({ conference, task }) => { Boolean(participants && conferenceSid) && status === 'active' && participants.filter(p => p.status === 'joined').length <= 2 && - participants.some(isJoinedWithoutEnd); + participants.some(isJoinedWithoutEnd) && + !postStudioFlows.voice; const updateEndConferenceOnExit = React.useCallback( (endConferenceOnExit: boolean) => async (participant: ConferenceParticipant) => { diff --git a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx index 11b9628e68..1d90250782 100644 --- a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx +++ b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx @@ -72,13 +72,13 @@ export const setUpConferenceActions = () => { Flex.Actions.addListener('beforeAcceptTask', payload => { if (getAseloFeatureFlags().enable_conference_status_event_handler) { + const { accountScopedLambdaBaseUrl, postStudioFlows } = getHrmConfig(); const { conferenceOptions } = payload; if (conferenceOptions) { - conferenceOptions.conferenceStatusCallback = `${ - getHrmConfig().accountScopedLambdaBaseUrl - }/conference/conferenceStatusCallback`; + conferenceOptions.conferenceStatusCallback = `${accountScopedLambdaBaseUrl}/conference/conferenceStatusCallback`; conferenceOptions.conferenceStatusCallbackMethod = 'POST'; - conferenceOptions.conferenceStatusCallbackEvent = 'leave'; + conferenceOptions.endConferenceOnExit = !postStudioFlows.voice; + conferenceOptions.conferenceStatusCallbackEvent = ['leave', 'join'].toString(); } } }); diff --git a/plugin-hrm-form/src/hrmConfig.ts b/plugin-hrm-form/src/hrmConfig.ts index 130a148549..8aa4886498 100644 --- a/plugin-hrm-form/src/hrmConfig.ts +++ b/plugin-hrm-form/src/hrmConfig.ts @@ -19,7 +19,7 @@ import { buildFormDefinitionsBaseUrlGetter } from 'hrm-form-definitions'; import type { RootState } from './states'; import { namespace } from './states/storeNamespaces'; -import { WorkerSID } from './types/twilio'; +import { StudioFlowSID, WorkerSID } from './types/twilio'; import { FeatureFlags } from './types/FeatureFlags'; const featureFlagEnvVarPrefix = 'REACT_APP_FF_'; @@ -101,6 +101,7 @@ const readConfig = () => { enableUnmaskingCalls, hideAddToNewCaseButton, enforceZeroTranscriptRetention, + postStudioFlows, } = { // Deprecated, remove when service configurations changes have applied 2025-09-30 ...manager.serviceConfiguration.attributes.config_flags, @@ -170,6 +171,7 @@ const readConfig = () => { enableClientProfiles, enableConferencing, hideAddToNewCaseButton, + postStudioFlows: (postStudioFlows ?? {}) as { [key in 'voice' | 'chat']?: StudioFlowSID }, }, referrableResources: { resourcesBaseUrl, diff --git a/plugin-hrm-form/src/types/twilio.ts b/plugin-hrm-form/src/types/twilio.ts index d95cb0a8db..14d589ce41 100644 --- a/plugin-hrm-form/src/types/twilio.ts +++ b/plugin-hrm-form/src/types/twilio.ts @@ -21,4 +21,5 @@ import { standaloneTaskSid } from './types'; export type AccountSID =`AC${string}`; export type WorkerSID = `WK${string}`; export type TaskSID = typeof standaloneTaskSid | `${'WT'|'offline-contact-task'}${string}`; +export type StudioFlowSID = `FW${string}` diff --git a/twilio-iac/helplines/as/configs/service-configuration/development.json b/twilio-iac/helplines/as/configs/service-configuration/development.json index 6d43e8f82d..767a1b5a40 100644 --- a/twilio-iac/helplines/as/configs/service-configuration/development.json +++ b/twilio-iac/helplines/as/configs/service-configuration/development.json @@ -34,7 +34,10 @@ "version": "2.2.0" }, "resources_base_url": "https://hrm-development.tl.techmatters.org", - "enforceZeroTranscriptRetention": false + "enforceZeroTranscriptRetention": false, + "postStudioFlows": { + "voice": "+12064083885" + } }, "ui_attributes": { "colorTheme": { diff --git a/twilio-iac/helplines/as/configs/service-configuration/production.json b/twilio-iac/helplines/as/configs/service-configuration/production.json index c8637be4ee..d80eea08fd 100644 --- a/twilio-iac/helplines/as/configs/service-configuration/production.json +++ b/twilio-iac/helplines/as/configs/service-configuration/production.json @@ -18,7 +18,8 @@ "enable_lex_v2": true }, "permissionConfig": "demo", - "resources_base_url": "https://hrm-production.tl.techmatters.org" + "resources_base_url": "https://hrm-production.tl.techmatters.org", + "" }, "ui_attributes": { "colorTheme": {