From 3e98956f989deadb405806aae786bf64bc2983b1 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Thu, 11 Jun 2026 11:49:28 +0100 Subject: [PATCH 01/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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/38] 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 422a2696f0c52810d67156cead56081c21db9e2c Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 19 Jun 2026 16:34:50 +0100 Subject: [PATCH 34/38] WIP - voice post studio flow kicked off from REST API (no call), and an endpoint to start post surveys --- .../channelCapture/captureChannelWithBot.ts | 4 +- .../postStudioFlowTaskRouterListener.ts | 43 +++++++----- .../src/channelCapture/postSurveyListener.ts | 67 ++++++++++++++++--- .../account-scoped/src/hrm/savePostSurvey.ts | 39 ++++++----- lambdas/account-scoped/src/router.ts | 5 ++ 5 files changed, 111 insertions(+), 47 deletions(-) diff --git a/lambdas/account-scoped/src/channelCapture/captureChannelWithBot.ts b/lambdas/account-scoped/src/channelCapture/captureChannelWithBot.ts index 50261c5a22..708a85d31c 100644 --- a/lambdas/account-scoped/src/channelCapture/captureChannelWithBot.ts +++ b/lambdas/account-scoped/src/channelCapture/captureChannelWithBot.ts @@ -24,7 +24,7 @@ import { getChatServiceSid, getHelplineCode, getSurveyWorkflowSid, - getTwilioWorkspaceSid, + getWorkspaceSid, } from '@tech-matters/twilio-configuration'; export const handleCaptureChannelWithBot: AccountScopedHandler = async ( @@ -61,7 +61,7 @@ export const handleCaptureChannelWithBot: AccountScopedHandler = async ( const chatServiceSid = await getChatServiceSid(accountSid); const helplineCode = await getHelplineCode(accountSid); const surveyWorkflowSid = await getSurveyWorkflowSid(accountSid); - const twilioWorkspaceSid = await getTwilioWorkspaceSid(accountSid); + const twilioWorkspaceSid = await getWorkspaceSid(accountSid); const result = await handleChannelCapture(twilioClient, { accountSid, diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 30d64be71d..55ff9b7b51 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -69,17 +69,18 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( console.info(`${logPrefix} Handling post studio flow trigger...`); console.debug('[SENSITIVE] taskAttributes', taskAttributes); - // This task is a candidate to trigger post survey. Check feature flags for the account. - const serviceConfigAttributes = - await retrieveServiceConfigurationAttributes(client); - const { postStudioFlows } = serviceConfigAttributes; - const studioFlowIdentifier: string = postStudioFlows?.[taskChannelUniqueName] ?? ''; + // 1. Fetch all active participants in the conference + const { conference, contactId } = taskAttributes; + const conferenceContext = client.conferences.get(conference.sid); + if (taskChannelUniqueName === 'voice' && conference) { + // This task is a candidate to trigger post survey. Check feature flags for the account. + const serviceConfigAttributes = + await retrieveServiceConfigurationAttributes(client); + const { postStudioFlows } = serviceConfigAttributes; + const studioFlowIdentifier: string = + postStudioFlows?.[taskChannelUniqueName] ?? ''; - if (studioFlowIdentifier.startsWith('+')) { - const { conference, contactId } = taskAttributes; - if (taskChannelUniqueName === 'voice' && conference) { - const conferenceContext = client.conferences.get(conference.sid); - // 1. Fetch all active participants in the conference + if (studioFlowIdentifier.startsWith('+')) { const allParticipants = await conferenceContext.participants.list(); console.debug( `${logPrefix} ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`, @@ -166,17 +167,27 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `${logPrefix} Only valid for redirecting to studio flow if there is only one connected participant on the conference`, ); } + } else if (studioFlowIdentifier.startsWith('FW')) { + await client.studio.v2.flows.get(studioFlowIdentifier).executions.create({ + from: taskAttributes.to, + parameters: { + contactId, + contactTaskSid: taskSid, + }, + to: taskAttributes.from, + }); + conferenceContext.participants.each(p => p.remove()); } else { - console.warn( - `${logPrefix} Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, - `taskChannelUniqueName: ${taskChannelUniqueName}`, - `conference: ${conference}`, - ); + console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); } console.info(`${logPrefix} Finished handling post studio flow trigger.`); } else { - console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); + console.warn( + `${logPrefix} Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`, + `taskChannelUniqueName: ${taskChannelUniqueName}`, + `conference: ${conference}`, + ); } } } catch (err) { diff --git a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts index 650f129a4c..ea787c3338 100644 --- a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts @@ -32,9 +32,12 @@ import { getChatServiceSid, getHelplineCode, getSurveyWorkflowSid, - getTwilioWorkspaceSid, + getTwilioClient, + getWorkspaceSid, } from '@tech-matters/twilio-configuration'; import { getTranslation } from '../translations/translationLookup'; +import { AccountScopedHandler } from '../httpTypes'; +import { newOk } from '../Result'; const GLOBAL_DEFAULT_LANGUAGE = 'en-US'; @@ -53,7 +56,6 @@ type TransferMeta = { }; const isTriggerPostSurvey = ({ - eventType, taskAttributes, taskChannelUniqueName, }: { @@ -64,15 +66,13 @@ const isTriggerPostSurvey = ({ isChatCaptureControl?: boolean; }; }) => { - if (eventType !== TASK_WRAPUP) return false; - // Post survey is for chat tasks only. This will change when we introduce voice based post surveys if (taskChannelUniqueName !== 'chat') return false; return !isChatCaptureControlTask(taskAttributes); }; -export const postSurveyInitHandler = async ({ +const postSurveyInitHandler = async ({ accountSid, channelType, chatServiceSid, @@ -86,6 +86,7 @@ export const postSurveyInitHandler = async ({ webhookBaseUrl, channelSid, conversationSid, + contactId, }: { accountSid: AccountSID; client: Twilio; @@ -98,6 +99,7 @@ export const postSurveyInitHandler = async ({ helplineCode: string; surveyWorkflowSid: string; twilioWorkspaceSid: string; + contactId: string; } & ( | { channelSid: string; @@ -126,6 +128,7 @@ export const postSurveyInitHandler = async ({ contactTaskId: taskSid, conversations: { conversation_id: taskSid }, language: taskLanguage, // if there's a task language, attach it to the post survey task + contactId, }), controlTaskTTL: 3600, channelType, @@ -156,8 +159,8 @@ const triggerPostSurvey: TaskRouterEventHandler = async ( const taskAttributes = JSON.parse(taskAttributesString); if (isTriggerPostSurvey({ eventType, taskAttributes, taskChannelUniqueName })) { - console.log('Handling post survey trigger...'); - console.log('taskAttributes', taskAttributes); + console.info('Handling post survey trigger...'); + console.debug('[SENSITIVE] taskAttributes', taskAttributes); // This task is a candidate to trigger post survey. Check feature flags for the account. const serviceConfigAttributes = @@ -166,7 +169,7 @@ const triggerPostSurvey: TaskRouterEventHandler = async ( const { enable_post_survey: enablePostSurvey } = featureFlags; if (enablePostSurvey) { - const { channelSid, conversationSid, channelType, customChannelType } = + const { channelSid, conversationSid, channelType, customChannelType, contactId } = taskAttributes; const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes); @@ -176,10 +179,11 @@ const triggerPostSurvey: TaskRouterEventHandler = async ( const chatServiceSid = await getChatServiceSid(accountSid); const helplineCode = await getHelplineCode(accountSid); const surveyWorkflowSid = await getSurveyWorkflowSid(accountSid); - const twilioWorkspaceSid = await getTwilioWorkspaceSid(accountSid); + const twilioWorkspaceSid = await getWorkspaceSid(accountSid); await postSurveyInitHandler({ channelSid, + contactId, conversationSid, taskSid, taskLanguage, @@ -194,9 +198,11 @@ const triggerPostSurvey: TaskRouterEventHandler = async ( webhookBaseUrl, }); - console.log('Finished handling post survey trigger.'); + console.info(`Finished handling post survey trigger for task ${taskSid}.`); } else { - console.log('Bypassing post survey trigger - they are disabled'); + console.debug( + `Bypassing post survey trigger for task ${taskSid} - they are disabled`, + ); } } } catch (err) { @@ -205,3 +211,42 @@ const triggerPostSurvey: TaskRouterEventHandler = async ( }; registerTaskRouterEventHandler([TASK_WRAPUP], triggerPostSurvey); + +export const startPostSurveyChatbotHandler: AccountScopedHandler = async ( + { body }, + accountSid, +) => { + const { channelType, language, contactId, conversationSid, taskSid } = body; + const client = await getTwilioClient(accountSid); + + const { helplineLanguage } = await retrieveServiceConfigurationAttributes(client); + const taskLanguage = getTaskLanguage(helplineLanguage)({ language }); + + const environment = process.env.NODE_ENV!; + const webhookBaseUrl = process.env.WEBHOOK_BASE_URL!; + const chatServiceSid = await getChatServiceSid(accountSid); + const helplineCode = await getHelplineCode(accountSid); + const surveyWorkflowSid = await getSurveyWorkflowSid(accountSid); + const twilioWorkspaceSid = await getWorkspaceSid(accountSid); + + await postSurveyInitHandler({ + conversationSid, + contactId, + taskSid, + taskLanguage, + channelType, + accountSid, + chatServiceSid, + client, + environment, + helplineCode, + surveyWorkflowSid, + twilioWorkspaceSid, + webhookBaseUrl, + }); + + console.info( + `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Finished handling post survey request for task ${taskSid}, contact ${contactId}.`, + ); + return newOk({}); +}; diff --git a/lambdas/account-scoped/src/hrm/savePostSurvey.ts b/lambdas/account-scoped/src/hrm/savePostSurvey.ts index b183302a8a..7a74dc8fb3 100644 --- a/lambdas/account-scoped/src/hrm/savePostSurvey.ts +++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts @@ -164,25 +164,27 @@ export const handleSavePostSurvey: AccountScopedHandler = async ( request, accountSid, ): Promise> => { - const { postSurveyAnswers, clientIdentifier } = request.body; + let { postSurveyAnswers, clientIdentifier, contactId, contactTaskSid } = 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}`, - ); + if (!contactId || !contactTaskSid) { + 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(); + ({ taskSid: contactTaskSid, contactId } = doc.data); + console.debug( + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Retrieved contactId ${contactId} and taskSid ${contactTaskSid} from sync doc ${docUniqueName}`, + ); + } const controlTask = await twilioClient.taskrouter.v1 .workspaces(await getWorkspaceSid(accountSid)) .tasks.create({ attributes: JSON.stringify({ - contactTaskId: taskSid, + contactTaskId: contactTaskSid, contactId, isSurveyTask: true, }), @@ -191,15 +193,16 @@ export const handleSavePostSurvey: AccountScopedHandler = async ( }); console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Created new post studio flow task ${controlTask.sid} for storing post survey data in insights`, ); await savePostSurvey({ twilioClient, accountSid, controlTask, postSurveyAnswers }); // As survey tasks will never be assigned to a worker, they'll be kept in pending state. A pending can't transition to completed state, so we cancel them here to raise a task.canceled taskrouter event (see functions/taskrouterListeners/janitorListener.ts) // This needs to be the last step so the new task attributes from saveSurveyInInsights make it to insights console.debug( - `[Post Survey Studio Flow - ${accountSid}/${taskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, + `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, ); await controlTask.update({ assignmentStatus: 'canceled' }); - await docContext.remove(); - return newOk(undefined); + return newOk({ + message: `[Post Survey Studio Flow - ${accountSid}/${contactTaskSid}]: Saved new post survey to HRM for contact ${contactId} and updated controlTask ${controlTask.sid} for insights.`, + }); }; diff --git a/lambdas/account-scoped/src/router.ts b/lambdas/account-scoped/src/router.ts index 1f4cc049e3..2426e5fd59 100644 --- a/lambdas/account-scoped/src/router.ts +++ b/lambdas/account-scoped/src/router.ts @@ -76,6 +76,7 @@ import { issueSyncTokenHandler } from './issueSyncToken'; import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location'; import { getMediaUrlHandler } from './conversation/getMediaUrl'; import { handleSavePostSurvey } from './hrm/savePostSurvey'; +import {startPostSurveyChatbotHandler} from "./channelCapture/postSurveyListener"; /** * Super simple router sufficient for directly ported Twilio Serverless functions @@ -117,6 +118,10 @@ const ACCOUNTSID_ROUTES: Record< requestPipeline: [validateWebhookRequest], handler: handleChatbotCallbackCleanup, }, + 'channelCapture/startPostSurveyChatbot': { + requestPipeline: [validateWebhookRequest], + handler: startPostSurveyChatbotHandler, + }, 'conference/conferenceStatusCallback': { requestPipeline: [validateWebhookRequest], handler: conferenceStatusCallbackHandler, From fdaef04efff5b530b4dd4151dee0715c488415ce Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 15:21:48 +0100 Subject: [PATCH 35/38] Logging --- .../postStudioFlowTaskRouterListener.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts index 55ff9b7b51..e347006462 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -168,6 +168,9 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( ); } } else if (studioFlowIdentifier.startsWith('FW')) { + console.debug( + `${logPrefix} Initiating post studio flow ${studioFlowIdentifier} configured for ${taskChannelUniqueName} via REST API - contact ${contactId}, task: ${taskSid}`, + ); await client.studio.v2.flows.get(studioFlowIdentifier).executions.create({ from: taskAttributes.to, parameters: { @@ -176,9 +179,17 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( }, to: taskAttributes.from, }); + console.debug( + `${logPrefix} Initiated post studio flow ${studioFlowIdentifier} configured for ${taskChannelUniqueName} via REST API - contact ${contactId}, task: ${taskSid}, removing participants`, + ); conferenceContext.participants.each(p => p.remove()); + console.debug( + `${logPrefix} Removed participants from conference ${conference.sid}.`, + ); } else { - console.debug(`No post studio flow configured for ${taskChannelUniqueName}`); + console.debug( + `${logPrefix} No post studio flow configured for ${taskChannelUniqueName}`, + ); } console.info(`${logPrefix} Finished handling post studio flow trigger.`); From 162601bcdf9f0aa0ba40cd478d3cc1ff235479bf Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 17:04:55 +0100 Subject: [PATCH 36/38] Change from number in post call text surveys --- .../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 e347006462..d4666a9591 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -172,7 +172,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `${logPrefix} Initiating post studio flow ${studioFlowIdentifier} configured for ${taskChannelUniqueName} via REST API - contact ${contactId}, task: ${taskSid}`, ); await client.studio.v2.flows.get(studioFlowIdentifier).executions.create({ - from: taskAttributes.to, + from: '+12064083885', parameters: { contactId, contactTaskSid: taskSid, From 678b20d1338833ead10a47a7b876afe2bc266e44 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Tue, 23 Jun 2026 17:25:54 +0100 Subject: [PATCH 37/38] Fix merge --- .../src/channelCapture/postSurveyListener.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts index dae00f71d4..014b97ddbc 100644 --- a/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts @@ -22,7 +22,10 @@ import { AccountSID } from '@tech-matters/twilio-types'; import { Twilio } from 'twilio'; import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes'; import { EventFields } from '../taskrouter'; -import { retrieveServiceConfiguration } from '../configuration/aseloConfiguration'; +import { + retrieveServiceConfiguration, + retrieveServiceConfigurationAttributes, +} from '../configuration/aseloConfiguration'; import { handleChannelCapture, HandleChannelCaptureParams, @@ -37,6 +40,8 @@ import { } from '@tech-matters/twilio-configuration'; import { getTranslation } from '../translations/translationLookup'; import { getCurrentDefinitionVersion } from '../hrm/formDefinitionsCache'; +import { newOk } from '../Result'; +import { AccountScopedHandler } from '../httpTypes'; const GLOBAL_DEFAULT_LANGUAGE = 'en-US'; From c4e218d83376f07807595839340daaaa13595489 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Wed, 24 Jun 2026 11:53:03 +0100 Subject: [PATCH 38/38] Revert to using same number for post conversation --- .../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 d4666a9591..e347006462 100644 --- a/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts +++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts @@ -172,7 +172,7 @@ const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async ( `${logPrefix} Initiating post studio flow ${studioFlowIdentifier} configured for ${taskChannelUniqueName} via REST API - contact ${contactId}, task: ${taskSid}`, ); await client.studio.v2.flows.get(studioFlowIdentifier).executions.create({ - from: '+12064083885', + from: taskAttributes.to, parameters: { contactId, contactTaskSid: taskSid,