From 3e98956f989deadb405806aae786bf64bc2983b1 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 11 Jun 2026 11:49:28 +0100 Subject: [PATCH 01/50] WIP --- .../postStudioFlowTaskRouterListener.ts | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts new file mode 100644 index 0000000000..a399ddb803 --- /dev/null +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -0,0 +1,198 @@ +/** + * 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 { Twilio } from 'twilio'; +import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; +import { EventFields } from '../taskrouter'; +import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; +import { + handleChannelCapture, + HandleChannelCaptureParams, + isChatCaptureControlTask, +} from './channelCaptureHandlers'; +import { + getChatServiceSid, + getHelplineCode, + getSurveyWorkflowSid, + getTwilioWorkspaceSid, getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; +import { getTranslation } from '../translations/translationLookup'; + +const GLOBAL_DEFAULT_LANGUAGE = 'en-US'; + +// ================== // +// TODO: unify this code with Flex codebase +const getTaskLanguage = + (helplineLanguage: string) => (taskAttributes: { language?: string }) => + taskAttributes.language || helplineLanguage || GLOBAL_DEFAULT_LANGUAGE; +// ================== // + +// 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); +}; + +export const postSurveyInitHandler = async ({ + accountSid, + channelType, + chatServiceSid, + client, + environment, + helplineCode, + surveyWorkflowSid, + taskLanguage, + taskSid, + workspaceSid, + webhookBaseUrl, + channelSid, + conversationSid, +}: { + accountSid: AccountSID; + client: Twilio; + taskSid: string; + taskLanguage: string; + channelType: string; + environment: string; + webhookBaseUrl: string; + chatServiceSid: string; + helplineCode: string; + surveyWorkflowSid: string; + workspaceSid: string; +} & ( + | { + channelSid: string; + conversationSid?: string; + } + | { + channelSid?: string; + conversationSid: string; + } +)) => { + const triggerMessage = await getTranslation(accountSid, taskLanguage, 'triggerMessage'); + + const params: HandleChannelCaptureParams = { + accountSid, + channelSid, + conversationSid: conversationSid || '', + 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, + channelType, + chatServiceSid, + environment, + helplineCode, + surveyWorkflowSid, + workspaceSid, + webhookBaseUrl, + }; + + return handleChannelCapture(client, params); +}; + +const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( + event: EventFields, + accountSid: AccountSID, + client: Twilio, +) => { + try { + const { + EventType: eventType, + TaskChannelUniqueName: taskChannelUniqueName, + TaskSid: taskSid, + TaskAttributes: taskAttributesString, + } = event; + + const taskAttributes = JSON.parse(taskAttributesString); + + if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { + console.info('Handling post survey trigger...'); + console.info('taskAttributes', taskAttributes); + + // This task is a candidate to trigger post survey. Check feature flags for the account. + const serviceConfigAttributes = + await retrieveServiceConfigurationAttributes(client); + const { helplineLanguage, postStudioFlows } = serviceConfigAttributes; + const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; + + if (studioFlowSid) { + const { channelSid, conversationSid, channelType, customChannelType } = + taskAttributes; + + const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes); + + const environment = process.env.NODE_ENV!; + const webhookBaseUrl = process.env.WEBHOOK_BASE_URL!; + const chatServiceSid = await getChatServiceSid(accountSid); + const helplineCode = await getHelplineCode(accountSid); + const workspaceSid = await getWorkspaceSid(accountSid); + + await postSurveyInitHandler({ + channelSid, + conversationSid, + taskSid, + taskLanguage, + channelType: customChannelType || channelType, + accountSid, + chatServiceSid, + client, + environment, + helplineCode, + workspaceSid, + webhookBaseUrl, + }); + + console.info('Finished handling post studio flow trigger.'); + } else { + console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); + } + } + } catch (err) { + console.error('postSurveyListener failed', err); + } +}; + +registerTaskRouterEventHandler([TASK_WRAPUP], triggerPostStudioFlowTaskRouterListener); From 6e5eea14a5dd3a11744b2b54d68f20c1435e26c1 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 11 Jun 2026 20:35:23 +0100 Subject: [PATCH 02/50] WIP --- aselo-webchat-react-app/public/index.html | 1 - .../postStudioFlowTaskRouterListener.ts | 125 ++++-------------- .../stopRecordingWhenLastAgentLeaves.ts | 2 +- 3 files changed, 26 insertions(+), 102 deletions(-) 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/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index a399ddb803..407458a34e 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -29,10 +29,11 @@ import { isChatCaptureControlTask, } from './channelCaptureHandlers'; import { - getChatServiceSid, - getHelplineCode, - getSurveyWorkflowSid, - getTwilioWorkspaceSid, getWorkspaceSid, + getChatServiceSid, + getHelplineCode, + getSurveyWorkflowSid, + getTwilioWorkspaceSid, + getWorkspaceSid, } from '@tech-matters/twilio-configuration'; import { getTranslation } from '../translations/translationLookup'; @@ -65,74 +66,6 @@ const isTriggerPostStudioFlow = ({ return !isChatCaptureControlTask(taskAttributes); }; -export const postSurveyInitHandler = async ({ - accountSid, - channelType, - chatServiceSid, - client, - environment, - helplineCode, - surveyWorkflowSid, - taskLanguage, - taskSid, - workspaceSid, - webhookBaseUrl, - channelSid, - conversationSid, -}: { - accountSid: AccountSID; - client: Twilio; - taskSid: string; - taskLanguage: string; - channelType: string; - environment: string; - webhookBaseUrl: string; - chatServiceSid: string; - helplineCode: string; - surveyWorkflowSid: string; - workspaceSid: string; -} & ( - | { - channelSid: string; - conversationSid?: string; - } - | { - channelSid?: string; - conversationSid: string; - } -)) => { - const triggerMessage = await getTranslation(accountSid, taskLanguage, 'triggerMessage'); - - const params: HandleChannelCaptureParams = { - accountSid, - channelSid, - conversationSid: conversationSid || '', - 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, - channelType, - chatServiceSid, - environment, - helplineCode, - surveyWorkflowSid, - workspaceSid, - webhookBaseUrl, - }; - - return handleChannelCapture(client, params); -}; - const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( event: EventFields, accountSid: AccountSID, @@ -142,48 +75,40 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const { EventType: eventType, TaskChannelUniqueName: taskChannelUniqueName, - TaskSid: taskSid, TaskAttributes: taskAttributesString, } = event; const taskAttributes = JSON.parse(taskAttributesString); if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { - console.info('Handling post survey trigger...'); + console.info('Handling post studio flow trigger...'); console.info('taskAttributes', taskAttributes); // This task is a candidate to trigger post survey. Check feature flags for the account. const serviceConfigAttributes = await retrieveServiceConfigurationAttributes(client); - const { helplineLanguage, postStudioFlows } = serviceConfigAttributes; + const { postStudioFlows } = serviceConfigAttributes; const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; if (studioFlowSid) { - const { channelSid, conversationSid, channelType, customChannelType } = - taskAttributes; - - const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes); - - const environment = process.env.NODE_ENV!; - const webhookBaseUrl = process.env.WEBHOOK_BASE_URL!; - const chatServiceSid = await getChatServiceSid(accountSid); - const helplineCode = await getHelplineCode(accountSid); - const workspaceSid = await getWorkspaceSid(accountSid); - - await postSurveyInitHandler({ - channelSid, - conversationSid, - taskSid, - taskLanguage, - channelType: customChannelType || channelType, - accountSid, - chatServiceSid, - client, - environment, - helplineCode, - workspaceSid, - webhookBaseUrl, - }); + const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; + const { conferenceSid } = taskAttributes; + if (taskChannelUniqueName === 'voice') { + // 1. Fetch all active participants in the conference + const allParticipants = await client.conferences + .get(conferenceSid) + .participants.list(); + const connectedParticipants = allParticipants.filter( + p => p.status === 'connected', + ); + if (connectedParticipants.length === 1) { + const [participant] = connectedParticipants; + await client.calls.get(participant.callSid).update({ + url: studioWebhookUrl, + method: 'POST', + }); + } + } console.info('Finished handling post studio flow trigger.'); } else { diff --git a/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts b/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts index 5855cb0b60..3a89f397ff 100644 --- a/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts +++ b/lambdas/account-scoped/src/conference/stopRecordingWhenLastAgentLeaves.ts @@ -22,7 +22,7 @@ import { import type RestException from 'twilio/lib/base/RestException'; import { hasTaskControl } from '../transfer/hasTaskControl'; -const isAgentInConference = ({ +export const isAgentInConference = ({ callSid, customerCallSid, participant, From 99ff99fb59811a6ae21f23221052a0d437247b63 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 15 Jun 2026 11:50:55 +0100 Subject: [PATCH 03/50] WIP post survey calls --- .../postStudioFlowTaskRouterListener.ts | 23 +------ .../conference/conferenceStatusCallback.ts | 4 +- .../src/conference/isAgentInConference.ts | 60 +++++++++++++++++++ .../src/conference/setEndConferenceOnExit.ts | 57 ++++++++++++++++++ .../stopRecordingWhenLastAgentLeaves.ts | 50 +--------------- lambdas/account-scoped/src/router.ts | 1 + .../account-scoped/src/taskrouter/index.ts | 1 + .../src/conference/setUpConferenceActions.tsx | 3 +- plugin-hrm-form/src/hrmConfig.ts | 4 +- plugin-hrm-form/src/types/twilio.ts | 1 + .../service-configuration/development.json | 5 +- .../service-configuration/production.json | 3 +- 12 files changed, 137 insertions(+), 75 deletions(-) create mode 100644 lambdas/account-scoped/src/conference/isAgentInConference.ts create mode 100644 lambdas/account-scoped/src/conference/setEndConferenceOnExit.ts diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 407458a34e..833bae8732 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -23,28 +23,7 @@ import { Twilio } from 'twilio'; import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; -import { - handleChannelCapture, - HandleChannelCaptureParams, - isChatCaptureControlTask, -} from './channelCaptureHandlers'; -import { - getChatServiceSid, - getHelplineCode, - getSurveyWorkflowSid, - getTwilioWorkspaceSid, - getWorkspaceSid, -} from '@tech-matters/twilio-configuration'; -import { getTranslation } from '../translations/translationLookup'; - -const GLOBAL_DEFAULT_LANGUAGE = 'en-US'; - -// ================== // -// TODO: unify this code with Flex codebase -const getTaskLanguage = - (helplineLanguage: string) => (taskAttributes: { language?: string }) => - taskAttributes.language || helplineLanguage || GLOBAL_DEFAULT_LANGUAGE; -// ================== // +import { isChatCaptureControlTask } from './channelCaptureHandlers'; // TODO: factor out type TransferMeta = { 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 3a89f397ff..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'; - -export 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/router.ts b/lambdas/account-scoped/src/router.ts index ae6b846509..6fb1a28caa 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'; 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/plugin-hrm-form/src/conference/setUpConferenceActions.tsx b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx index 11b9628e68..d74bfc87c2 100644 --- a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx +++ b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx @@ -78,7 +78,8 @@ export const setUpConferenceActions = () => { getHrmConfig().accountScopedLambdaBaseUrl }/conference/conferenceStatusCallback`; conferenceOptions.conferenceStatusCallbackMethod = 'POST'; - conferenceOptions.conferenceStatusCallbackEvent = 'leave'; + conferenceOptions.endConferenceOnExit = !getHrmConfig().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..46f2fe642c 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": "FW7345ce250648845e9869f59df2c9c961" + } }, "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": { From 8444c993244e88bcaf452ee44488948fdc4115d8 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 15 Jun 2026 14:48:52 +0100 Subject: [PATCH 04/50] Fix post studio flow for voice logic and add log --- .../postStudioFlowTaskRouterListener.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 833bae8732..784e7007aa 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -71,11 +71,11 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( if (studioFlowSid) { const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; - const { conferenceSid } = taskAttributes; - if (taskChannelUniqueName === 'voice') { + const { conference } = taskAttributes; + if (taskChannelUniqueName === 'voice' && conference) { // 1. Fetch all active participants in the conference const allParticipants = await client.conferences - .get(conferenceSid) + .get(conference.sid) .participants.list(); const connectedParticipants = allParticipants.filter( p => p.status === 'connected', @@ -87,6 +87,12 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( method: 'POST', }); } + } else { + console.warn( + `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('Finished handling post studio flow trigger.'); From a701f9deb7ff59172a12d3e70f5d1a640a9e0dae Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 15 Jun 2026 16:08:30 +0100 Subject: [PATCH 05/50] Add logs to post studio flow for voice logic --- .../postStudioFlowTaskRouterListener.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 784e7007aa..40cf21b13b 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -60,8 +60,10 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const taskAttributes = JSON.parse(taskAttributesString); if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { - console.info('Handling post studio flow trigger...'); - console.info('taskAttributes', taskAttributes); + console.info( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: 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 = @@ -77,6 +79,9 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const allParticipants = await client.conferences .get(conference.sid) .participants.list(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + ); const connectedParticipants = allParticipants.filter( p => p.status === 'connected', ); @@ -89,19 +94,24 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( } } else { console.warn( - `Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: 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('Finished handling post studio flow trigger.'); + console.info( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Finished handling post studio flow trigger.`, + ); } else { console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); } } } catch (err) { - console.error('postSurveyListener failed', err); + console.error( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: triggerPostStudioFlowTaskRouterListener failed`, + err, + ); } }; From d4ee435d7a02053826ec942776a5edfc2862fc82 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 15 Jun 2026 16:20:57 +0100 Subject: [PATCH 06/50] Add logs to post studio flow for voice logic --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 40cf21b13b..e0718b3d9b 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -81,16 +81,24 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( .participants.list(); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + allParticipants, ); const connectedParticipants = allParticipants.filter( p => p.status === 'connected', ); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + connectedParticipants, + ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; await client.calls.get(participant.callSid).update({ url: studioWebhookUrl, method: 'POST', }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, + ); } } else { console.warn( From 121a5bca27e416099efbdbe97fa39edbad57f3c2 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 15 Jun 2026 17:31:12 +0100 Subject: [PATCH 07/50] Update conference monitor to not end conference on exit if post flow is set --- .../src/components/Conference/ConferenceMonitor/index.tsx | 7 +++++-- plugin-hrm-form/src/conference/setUpConferenceActions.tsx | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) 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 d74bfc87c2..1d90250782 100644 --- a/plugin-hrm-form/src/conference/setUpConferenceActions.tsx +++ b/plugin-hrm-form/src/conference/setUpConferenceActions.tsx @@ -72,13 +72,12 @@ 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.endConferenceOnExit = !getHrmConfig().postStudioFlows.voice; + conferenceOptions.endConferenceOnExit = !postStudioFlows.voice; conferenceOptions.conferenceStatusCallbackEvent = ['leave', 'join'].toString(); } } From 6b45516d9477c74083d3986592c2691bec73aa9e Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 08:59:30 +0100 Subject: [PATCH 08/50] Extra logging for post studio flow setup --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index e0718b3d9b..37ea9fc5a5 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -92,6 +92,11 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; + const call = await client.calls.get(participant.callSid).fetch(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${call.sid} on conference: ${conference.sid} at ${eventType}.`, + call, + ); await client.calls.get(participant.callSid).update({ url: studioWebhookUrl, method: 'POST', From 1ddb1b3a0ca39fb16d53feaacf70469064ad5d01 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 10:09:59 +0100 Subject: [PATCH 09/50] Complete conference on post survey flow --- .../postStudioFlowTaskRouterListener.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 37ea9fc5a5..790a326961 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -55,13 +55,14 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( EventType: eventType, TaskChannelUniqueName: taskChannelUniqueName, TaskAttributes: taskAttributesString, + TaskSid: taskSid, } = event; const taskAttributes = JSON.parse(taskAttributesString); if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { console.info( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Handling post studio flow trigger...`, + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Handling post studio flow trigger...`, ); console.debug('[SENSITIVE] taskAttributes', taskAttributes); @@ -75,27 +76,27 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; const { conference } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { + const conferenceContext = client.conferences.get(conference.sid); // 1. Fetch all active participants in the conference - const allParticipants = await client.conferences - .get(conference.sid) - .participants.list(); + const allParticipants = await conferenceContext.participants.list(); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, allParticipants, ); const connectedParticipants = allParticipants.filter( p => p.status === 'connected', ); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, connectedParticipants, ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - const call = await client.calls.get(participant.callSid).fetch(); + await conferenceContext.update({ + status: 'completed', + }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: ${call.sid} on conference: ${conference.sid} at ${eventType}.`, - call, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Completed conference: ${conference.sid} at ${eventType}.`, ); await client.calls.get(participant.callSid).update({ url: studioWebhookUrl, From 30e411dd82328b0dc351add1fcd2156c01065396 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 10:34:35 +0100 Subject: [PATCH 10/50] Try redirecting using twilml --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 790a326961..39646e7a5d 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -92,15 +92,8 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - await conferenceContext.update({ - status: 'completed', - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Completed conference: ${conference.sid} at ${eventType}.`, - ); await client.calls.get(participant.callSid).update({ - url: studioWebhookUrl, - method: 'POST', + twiml: `${studioWebhookUrl}`, }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, From 10699515b268a2a86ca24d14f82c7d057188a93d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 11:23:30 +0100 Subject: [PATCH 11/50] Try redirecting parent call using twilml --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 39646e7a5d..b8d079e9f5 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -92,11 +92,17 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - await client.calls.get(participant.callSid).update({ + const call = await client.calls.get(participant.callSid).fetch(); + + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Found call ${call.sid} conference ${conference.sid}.`, + call, + ); + await client.calls.get(call.parentCallSid).update({ twiml: `${studioWebhookUrl}`, }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${call.parentCallSid} webhook to ${studioWebhookUrl}.`, ); } } else { From 9b02e7029e27aeb0253f1f86bb06ab5869ee1360 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 11:35:16 +0100 Subject: [PATCH 12/50] Fix log --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index b8d079e9f5..b61377341b 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -102,7 +102,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( twiml: `${studioWebhookUrl}`, }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${call.parentCallSid} webhook to ${studioWebhookUrl}.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated call ${call.parentCallSid} webhook to ${studioWebhookUrl}.`, ); } } else { From 85ce7960ae3d1547fa52d620483de9bedd1dc3f9 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 15:14:25 +0100 Subject: [PATCH 13/50] Try redirecting from reservation --- .../postStudioFlowTaskRouterListener.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index b61377341b..0a241c0ec6 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -24,6 +24,7 @@ import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; +import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; // TODO: factor out type TransferMeta = { @@ -92,17 +93,22 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - const call = await client.calls.get(participant.callSid).fetch(); - - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Found call ${call.sid} conference ${conference.sid}.`, - call, - ); - await client.calls.get(call.parentCallSid).update({ - twiml: `${studioWebhookUrl}`, - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated call ${call.parentCallSid} webhook to ${studioWebhookUrl}.`, + const taskReservations = await client.taskrouter.v1.workspaces + .get(await getWorkspaceSid(accountSid)) + .tasks.get(taskSid) + .reservations.list(); + await Promise.all( + taskReservations.map(tr => { + tr.update({ + redirectUrl: studioWebhookUrl, + reservationStatus: 'wrapping', + instruction: 'redirect', + redirectCallSid: participant.callSid, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated reservation ${tr.sid} to redirect to to ${studioWebhookUrl}.`, + ); + }), ); } } else { From 78bd0c1582f82837ea2e9ea16a5306621ba52f48 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 16 Jun 2026 17:11:57 +0100 Subject: [PATCH 14/50] Try redirecting using a TwilML bin --- .../postStudioFlowTaskRouterListener.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 0a241c0ec6..c84d94c176 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -24,7 +24,6 @@ import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; -import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; // TODO: factor out type TransferMeta = { @@ -74,7 +73,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; if (studioFlowSid) { - const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; + // const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; const { conference } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); @@ -93,24 +92,19 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - const taskReservations = await client.taskrouter.v1.workspaces - .get(await getWorkspaceSid(accountSid)) - .tasks.get(taskSid) - .reservations.list(); - await Promise.all( - taskReservations.map(tr => { - tr.update({ - redirectUrl: studioWebhookUrl, - reservationStatus: 'wrapping', - instruction: 'redirect', - redirectCallSid: participant.callSid, - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated reservation ${tr.sid} to redirect to to ${studioWebhookUrl}.`, - ); - }), + await participant.update({ + announceMethod: 'POST', + announceUrl: `https://handler.twilio.com/twiml/EH8e271fa47b075f55eb20893823b174d3`, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Participant ${participant.callSid} on conference: ${conference.sid} at ${eventType} updated with redirect announceUrl.`, + connectedParticipants, ); } + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Not redirecting because we only .`, + connectedParticipants, + ); } else { console.warn( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, From 4dbac667cb0077a02f5deea71e1d4995c98a6045 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 10:55:22 +0100 Subject: [PATCH 15/50] Try putting the participant on hold before removing from the conference --- .../postStudioFlowTaskRouterListener.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index c84d94c176..79acd986ce 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -24,6 +24,10 @@ import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; +import { + getSurveyWorkflowSid, + getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; // TODO: factor out type TransferMeta = { @@ -73,7 +77,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; if (studioFlowSid) { - // const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; + const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; const { conference } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); @@ -93,18 +97,27 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; await participant.update({ - announceMethod: 'POST', - announceUrl: `https://handler.twilio.com/twiml/EH8e271fa47b075f55eb20893823b174d3`, + hold: true, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, + ); + await participant.remove(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, + ); + await client.calls.get(participant.callSid).update({ + url: studioWebhookUrl, + method: 'POST', }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Participant ${participant.callSid} on conference: ${conference.sid} at ${eventType} updated with redirect announceUrl.`, - connectedParticipants, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, + ); + } else { + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only valid for redirecting to studio flow if there is only one connected participant on the conference`, ); } - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Not redirecting because we only .`, - connectedParticipants, - ); } else { console.warn( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, From 6a3ed8e5c8a49c7a06e5f69607122d6cdff0898a Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 11:16:10 +0100 Subject: [PATCH 16/50] Try putting the participant on hold before removing from the conference and using Twilml --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 79acd986ce..9661c126c0 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -107,8 +107,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, ); await client.calls.get(participant.callSid).update({ - url: studioWebhookUrl, - method: 'POST', + twiml: `${studioWebhookUrl}` }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, From cde3a65bf9ee7e1572b9603b4b582c77a222ed28 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 11:16:28 +0100 Subject: [PATCH 17/50] Try putting the participant on hold before removing from the conference and using Twilml --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 9661c126c0..9da3b2621b 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -107,7 +107,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, ); await client.calls.get(participant.callSid).update({ - twiml: `${studioWebhookUrl}` + twiml: `${studioWebhookUrl}`, }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, From e1b620ffa2dec0c10374574e908738b78b71a281 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 14:28:55 +0100 Subject: [PATCH 18/50] Remove participant AFTER setting redirect --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 9da3b2621b..0027feeb95 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -102,16 +102,16 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); - await participant.remove(); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, - ); await client.calls.get(participant.callSid).update({ twiml: `${studioWebhookUrl}`, }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, ); + await participant.remove(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, + ); } else { console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only valid for redirecting to studio flow if there is only one connected participant on the conference`, From ba6ed35184eebf77f1240f9724fae3907eecce06 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 14:30:12 +0100 Subject: [PATCH 19/50] Remove participant AFTER setting redirect --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 0027feeb95..0c4cae855e 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -110,7 +110,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); await participant.remove(); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, ); } else { console.debug( From c0a420b66225c91cbfc583113eff1f6315f1e8b9 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 17 Jun 2026 16:49:41 +0100 Subject: [PATCH 20/50] Use twilml URL --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 0c4cae855e..0334bea780 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -24,10 +24,6 @@ import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; -import { - getSurveyWorkflowSid, - getWorkspaceSid, -} from '@tech-matters/twilio-configuration'; // TODO: factor out type TransferMeta = { @@ -103,7 +99,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); await client.calls.get(participant.callSid).update({ - twiml: `${studioWebhookUrl}`, + url: 'https://handler.twilio.com/twiml/EH8e271fa47b075f55eb20893823b174d3', }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, From a5ba182be071fd7224c4af2c33ff60f594abad81 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 18 Jun 2026 06:45:38 +0100 Subject: [PATCH 21/50] Dial studio flow --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 0334bea780..d7dc42d0a6 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -99,10 +99,10 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); await client.calls.get(participant.callSid).update({ - url: 'https://handler.twilio.com/twiml/EH8e271fa47b075f55eb20893823b174d3', + twiml: `+1 206 408 3885`, }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated conference ${conference.sid} webhook to ${studioWebhookUrl}.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Dialed +1 206 408 3885 to start post survey.`, ); await participant.remove(); console.debug( From 9bcfc5eba0e9ea5e8ef26662a20313fd57de281f Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 18 Jun 2026 07:37:12 +0100 Subject: [PATCH 22/50] Dial studio flow --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index d7dc42d0a6..f454f9f178 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -99,7 +99,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); await client.calls.get(participant.callSid).update({ - twiml: `+1 206 408 3885`, + twiml: `+1 206 408 3885`, }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Dialed +1 206 408 3885 to start post survey.`, From 97134e270e5f4e494f1392a85812d7c031474171 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 18 Jun 2026 08:53:43 +0100 Subject: [PATCH 23/50] Add contact ID to dial --- .../postStudioFlowTaskRouterListener.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index f454f9f178..3a2b39fdeb 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -19,11 +19,12 @@ import { TaskRouterEventHandler, } from '../taskrouter/taskrouterEventHandler'; import { AccountSID } from '@tech-matters/twilio-types'; -import { Twilio } from 'twilio'; +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 = { @@ -73,8 +74,8 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; if (studioFlowSid) { - const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}`; - const { conference } = taskAttributes; + const { conference, contactId } = taskAttributes; + const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}?contactId=${contactId}`; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); // 1. Fetch all active participants in the conference @@ -98,8 +99,16 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); + const twiml = new VoiceResponse(); + twiml.dial( + { + action: studioWebhookUrl, + method: 'POST', + }, + '+1 206 408 3885', + ); await client.calls.get(participant.callSid).update({ - twiml: `+1 206 408 3885`, + twiml, }); console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Dialed +1 206 408 3885 to start post survey.`, From 2bc40a58e787332ce6b4936367a4990809eb2c16 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 18 Jun 2026 13:24:09 +0100 Subject: [PATCH 24/50] Add contact ID to inline twiml --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 3a2b39fdeb..6eb89e3d51 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -100,13 +100,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); const twiml = new VoiceResponse(); - twiml.dial( - { - action: studioWebhookUrl, - method: 'POST', - }, - '+1 206 408 3885', - ); + twiml.say(`Hello! Welcome ${contactId}`); await client.calls.get(participant.callSid).update({ twiml, }); From cb633c4129a8c81126befe6a8abe75283ba4463b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 18 Jun 2026 20:44:33 +0100 Subject: [PATCH 25/50] Reorganised post survey code a bit. Added mechanism to stash contact ID and task SID in sync doc keyed by phone number --- .../channelCapture/channelCaptureHandlers.ts | 117 ++-------- .../src/channelCapture/hrmDataManipulation.ts | 61 ------ .../postStudioFlowTaskRouterListener.ts | 49 ++++- .../account-scoped/src/hrm/savePostSurvey.ts | 199 ++++++++++++++++++ lambdas/account-scoped/src/router.ts | 5 + 5 files changed, 264 insertions(+), 167 deletions(-) delete mode 100644 lambdas/account-scoped/src/channelCapture/hrmDataManipulation.ts create mode 100644 lambdas/account-scoped/src/hrm/savePostSurvey.ts diff --git a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts index 24cd8a8acd..b31b14c774 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 - .workspaces(twilioWorkspaceSid) - .tasks(capturedChannelAttributes.controlTaskSid) - .fetch(); + const controlTaskContext = twilioClient.taskrouter.v1 + .workspaces(twilioWorkspaceSid) + .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 index 6eb89e3d51..efcf05875a 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -25,6 +25,9 @@ import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; import VoiceResponse = TwilioSDK.twiml.VoiceResponse; +import { getSyncServiceSid } from '@tech-matters/twilio-configuration'; +import { getPostSurveySyncDocUniqueName } from '../hrm/savePostSurvey'; +import RestException from 'twilio/lib/base/RestException'; // TODO: factor out type TransferMeta = { @@ -75,7 +78,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( if (studioFlowSid) { const { conference, contactId } = taskAttributes; - const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}?contactId=${contactId}`; + const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}?`; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); // 1. Fetch all active participants in the conference @@ -99,8 +102,50 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); + const { from } = await client.calls.get(participant.callSid).fetch(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Retrieved call ${participant.callSid} from conference ${conference.sid}.`, + ); + const uniqueName = getPostSurveySyncDocUniqueName(from); + const docList = client.sync.v1.services.get( + await getSyncServiceSid(accountSid), + ).documents; + try { + await docList.get(uniqueName).remove(); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed existing sync document ${uniqueName}.`, + ); + } catch (err) { + if ((err as RestException).status === 404) { + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: No existing sync document ${uniqueName} to remove.`, + ); + } else { + console.error( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Error removing sync document ${uniqueName}`, + err, + ); + } + } + await docList.create({ + uniqueName, + data: { + taskSid, + contactId, + }, + ttl: 24 * 60 * 60, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, + ); const twiml = new VoiceResponse(); - twiml.say(`Hello! Welcome ${contactId}`); + twiml.dial( + { + action: studioWebhookUrl, + method: 'POST', + }, + '+1 206 408 3885', + ); await client.calls.get(participant.callSid).update({ twiml, }); diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts new file mode 100644 index 0000000000..4f76e9d370 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -0,0 +1,199 @@ +/** + * 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 { newOk, Result } from '../Result'; +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'; +import { + getSurveyWorkflowSid, + getSyncServiceSid, + getTwilioClient, + getWorkspaceSid, +} from '@tech-matters/twilio-configuration'; + +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}`); + } +}; + +export const getPostSurveySyncDocUniqueName = (callerIdentifier: string) => + `/post-surveys/pending/${callerIdentifier}`; + +export const handleSavePostSurvey: AccountScopedHandler = async ( + request, + accountSid, +): Promise> => { + const { postSurveyAnswers, clientIdentifier } = request.body; + const twilioClient = await getTwilioClient(accountSid); + const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); + const docContext = twilioClient.sync.v1.services + .get(await getSyncServiceSid(accountSid)) + .documents.get(docUniqueName); + const doc = await docContext.fetch(); + const { taskSid, contactId } = doc.data; + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Retrieved contactId ${contactId} and taskSid ${taskSid} from sync doc ${docUniqueName}`, + ); + 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 + await controlTask.update({ assignmentStatus: 'canceled' }); + await docContext.remove(); + return newOk(undefined); +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 6fb1a28caa..376db7ab58 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -75,6 +75,7 @@ import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRu import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; +import {handleSavePostSurvey} from "./hrm/savePostSurvey"; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -96,6 +97,10 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateWebhookRequest], handler: handleTaskRouterEvent, }, + 'hrm/savePostSurvey': { + requestPipeline: [validateWebhookRequest], + handler: handleSavePostSurvey, + }, getProfileFlagsForIdentifier: { requestPipeline: [validateWebhookRequest], handler: handleGetProfileFlagsForIdentifier, From 62e86c68bd4aaca6f74c41eb22196cc8a276a8ce Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 09:34:30 +0100 Subject: [PATCH 26/50] Changed sync doc creation from remove-then-create to upsert --- .../postStudioFlowTaskRouterListener.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index efcf05875a..8320d9587c 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -111,14 +111,31 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( await getSyncServiceSid(accountSid), ).documents; try { - await docList.get(uniqueName).remove(); + await docList.get(uniqueName).update({ + data: { + taskSid, + contactId, + }, + ttl: 24 * 60 * 60, + }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed existing sync document ${uniqueName}.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated existing sync document ${uniqueName}.`, ); } catch (err) { if ((err as RestException).status === 404) { console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: No existing sync document ${uniqueName} to remove.`, + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: No existing sync document ${uniqueName} to update.`, + ); + await docList.create({ + uniqueName, + data: { + taskSid, + contactId, + }, + ttl: 24 * 60 * 60, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, ); } else { console.error( @@ -127,17 +144,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); } } - await docList.create({ - uniqueName, - data: { - taskSid, - contactId, - }, - ttl: 24 * 60 * 60, - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, - ); + const twiml = new VoiceResponse(); twiml.dial( { From 64cb57762aee8b1fc9cacaa4029089da8aab0e7f Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 09:44:18 +0100 Subject: [PATCH 27/50] Tidy up logs, remove participant from conference on error --- .../postStudioFlowTaskRouterListener.ts | 122 +++++++++--------- 1 file changed, 58 insertions(+), 64 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 8320d9587c..568d4639b9 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -54,6 +54,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( accountSid: AccountSID, client: Twilio, ) => { + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]:`; try { const { EventType: eventType, @@ -65,9 +66,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const taskAttributes = JSON.parse(taskAttributesString); if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) { - console.info( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Handling post studio flow trigger...`, - ); + 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. @@ -78,56 +77,40 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( if (studioFlowSid) { const { conference, contactId } = taskAttributes; - const studioWebhookUrl = `https://webhooks.twilio.com/v1/Accounts/${accountSid}/Flows/${studioFlowSid}?`; 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( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + `${logPrefix} ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, allParticipants, ); const connectedParticipants = allParticipants.filter( p => p.status === 'connected', ); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, + `${logPrefix} ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, connectedParticipants, ); if (connectedParticipants.length === 1) { const [participant] = connectedParticipants; - await participant.update({ - hold: true, - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, - ); - const { from } = await client.calls.get(participant.callSid).fetch(); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Retrieved call ${participant.callSid} from conference ${conference.sid}.`, - ); - const uniqueName = getPostSurveySyncDocUniqueName(from); - const docList = client.sync.v1.services.get( - await getSyncServiceSid(accountSid), - ).documents; try { - await docList.get(uniqueName).update({ - data: { - taskSid, - contactId, - }, - ttl: 24 * 60 * 60, + await participant.update({ + hold: true, }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Updated existing sync document ${uniqueName}.`, + `${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); - } catch (err) { - if ((err as RestException).status === 404) { - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: No existing sync document ${uniqueName} to update.`, - ); - await docList.create({ - uniqueName, + const { from } = await client.calls.get(participant.callSid).fetch(); + console.debug( + `${logPrefix} Retrieved call ${participant.callSid} from conference ${conference.sid}.`, + ); + const uniqueName = getPostSurveySyncDocUniqueName(from); + const docList = client.sync.v1.services.get( + await getSyncServiceSid(accountSid), + ).documents; + try { + await docList.get(uniqueName).update({ data: { taskSid, contactId, @@ -135,50 +118,61 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ttl: 24 * 60 * 60, }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, - ); - } else { - console.error( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Error removing sync document ${uniqueName}`, - err, + `${logPrefix} Updated existing sync document ${uniqueName}.`, ); + } catch (err) { + if ((err as RestException).status === 404) { + console.debug( + `${logPrefix} No existing sync document ${uniqueName} to update.`, + ); + await docList.create({ + uniqueName, + data: { + taskSid, + contactId, + }, + ttl: 24 * 60 * 60, + }); + console.debug( + `${logPrefix} Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, + ); + } else { + console.error( + `${logPrefix} Error updating sync document ${uniqueName}`, + err, + ); + } } + const twiml = new VoiceResponse(); + twiml.dial('+1 206 408 3885'); + await client.calls.get(participant.callSid).update({ + twiml, + }); + console.debug(`${logPrefix} Dialed +1 206 408 3885 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, + ); } - - const twiml = new VoiceResponse(); - twiml.dial( - { - action: studioWebhookUrl, - method: 'POST', - }, - '+1 206 408 3885', - ); - await client.calls.get(participant.callSid).update({ - twiml, - }); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Dialed +1 206 408 3885 to start post survey.`, - ); - await participant.remove(); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Removed participant ${participant.callSid} from conference ${conference.sid}.`, - ); } else { console.debug( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only valid for redirecting to studio flow if there is only one connected participant on the conference`, + `${logPrefix} Only valid for redirecting to studio flow if there is only one connected participant on the conference`, ); } } else { console.warn( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, + `${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( - `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: Finished handling post studio flow trigger.`, - ); + console.info(`${logPrefix} Finished handling post studio flow trigger.`); } else { console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); } From 785cbad696550d33c46c675a3900c357e343bc73 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 10:22:58 +0100 Subject: [PATCH 28/50] savePostSurvey logging --- lambdas/account-scoped/src/hrm/savePostSurvey.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts index 4f76e9d370..cf37c41514 100644 --- a/lambdas/account-scoped/src/hrm/savePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -167,6 +167,9 @@ export const handleSavePostSurvey: AccountScopedHandler = async ( const { postSurveyAnswers, clientIdentifier } = request.body; const twilioClient = await getTwilioClient(accountSid); const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Looking up sync doc ${docUniqueName}`, + ); const docContext = twilioClient.sync.v1.services .get(await getSyncServiceSid(accountSid)) .documents.get(docUniqueName); @@ -193,6 +196,9 @@ export const handleSavePostSurvey: AccountScopedHandler = async ( 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(); return newOk(undefined); From f74b93b6977d6741763419984a30d4fc1e7a5124 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 10:40:33 +0100 Subject: [PATCH 29/50] Remove slashes and pluses from unique doc names --- lambdas/account-scoped/src/hrm/savePostSurvey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts index cf37c41514..b183302a8a 100644 --- a/lambdas/account-scoped/src/hrm/savePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -158,7 +158,7 @@ export const savePostSurvey = async ({ }; export const getPostSurveySyncDocUniqueName = (callerIdentifier: string) => - `/post-surveys/pending/${callerIdentifier}`; + `post-surveys-pending-${callerIdentifier.replaceAll('+', '')}`; export const handleSavePostSurvey: AccountScopedHandler = async ( request, From 40d8e80b6bdb964f9c1824b8d1bb639ec4b98520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:16:25 +0000 Subject: [PATCH 30/50] Fix twilio mock in taskrouterEventHandler test to include twiml.VoiceResponse Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../tests/unit/taskrouter/taskrouterEventHandler.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From ed5e1e20a89b30ad0e9d1c6265478493501e4a82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:26:09 +0000 Subject: [PATCH 31/50] Fix setUpConferenceActions test: add postStudioFlows to mock and update conferenceStatusCallbackEvent assertion Co-authored-by: stephenhand <1694716+stephenhand@users.noreply.github.com> --- .../src/___tests__/conference/setUpConferenceActions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts b/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts index 588669fc61..a2dd4bf0d1 100644 --- a/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts +++ b/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts @@ -70,7 +70,7 @@ 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 +92,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 () => { From 5348441613d95692d9df9c47d42acef41e0771b5 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 11:55:13 +0100 Subject: [PATCH 32/50] Linter --- .../src/channelCapture/channelCaptureHandlers.ts | 4 ++-- lambdas/account-scoped/src/router.ts | 2 +- .../src/___tests__/conference/setUpConferenceActions.test.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts index b31b14c774..c03b7b919e 100644 --- a/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts +++ b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts @@ -627,8 +627,8 @@ export const handleChannelRelease = async ({ try { // get the control task const controlTaskContext = twilioClient.taskrouter.v1 - .workspaces(twilioWorkspaceSid) - .tasks(capturedChannelAttributes.controlTaskSid); + .workspaces(twilioWorkspaceSid) + .tasks(capturedChannelAttributes.controlTaskSid); // get the control task const controlTask = await controlTaskContext.fetch(); diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 376db7ab58..1f4cc049e3 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -75,7 +75,7 @@ import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRu import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; -import {handleSavePostSurvey} from "./hrm/savePostSurvey"; +import { handleSavePostSurvey } from './hrm/savePostSurvey'; /** * Super simple router sufficient for directly ported Twilio Serverless functions diff --git a/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts b/plugin-hrm-form/src/___tests__/conference/setUpConferenceActions.test.ts index a2dd4bf0d1..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', postStudioFlows: {} } as any); + mockGetHrmConfig.mockReturnValue({ + accountScopedLambdaBaseUrl: 'https://lambda.example.com', + postStudioFlows: {}, + } as any); captureListeners(); }); From 9057132a975379f5ebffad32a44f93cfea3dd642 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 12:13:09 +0100 Subject: [PATCH 33/50] Pass phone number as post studio flow identifier --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 10 ++++++---- .../as/configs/service-configuration/development.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 568d4639b9..30d64be71d 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -73,9 +73,9 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const serviceConfigAttributes = await retrieveServiceConfigurationAttributes(client); const { postStudioFlows } = serviceConfigAttributes; - const studioFlowSid = postStudioFlows?.[taskChannelUniqueName]; + const studioFlowIdentifier: string = postStudioFlows?.[taskChannelUniqueName] ?? ''; - if (studioFlowSid) { + if (studioFlowIdentifier.startsWith('+')) { const { conference, contactId } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); @@ -144,11 +144,13 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( } } const twiml = new VoiceResponse(); - twiml.dial('+1 206 408 3885'); + twiml.dial(studioFlowIdentifier); await client.calls.get(participant.callSid).update({ twiml, }); - console.debug(`${logPrefix} Dialed +1 206 408 3885 to start post survey.`); + console.debug( + `${logPrefix} Dialed ${studioFlowIdentifier} to start post survey.`, + ); } catch (err) { await participant.remove(); console.debug( diff --git a/twilio-iac/helplines/as/configs/service-configuration/development.json b/twilio-iac/helplines/as/configs/service-configuration/development.json index 46f2fe642c..767a1b5a40 100644 --- a/twilio-iac/helplines/as/configs/service-configuration/development.json +++ b/twilio-iac/helplines/as/configs/service-configuration/development.json @@ -36,7 +36,7 @@ "resources_base_url": "https://hrm-development.tl.techmatters.org", "enforceZeroTranscriptRetention": false, "postStudioFlows": { - "voice": "FW7345ce250648845e9869f59df2c9c961" + "voice": "+12064083885" } }, "ui_attributes": { From a029fa201b48fb2a3022c08b9b84b77abba7f139 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 10:54:11 +0100 Subject: [PATCH 34/50] Post survey flow fixes --- .../postStudioFlowTaskRouterListener.ts | 55 +---- .../account-scoped/src/hrm/savePostSurvey.ts | 55 ----- .../account-scoped/src/hrm/voicePostSurvey.ts | 98 +++++++++ lambdas/account-scoped/src/router.ts | 200 ++++++++++++------ .../account-scoped/src/validation/method.ts | 13 +- 5 files changed, 254 insertions(+), 167 deletions(-) create mode 100644 lambdas/account-scoped/src/hrm/voicePostSurvey.ts diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 30d64be71d..9a6403103e 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -25,9 +25,6 @@ import { EventFields } from '../taskrouter'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; import { isChatCaptureControlTask } from './channelCaptureHandlers'; import VoiceResponse = TwilioSDK.twiml.VoiceResponse; -import { getSyncServiceSid } from '@tech-matters/twilio-configuration'; -import { getPostSurveySyncDocUniqueName } from '../hrm/savePostSurvey'; -import RestException from 'twilio/lib/base/RestException'; // TODO: factor out type TransferMeta = { @@ -101,50 +98,16 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); - const { from } = await client.calls.get(participant.callSid).fetch(); - console.debug( - `${logPrefix} Retrieved call ${participant.callSid} from conference ${conference.sid}.`, - ); - const uniqueName = getPostSurveySyncDocUniqueName(from); - const docList = client.sync.v1.services.get( - await getSyncServiceSid(accountSid), - ).documents; - try { - await docList.get(uniqueName).update({ - data: { - taskSid, - contactId, - }, - ttl: 24 * 60 * 60, - }); - console.debug( - `${logPrefix} Updated existing sync document ${uniqueName}.`, - ); - } catch (err) { - if ((err as RestException).status === 404) { - console.debug( - `${logPrefix} No existing sync document ${uniqueName} to update.`, - ); - await docList.create({ - uniqueName, - data: { - taskSid, - contactId, - }, - ttl: 24 * 60 * 60, - }); - console.debug( - `${logPrefix} Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`, - ); - } else { - console.error( - `${logPrefix} Error updating sync document ${uniqueName}`, - err, - ); - } - } const twiml = new VoiceResponse(); - twiml.dial(studioFlowIdentifier); + const { hrm_base_url: hrmBaseUrl } = + await retrieveServiceConfigurationAttributes(client); + twiml.dial( + { + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskId=${taskSid}`, + method: 'GET', + }, + studioFlowIdentifier, + ); await client.calls.get(participant.callSid).update({ twiml, }); diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts index b183302a8a..4a2ff068e9 100644 --- a/lambdas/account-scoped/src/hrm/savePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -14,8 +14,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { AccountScopedHandler, HttpError } from '../httpTypes'; -import { newOk, Result } from '../Result'; import { LegacyOneToManyConfigSpec } from '@tech-matters/hrm-form-definitions'; import { LexMemory } from '../channelCapture/lexClient'; import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; @@ -25,12 +23,6 @@ import { Twilio } from 'twilio'; import { getCurrentDefinitionVersion } from './formDefinitionsCache'; import { postToInternalHrmEndpoint } from './internalHrmRequest'; import { get } from 'lodash'; -import { - getSurveyWorkflowSid, - getSyncServiceSid, - getTwilioClient, - getWorkspaceSid, -} from '@tech-matters/twilio-configuration'; export type PostSurveyData = { [question: string]: string | number }; /** @@ -156,50 +148,3 @@ export const savePostSurvey = async ({ console.info(`Error accessing to the post survey form definitions: ${errorMessage}`); } }; - -export const getPostSurveySyncDocUniqueName = (callerIdentifier: string) => - `post-surveys-pending-${callerIdentifier.replaceAll('+', '')}`; - -export const handleSavePostSurvey: AccountScopedHandler = async ( - request, - accountSid, -): Promise> => { - const { postSurveyAnswers, clientIdentifier } = request.body; - const twilioClient = await getTwilioClient(accountSid); - const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Looking up sync doc ${docUniqueName}`, - ); - const docContext = twilioClient.sync.v1.services - .get(await getSyncServiceSid(accountSid)) - .documents.get(docUniqueName); - const doc = await docContext.fetch(); - const { taskSid, contactId } = doc.data; - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Retrieved contactId ${contactId} and taskSid ${taskSid} from sync doc ${docUniqueName}`, - ); - 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(); - return newOk(undefined); -}; diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts new file mode 100644 index 0000000000..73604c3db3 --- /dev/null +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -0,0 +1,98 @@ +/** + * 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'; + +const getPostSurveySyncDocUniqueName = (callerIdentifier: string) => + `post-studio-flow-call-data-${callerIdentifier}`; + +export const savePostSurveyHandler: AccountScopedHandler = async ( + request, + accountSid, +): Promise> => { + const { postSurveyAnswers, clientIdentifier } = request.body; + const twilioClient = await getTwilioClient(accountSid); + const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Looking up sync doc ${docUniqueName}`, + ); + await twilioClient.sync.v1.services + .get(await getSyncServiceSid(accountSid)) + .documents.create({ + uniqueName: docUniqueName, + data: { postSurveyAnswers, clientIdentifier }, + ttl: 120, + }); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Stored post survey answers in sync doc ${docUniqueName}`, + ); + return newOk(undefined); +}; + +export const voicePostSurveyActionHandler: AccountScopedHandler = async ( + { query }, + accountSid, +) => { + const twilioClient = await getTwilioClient(accountSid); + const { DialCallSid: clientIdentifier, contactId, contactTaskSid: taskSid } = query; + if (!clientIdentifier) return newMissingParameterResult('DialCallSid'); + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${taskSid}]:`; + const uniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); + 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 }); +}; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 1f4cc049e3..912cb97bae 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -75,7 +75,10 @@ import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRu import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; -import { handleSavePostSurvey } from './hrm/savePostSurvey'; +import { + savePostSurveyHandler, + voicePostSurveyActionHandler, +} from './hrm/voicePostSurvey'; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -87,221 +90,298 @@ import { handleSavePostSurvey } from './hrm/savePostSurvey'; export const ROUTE_PREFIX = '/lambda/twilio/account-scoped/'; -const INITIAL_PIPELINE = [validateRequestMethod]; - const ACCOUNTSID_ROUTES: Record< string, FunctionRoute | FunctionRoute > = { 'webhooks/taskrouterCallback': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleTaskRouterEvent, }, - 'hrm/savePostSurvey': { - requestPipeline: [validateWebhookRequest], - handler: handleSavePostSurvey, - }, getProfileFlagsForIdentifier: { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleGetProfileFlagsForIdentifier, }, 'channelCapture/captureChannelWithBot': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleCaptureChannelWithBot, }, 'channelCapture/chatbotCallback': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallback, }, 'channelCapture/chatbotCallbackCleanup': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallbackCleanup, }, + 'hrm/savePostSurvey': { + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: savePostSurveyHandler, + }, + 'hrm/voicePostSurveyAction': { + requestPipeline: [validateRequestMethod('GET'), validateWebhookRequest], + handler: voicePostSurveyActionHandler, + }, 'conference/conferenceStatusCallback': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: conferenceStatusCallbackHandler, }, 'conference/addParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: addParticipantHandler, }, 'conference/getParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getParticipantHandler, }, 'conference/removeParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: removeParticipantHandler, }, 'conference/updateParticipant': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: updateParticipantHandler, }, 'conference/participantStatusCallback': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: participantStatusCallbackHandler, }, 'conversations/serviceScopedConversationEventHandler': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleConversationEvent, }, 'conversation/checkBlockList': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: checkBlockListHandler, }, 'conversation/transitionAgentParticipants': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: transitionAgentParticipantsHandler, }, 'customChannels/instagram/instagramToFlex': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: instagramToFlexHandler, }, 'customChannels/instagram/flexToInstagram': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToInstagramHandler, }, 'customChannels/telegram/telegramToFlex': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: telegramToFlexHandler, }, 'customChannels/telegram/flexToTelegram': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToTelegramHandler, }, 'customChannels/modica/modicaToFlex': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: modicaToFlexHandler, }, 'customChannels/modica/flexToModica': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToModicaHandler, }, 'customChannels/line/lineToFlex': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: lineToFlexHandler, }, 'customChannels/line/flexToLine': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToLineHandler, }, 'webchatAuth/initWebchat': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, }, 'webchatAuth/refreshToken': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: refreshTokenHandler, }, toggleSwitchboardQueue: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'supervisor' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'supervisor' }), + ], handler: handleToggleSwitchboardQueue, }, 'task/assignOfflineContactInit': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: assignOfflineContactInitHandler, }, 'task/assignOfflineContactResolve': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: assignOfflineContactResolveHandler, }, endChat: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'guest' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'guest' }), + ], handler: handleEndChat, }, operatingHours: { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, }, 'task/checkTaskAssignment': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: checkTaskAssignmentHandler, }, 'task/completeTaskAssignment': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: completeTaskAssignmentHandler, }, 'task/cancelOrRemoveTask': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: cancelOrRemoveTaskHandler, }, 'task/getTaskAndReservations': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getTaskAndReservationsHandler, }, 'transfer/transferStart': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: transferStartHandler, }, updateWorkersSkills: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'supervisor' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'supervisor' }), + ], handler: handleUpdateWorkersSkills, }, 'integrations/iwf/reportToIWF': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: reportToIWFHandler, }, 'integrations/iwf/selfReportToIWF': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: selfReportToIWFHandler, }, 'conversation/getExternalRecordingS3Location': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getExternalRecordingS3LocationHandler, }, 'conversation/getMediaUrl': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getMediaUrlHandler, }, 'worker/populateCounselors': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: populateCounselorsHandler, }, 'worker/getWorkerAttributes': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: getWorkerAttributesHandler, }, 'worker/listWorkerQueues': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: listWorkerQueuesHandler, }, 'worker/pullTask': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: pullTaskHandler, }, 'conversation/sendSystemMessage': { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: sendSystemMessageHandler, }, 'conversation/sendStudioMessage': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendStudioMessageHandler, }, 'conversation/sendMessageAndRunJanitor': { - requestPipeline: [validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendMessageAndRunJanitorHandler, }, issueSyncToken: { - requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })], + requestPipeline: [ + validateRequestMethod('POST'), + validateFlexTokenRequest({ tokenMode: 'agent' }), + ], handler: issueSyncTokenHandler, }, }; const ENV_SHORTCODE_ROUTES: Record = { 'webchatAuthentication/initWebchat': { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, }, 'webchatAuthentication/refreshToken': { - requestPipeline: [validateRequestWithTwilioJwtToken], + requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: refreshTokenHandler, }, endChat: { - requestPipeline: [validateRequestWithTwilioJwtToken], + requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: handleEndChat, }, operatingHours: { - requestPipeline: [], + requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, }, }; @@ -326,7 +406,7 @@ export const lookupRoute = async ( return { accountSid: accountIdentifier, ...functionRoute, - requestPipeline: [...INITIAL_PIPELINE, ...functionRoute.requestPipeline], + requestPipeline: [...functionRoute.requestPipeline], }; } } else { @@ -342,7 +422,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/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); + }; From 3f9138fd1c2effc904674bd38f80df5eb04bef78 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 13:08:32 +0100 Subject: [PATCH 35/50] Fix bodyless webhook validation --- lambdas/account-scoped/src/validation/twilioWebhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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( From 8c1aab5bd7bc099ba76a10be032b7c693232ab50 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 13:42:31 +0100 Subject: [PATCH 36/50] Use POST for webhook validation --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 9a6403103e..95917481dd 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -104,7 +104,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( twiml.dial( { action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskId=${taskSid}`, - method: 'GET', + method: 'POST', }, studioFlowIdentifier, ); diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 73604c3db3..92d7c72a88 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -52,11 +52,12 @@ export const savePostSurveyHandler: AccountScopedHandler = async ( }; export const voicePostSurveyActionHandler: AccountScopedHandler = async ( - { query }, + { query, body }, accountSid, ) => { const twilioClient = await getTwilioClient(accountSid); - const { DialCallSid: clientIdentifier, contactId, contactTaskSid: taskSid } = query; + const { contactId, contactTaskSid: taskSid } = query; + const { DialCallSid: clientIdentifier } = body; if (!clientIdentifier) return newMissingParameterResult('DialCallSid'); const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${taskSid}]:`; const uniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); From 50eb15c85c5dc440521f856caf415ad846c1741b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 14:10:21 +0100 Subject: [PATCH 37/50] Use POST for webhook to fix validation --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 3 +++ lambdas/account-scoped/src/router.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 92d7c72a88..07a2e6cf51 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -61,6 +61,9 @@ export const voicePostSurveyActionHandler: AccountScopedHandler = async ( if (!clientIdentifier) return newMissingParameterResult('DialCallSid'); const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${taskSid}]:`; 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); diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 912cb97bae..4bc580a599 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -119,7 +119,7 @@ const ACCOUNTSID_ROUTES: Record< handler: savePostSurveyHandler, }, 'hrm/voicePostSurveyAction': { - requestPipeline: [validateRequestMethod('GET'), validateWebhookRequest], + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: voicePostSurveyActionHandler, }, 'conference/conferenceStatusCallback': { From 98149f55cc18dd717cfa1c72f25d3405822cefc9 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 14:34:56 +0100 Subject: [PATCH 38/50] Logging --- .../channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 95917481dd..6eb3c6b536 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -103,7 +103,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( await retrieveServiceConfigurationAttributes(client); twiml.dial( { - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskId=${taskSid}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, method: 'POST', }, studioFlowIdentifier, diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 07a2e6cf51..4bc1b1483c 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -34,6 +34,12 @@ export const savePostSurveyHandler: AccountScopedHandler = async ( ): Promise> => { const { postSurveyAnswers, clientIdentifier } = request.body; const twilioClient = await getTwilioClient(accountSid); + const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]:`; + + console.debug( + `${logPrefix} DialCallSid call`, + await twilioClient.calls.get(clientIdentifier).fetch(), + ); const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); console.debug( `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Looking up sync doc ${docUniqueName}`, @@ -60,6 +66,10 @@ export const voicePostSurveyActionHandler: AccountScopedHandler = async ( 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.`, From 2cf4ad1bcb7bea658b97fa2b6d2ff89c7a63ffca Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 16:18:56 +0100 Subject: [PATCH 39/50] Try twiml --- lambdas/account-scoped/src/albResponses.ts | 9 + .../account-scoped/src/hrm/voicePostSurvey.ts | 65 ++++++ lambdas/account-scoped/src/httpTypes.ts | 1 + lambdas/account-scoped/src/index.ts | 10 +- lambdas/account-scoped/src/router.ts | 217 ++++++++++-------- 5 files changed, 203 insertions(+), 99 deletions(-) diff --git a/lambdas/account-scoped/src/albResponses.ts b/lambdas/account-scoped/src/albResponses.ts index 2880a3b7a6..b45147b168 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: JSON.stringify(body), +}); + export const notFoundResponse = (event: ALBEvent): ALBResult => ({ statusCode: 404, headers: { diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 4bc1b1483c..199be782f1 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -24,6 +24,9 @@ import { 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}`; @@ -110,3 +113,65 @@ export const voicePostSurveyActionHandler: AccountScopedHandler = async ( 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(); + if (!digits) { + response.say('Press 1 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, + }); + } else if (!answer1) { + response.say('Press 2 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${digits}`, + }); + } else if (!answer2) { + response.say('Press 3 to continue'); + response.gather({ + method: 'POST', + numDigits: 1, + timeout: 10, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${answer1}&answer2=${digits}`, + }); + } else { + 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: { answer1, 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 4bc580a599..8cdf297334 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -78,6 +78,7 @@ import { getMediaUrlHandler } from './conversation/getMediaUrl'; import { savePostSurveyHandler, voicePostSurveyActionHandler, + voicePostSurveyAnswerHandler, } from './hrm/voicePostSurvey'; /** @@ -89,300 +90,320 @@ import { */ export const ROUTE_PREFIX = '/lambda/twilio/account-scoped/'; +type RouteParam = Omit< + FunctionRoute | FunctionRoute, + 'responseType' +> & { responseType?: FunctionRoute['responseType'] }; + +const newRoute = ( + route: RouteParam, +): FunctionRoute | FunctionRoute => ({ + responseType: 'json', + ...route, +}); const ACCOUNTSID_ROUTES: Record< string, FunctionRoute | FunctionRoute > = { - 'webhooks/taskrouterCallback': { + 'webhooks/taskrouterCallback': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleTaskRouterEvent, - }, - getProfileFlagsForIdentifier: { + }), + getProfileFlagsForIdentifier: newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleGetProfileFlagsForIdentifier, - }, - 'channelCapture/captureChannelWithBot': { + }), + 'channelCapture/captureChannelWithBot': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleCaptureChannelWithBot, - }, - 'channelCapture/chatbotCallback': { + }), + 'channelCapture/chatbotCallback': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallback, - }, - 'channelCapture/chatbotCallbackCleanup': { + }), + 'channelCapture/chatbotCallbackCleanup': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleChatbotCallbackCleanup, - }, - 'hrm/savePostSurvey': { + }), + 'hrm/savePostSurvey': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: savePostSurveyHandler, - }, - 'hrm/voicePostSurveyAction': { + }), + 'hrm/voicePostSurveyAction': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: voicePostSurveyActionHandler, - }, - 'conference/conferenceStatusCallback': { + }), + 'hrm/voicePostSurveyAnswer': newRoute({ + requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], + handler: voicePostSurveyAnswerHandler, + responseType: 'xml', + }), + 'conference/conferenceStatusCallback': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: conferenceStatusCallbackHandler, - }, - 'conference/addParticipant': { + }), + 'conference/addParticipant': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: addParticipantHandler, - }, - 'conference/getParticipant': { + }), + 'conference/getParticipant': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: getParticipantHandler, - }, - 'conference/removeParticipant': { + }), + 'conference/removeParticipant': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: removeParticipantHandler, - }, - 'conference/updateParticipant': { + }), + 'conference/updateParticipant': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: updateParticipantHandler, - }, - 'conference/participantStatusCallback': { + }), + 'conference/participantStatusCallback': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: participantStatusCallbackHandler, - }, - 'conversations/serviceScopedConversationEventHandler': { + }), + 'conversations/serviceScopedConversationEventHandler': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: handleConversationEvent, - }, - 'conversation/checkBlockList': { + }), + 'conversation/checkBlockList': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: checkBlockListHandler, - }, - 'conversation/transitionAgentParticipants': { + }), + 'conversation/transitionAgentParticipants': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: transitionAgentParticipantsHandler, - }, - 'customChannels/instagram/instagramToFlex': { + }), + 'customChannels/instagram/instagramToFlex': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: instagramToFlexHandler, - }, - 'customChannels/instagram/flexToInstagram': { + }), + 'customChannels/instagram/flexToInstagram': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToInstagramHandler, - }, - 'customChannels/telegram/telegramToFlex': { + }), + 'customChannels/telegram/telegramToFlex': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: telegramToFlexHandler, - }, - 'customChannels/telegram/flexToTelegram': { + }), + 'customChannels/telegram/flexToTelegram': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToTelegramHandler, - }, - 'customChannels/modica/modicaToFlex': { + }), + 'customChannels/modica/modicaToFlex': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: modicaToFlexHandler, - }, - 'customChannels/modica/flexToModica': { + }), + 'customChannels/modica/flexToModica': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToModicaHandler, - }, - 'customChannels/line/lineToFlex': { + }), + 'customChannels/line/lineToFlex': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: lineToFlexHandler, - }, - 'customChannels/line/flexToLine': { + }), + 'customChannels/line/flexToLine': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: flexToLineHandler, - }, - 'webchatAuth/initWebchat': { + }), + 'webchatAuth/initWebchat': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, - }, - 'webchatAuth/refreshToken': { + }), + 'webchatAuth/refreshToken': newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: refreshTokenHandler, - }, - toggleSwitchboardQueue: { + }), + toggleSwitchboardQueue: newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'supervisor' }), ], handler: handleToggleSwitchboardQueue, - }, - 'task/assignOfflineContactInit': { + }), + 'task/assignOfflineContactInit': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: assignOfflineContactInitHandler, - }, - 'task/assignOfflineContactResolve': { + }), + 'task/assignOfflineContactResolve': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: assignOfflineContactResolveHandler, - }, - endChat: { + }), + endChat: newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'guest' }), ], handler: handleEndChat, - }, - operatingHours: { + }), + operatingHours: newRoute({ requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, - }, - 'task/checkTaskAssignment': { + }), + 'task/checkTaskAssignment': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: checkTaskAssignmentHandler, - }, - 'task/completeTaskAssignment': { + }), + 'task/completeTaskAssignment': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: completeTaskAssignmentHandler, - }, - 'task/cancelOrRemoveTask': { + }), + 'task/cancelOrRemoveTask': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: cancelOrRemoveTaskHandler, - }, - 'task/getTaskAndReservations': { + }), + 'task/getTaskAndReservations': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: getTaskAndReservationsHandler, - }, - 'transfer/transferStart': { + }), + 'transfer/transferStart': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: transferStartHandler, - }, - updateWorkersSkills: { + }), + updateWorkersSkills: newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'supervisor' }), ], handler: handleUpdateWorkersSkills, - }, - 'integrations/iwf/reportToIWF': { + }), + 'integrations/iwf/reportToIWF': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: reportToIWFHandler, - }, - 'integrations/iwf/selfReportToIWF': { + }), + 'integrations/iwf/selfReportToIWF': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: selfReportToIWFHandler, - }, - 'conversation/getExternalRecordingS3Location': { + }), + 'conversation/getExternalRecordingS3Location': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: getExternalRecordingS3LocationHandler, - }, - 'conversation/getMediaUrl': { + }), + 'conversation/getMediaUrl': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: getMediaUrlHandler, - }, - 'worker/populateCounselors': { + }), + 'worker/populateCounselors': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: populateCounselorsHandler, - }, - 'worker/getWorkerAttributes': { + }), + 'worker/getWorkerAttributes': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: getWorkerAttributesHandler, - }, - 'worker/listWorkerQueues': { + }), + 'worker/listWorkerQueues': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: listWorkerQueuesHandler, - }, - 'worker/pullTask': { + }), + 'worker/pullTask': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: pullTaskHandler, - }, - 'conversation/sendSystemMessage': { + }), + 'conversation/sendSystemMessage': newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: sendSystemMessageHandler, - }, - 'conversation/sendStudioMessage': { + }), + 'conversation/sendStudioMessage': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendStudioMessageHandler, - }, - 'conversation/sendMessageAndRunJanitor': { + }), + 'conversation/sendMessageAndRunJanitor': newRoute({ requestPipeline: [validateRequestMethod('POST'), validateWebhookRequest], handler: sendMessageAndRunJanitorHandler, - }, - issueSyncToken: { + }), + issueSyncToken: newRoute({ requestPipeline: [ validateRequestMethod('POST'), validateFlexTokenRequest({ tokenMode: 'agent' }), ], handler: issueSyncTokenHandler, - }, + }), }; const ENV_SHORTCODE_ROUTES: Record = { 'webchatAuthentication/initWebchat': { requestPipeline: [validateRequestMethod('POST')], handler: initWebchatHandler, + responseType: 'json', }, 'webchatAuthentication/refreshToken': { requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: refreshTokenHandler, + responseType: 'json', }, endChat: { requestPipeline: [validateRequestMethod('POST'), validateRequestWithTwilioJwtToken], handler: handleEndChat, + responseType: 'json', }, operatingHours: { requestPipeline: [validateRequestMethod('POST')], handler: handleOperatingHours, + responseType: 'json', }, }; From 88f8d37a209a350b6d680bd3f31fea881728985d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 16:25:40 +0100 Subject: [PATCH 40/50] Add call to twiml and logging --- .../postStudioFlowTaskRouterListener.ts | 14 ++------------ lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 6eb3c6b536..fa1914cc91 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -98,22 +98,12 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); - const twiml = new VoiceResponse(); const { hrm_base_url: hrmBaseUrl } = await retrieveServiceConfigurationAttributes(client); - twiml.dial( - { - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, - method: 'POST', - }, - studioFlowIdentifier, - ); await client.calls.get(participant.callSid).update({ - twiml, + url: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, }); - console.debug( - `${logPrefix} Dialed ${studioFlowIdentifier} to start post survey.`, - ); + console.debug(`${logPrefix} Started custom twiml to start post survey.`); } catch (err) { await participant.remove(); console.debug( diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 199be782f1..c4face35c7 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -124,7 +124,9 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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, gathing for first answer`); response.say('Press 1 to continue'); response.gather({ method: 'POST', @@ -133,6 +135,9 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?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', @@ -141,6 +146,9 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${digits}`, }); } 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', @@ -149,6 +157,9 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${answer1}&answer2=${digits}`, }); } 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({ From e621c80ed1f90e4d376bc2587861dd875c4bf57c Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 16:25:55 +0100 Subject: [PATCH 41/50] typo --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index c4face35c7..f0282936f9 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -126,7 +126,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( const response = new VoiceResponse(); const logPrefix = `[Post Survey Studio Flow - ${accountSid}]:`; if (!digits) { - console.debug(`${logPrefix} No digits, gathing for first answer`); + console.debug(`${logPrefix} No digits, gathering for first answer`); response.say('Press 1 to continue'); response.gather({ method: 'POST', From c436e5b6493b6392ee5dfa88e7a8f3b4c7a6d0c5 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 16:41:11 +0100 Subject: [PATCH 42/50] fix url --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 2 +- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index fa1914cc91..d501e4f8ca 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -101,7 +101,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const { hrm_base_url: hrmBaseUrl } = await retrieveServiceConfigurationAttributes(client); await client.calls.get(participant.callSid).update({ - url: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, + url: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}`, }); console.debug(`${logPrefix} Started custom twiml to start post survey.`); } catch (err) { diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index f0282936f9..d3fdd097a3 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -132,7 +132,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( method: 'POST', numDigits: 1, timeout: 10, - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}`, }); } else if (!answer1) { console.debug( @@ -143,7 +143,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( method: 'POST', numDigits: 1, timeout: 10, - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${digits}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${digits}`, }); } else if (!answer2) { console.debug( @@ -154,7 +154,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( method: 'POST', numDigits: 1, timeout: 10, - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAction?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${answer1}&answer2=${digits}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${answer1}&answer2=${digits}`, }); } else { console.debug( From 939d5df45ce8b3cf1305fa1194848446a4921555 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 16:53:49 +0100 Subject: [PATCH 43/50] fix xml responses --- lambdas/account-scoped/src/albResponses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/albResponses.ts b/lambdas/account-scoped/src/albResponses.ts index b45147b168..f13f586f83 100644 --- a/lambdas/account-scoped/src/albResponses.ts +++ b/lambdas/account-scoped/src/albResponses.ts @@ -40,7 +40,7 @@ export const okXmlResponse = (body: string = ''): ALBResult => ({ 'Content-Type': 'application/xml', }, statusCode: 200, - body: JSON.stringify(body), + body, }); export const notFoundResponse = (event: ALBEvent): ALBResult => ({ From 24642fc4725f7cdb3c39d9d53c187dfb67043e54 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 17:59:16 +0100 Subject: [PATCH 44/50] fix query ordering --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index d3fdd097a3..4866068ed3 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -132,6 +132,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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) { @@ -143,7 +144,8 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( method: 'POST', numDigits: 1, timeout: 10, - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${digits}`, + // 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( @@ -154,7 +156,8 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( method: 'POST', numDigits: 1, timeout: 10, - action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&answer1=${answer1}&answer2=${digits}`, + // 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( From 2f8a9f80d47ad9487633112efb7467b66a707a39 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 18:10:48 +0100 Subject: [PATCH 45/50] fix post survey q names --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 4866068ed3..20219f0e83 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -145,7 +145,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&was_helpful=${digits}`, }); } else if (!answer2) { console.debug( @@ -157,7 +157,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}&was_helpful=${answer1}&would_recommend=${digits}`, }); } else { console.debug( @@ -180,12 +180,16 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( ); await savePostSurvey({ accountSid, - postSurveyAnswers: { answer1, answer2, answer3: digits }, + postSurveyAnswers: { + was_helpful: answer1, + would_recommend: answer2, + answer3: digits, + }, twilioClient, controlTask, }); - response.say('No go away'); + response.say('Now go away'); } return newOk(response); }; From 7884550a709db6d9528dc653cebe84fcb711924b Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Mon, 22 Jun 2026 18:19:55 +0100 Subject: [PATCH 46/50] fix post survey q names --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 20219f0e83..522d3b498a 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -145,7 +145,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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}&was_helpful=${digits}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?answer1=${digits}&contactId=${contactId}&contactTaskSid=${taskSid}`, }); } else if (!answer2) { console.debug( @@ -157,7 +157,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( 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}&was_helpful=${answer1}&would_recommend=${digits}`, + action: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?answer1=${answer1}&answer2=${digits}&contactId=${contactId}&contactTaskSid=${taskSid}`, }); } else { console.debug( @@ -189,7 +189,7 @@ export const voicePostSurveyAnswerHandler: AccountScopedHandler = async ( controlTask, }); - response.say('Now go away'); + response.say('No go away'); } return newOk(response); }; From 3aafaae16c08fe24a11b5bbb5c949847b941ccc8 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 07:21:18 +0100 Subject: [PATCH 47/50] Try sip --- .../postStudioFlowTaskRouterListener.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index d501e4f8ca..b8e37c170f 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -70,9 +70,10 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const serviceConfigAttributes = await retrieveServiceConfigurationAttributes(client); const { postStudioFlows } = serviceConfigAttributes; - const studioFlowIdentifier: string = postStudioFlows?.[taskChannelUniqueName] ?? ''; + const studioFlowIdentifier = postStudioFlows?.[taskChannelUniqueName] ?? ''; - if (studioFlowIdentifier.startsWith('+')) { + if (typeof studioFlowIdentifier === 'object') { + const { sipDomain, sipUsername, sipPassword } = studioFlowIdentifier; const { conference, contactId } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); @@ -98,12 +99,21 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.debug( `${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`, ); - const { hrm_base_url: hrmBaseUrl } = - await retrieveServiceConfigurationAttributes(client); + const twiml = new VoiceResponse(); + const dial = twiml.dial(); + dial.sip( + { + username: sipUsername, + password: sipPassword, + }, + `sip:${contactId}@${sipDomain}?x-contactId=${contactId}&x-taskSid=${taskSid}`, + ); await client.calls.get(participant.callSid).update({ - url: `${hrmBaseUrl}/lambda/twilio/account-scoped/${accountSid}/hrm/voicePostSurveyAnswer?contactId=${contactId}&contactTaskSid=${taskSid}`, + twiml, }); - console.debug(`${logPrefix} Started custom twiml to start post survey.`); + console.debug( + `${logPrefix} Dialed ${studioFlowIdentifier} passing ${contactId} and ${taskSid} to start post survey.`, + ); } catch (err) { await participant.remove(); console.debug( From 4c63f12ec7fd8a22800b82875971a420d64d79cd Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 07:24:29 +0100 Subject: [PATCH 48/50] Try sip --- lambdas/account-scoped/src/hrm/voicePostSurvey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 522d3b498a..2108a3c3da 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -40,7 +40,7 @@ export const savePostSurveyHandler: AccountScopedHandler = async ( const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]:`; console.debug( - `${logPrefix} DialCallSid call`, + `${logPrefix} Studio Flow call`, await twilioClient.calls.get(clientIdentifier).fetch(), ); const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); From ee8f32dc4fa29cb35071486c363e3d219f81842d Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 07:42:17 +0100 Subject: [PATCH 49/50] Try sip --- .../postStudioFlowTaskRouterListener.ts | 2 +- .../account-scoped/src/hrm/voicePostSurvey.ts | 39 +++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index b8e37c170f..24c003ac56 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -106,7 +106,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( username: sipUsername, password: sipPassword, }, - `sip:${contactId}@${sipDomain}?x-contactId=${contactId}&x-taskSid=${taskSid}`, + `sip:post-survey@${sipDomain}?x-contactId=${contactId}&x-taskSid=${taskSid}`, ); await client.calls.get(participant.callSid).update({ twiml, diff --git a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts index 2108a3c3da..9b8c0e8238 100644 --- a/lambdas/account-scoped/src/hrm/voicePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/voicePostSurvey.ts @@ -35,29 +35,34 @@ export const savePostSurveyHandler: AccountScopedHandler = async ( request, accountSid, ): Promise> => { - const { postSurveyAnswers, clientIdentifier } = request.body; + const { postSurveyAnswers, contactId, contactTaskSid } = request.body; const twilioClient = await getTwilioClient(accountSid); - const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]:`; + 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( - `${logPrefix} Studio Flow call`, - await twilioClient.calls.get(clientIdentifier).fetch(), + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, ); - const docUniqueName = getPostSurveySyncDocUniqueName(clientIdentifier); - console.debug( - `[Post Survey Studio Flow - ${accountSid}/${clientIdentifier}]: Looking up sync doc ${docUniqueName}`, - ); - await twilioClient.sync.v1.services - .get(await getSyncServiceSid(accountSid)) - .documents.create({ - uniqueName: docUniqueName, - data: { postSurveyAnswers, clientIdentifier }, - ttl: 120, - }); + 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}/${clientIdentifier}]: Stored post survey answers in sync doc ${docUniqueName}`, + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, ); - return newOk(undefined); + 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 ( From 7c20333c6b074dc1bd016731cf3ddaffe473aa1a Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 07:53:17 +0100 Subject: [PATCH 50/50] Try sip --- .../src/channelCapture/postStudioFlowTaskRouterListener.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 24c003ac56..648bf3c86d 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -73,7 +73,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( const studioFlowIdentifier = postStudioFlows?.[taskChannelUniqueName] ?? ''; if (typeof studioFlowIdentifier === 'object') { - const { sipDomain, sipUsername, sipPassword } = studioFlowIdentifier; + const { sipEndpoint, sipUsername, sipPassword } = studioFlowIdentifier; const { conference, contactId } = taskAttributes; if (taskChannelUniqueName === 'voice' && conference) { const conferenceContext = client.conferences.get(conference.sid); @@ -106,7 +106,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( username: sipUsername, password: sipPassword, }, - `sip:post-survey@${sipDomain}?x-contactId=${contactId}&x-taskSid=${taskSid}`, + `${sipEndpoint}?x-contactId=${contactId}&x-taskSid=${taskSid}`, ); await client.calls.get(participant.callSid).update({ twiml,