Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
3e98956
WIP
stephenhand Jun 11, 2026
6e5eea1
WIP
stephenhand Jun 11, 2026
99ff99f
WIP post survey calls
stephenhand Jun 15, 2026
8444c99
Fix post studio flow for voice logic and add log
stephenhand Jun 15, 2026
a701f9d
Add logs to post studio flow for voice logic
stephenhand Jun 15, 2026
d4ee435
Add logs to post studio flow for voice logic
stephenhand Jun 15, 2026
121a5bc
Update conference monitor to not end conference on exit if post flow …
stephenhand Jun 15, 2026
6b45516
Extra logging for post studio flow setup
stephenhand Jun 16, 2026
1ddb1b3
Complete conference on post survey flow
stephenhand Jun 16, 2026
30e411d
Try redirecting using twilml
stephenhand Jun 16, 2026
1069951
Try redirecting parent call using twilml
stephenhand Jun 16, 2026
9b02e70
Fix log
stephenhand Jun 16, 2026
85ce796
Try redirecting from reservation
stephenhand Jun 16, 2026
78bd0c1
Try redirecting using a TwilML bin
stephenhand Jun 16, 2026
4dbac66
Try putting the participant on hold before removing from the conference
stephenhand Jun 17, 2026
6a3ed8e
Try putting the participant on hold before removing from the conferen…
stephenhand Jun 17, 2026
cde3a65
Try putting the participant on hold before removing from the conferen…
stephenhand Jun 17, 2026
e1b620f
Remove participant AFTER setting redirect
stephenhand Jun 17, 2026
ba6ed35
Remove participant AFTER setting redirect
stephenhand Jun 17, 2026
c0a420b
Use twilml URL
stephenhand Jun 17, 2026
a5ba182
Dial studio flow
stephenhand Jun 18, 2026
9bcfc5e
Dial studio flow
stephenhand Jun 18, 2026
97134e2
Add contact ID to dial
stephenhand Jun 18, 2026
2bc40a5
Add contact ID to inline twiml
stephenhand Jun 18, 2026
cb633c4
Reorganised post survey code a bit. Added mechanism to stash contact …
stephenhand Jun 18, 2026
62e86c6
Changed sync doc creation from remove-then-create to upsert
stephenhand Jun 19, 2026
64cb577
Tidy up logs, remove participant from conference on error
stephenhand Jun 19, 2026
785cbad
savePostSurvey logging
stephenhand Jun 19, 2026
f74b93b
Remove slashes and pluses from unique doc names
stephenhand Jun 19, 2026
40d8e80
Fix twilio mock in taskrouterEventHandler test to include twiml.Voice…
Copilot Jun 19, 2026
ed5e1e2
Fix setUpConferenceActions test: add postStudioFlows to mock and upda…
Copilot Jun 19, 2026
5348441
Linter
stephenhand Jun 19, 2026
9057132
Pass phone number as post studio flow identifier
stephenhand Jun 19, 2026
a029fa2
Post survey flow fixes
stephenhand Jun 22, 2026
3f9138f
Fix bodyless webhook validation
stephenhand Jun 22, 2026
8c1aab5
Use POST for webhook validation
stephenhand Jun 22, 2026
50eb15c
Use POST for webhook to fix validation
stephenhand Jun 22, 2026
98149f5
Logging
stephenhand Jun 22, 2026
2cf4ad1
Try twiml
stephenhand Jun 22, 2026
88f8d37
Add call to twiml and logging
stephenhand Jun 22, 2026
e621c80
typo
stephenhand Jun 22, 2026
c436e5b
fix url
stephenhand Jun 22, 2026
939d5df
fix xml responses
stephenhand Jun 22, 2026
24642fc
fix query ordering
stephenhand Jun 22, 2026
2f8a9f8
fix post survey q names
stephenhand Jun 22, 2026
7884550
fix post survey q names
stephenhand Jun 22, 2026
3aafaae
Try sip
stephenhand Jun 23, 2026
4c63f12
Try sip
stephenhand Jun 23, 2026
ee8f32d
Try sip
stephenhand Jun 23, 2026
7c20333
Try sip
stephenhand Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion aselo-webchat-react-app/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
-->
<link rel="shortcut icon" href="https://media.twiliocdn.com/sdk/js/webchat-v3/assets/favicon.ico">
<title>Twilio Webchat React App</title>
<link rel="stylesheet" href="./app.css">
<script defer src="./app.js"></script>
</head>

Expand Down
9 changes: 9 additions & 0 deletions lambdas/account-scoped/src/albResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export const okJsonResponse = (body: any = {}): ALBResult => ({
body: JSON.stringify(body),
});

export const okXmlResponse = (body: string = ''): ALBResult => ({
headers: {
...CORS_HEADERS,
'Content-Type': 'application/xml',
},
statusCode: 200,
body,
});

export const notFoundResponse = (event: ALBEvent): ALBResult => ({
statusCode: 404,
headers: {
Expand Down
115 changes: 12 additions & 103 deletions lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -724,10 +626,11 @@ export const handleChannelRelease = async ({
}) => {
try {
// get the control task
const controlTask = await twilioClient.taskrouter.v1
const controlTaskContext = twilioClient.taskrouter.v1
.workspaces(twilioWorkspaceSid)
.tasks(capturedChannelAttributes.controlTaskSid)
.fetch();
.tasks(capturedChannelAttributes.controlTaskSid);
// get the control task
const controlTask = await controlTaskContext.fetch();

if (capturedChannelAttributes.releaseType === 'triggerStudioFlow') {
await createStudioFlowTrigger(
Expand All @@ -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({});
Expand Down
61 changes: 0 additions & 61 deletions lambdas/account-scoped/src/channelCapture/hrmDataManipulation.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import {
registerTaskRouterEventHandler,
TaskRouterEventHandler,
} from '../taskrouter/taskrouterEventHandler';
import { AccountSID } from '@tech-matters/twilio-types';
import TwilioSDK, { Twilio } from 'twilio';
import { EventType, TASK_WRAPUP } from '../taskrouter/eventTypes';
import { EventFields } from '../taskrouter';
import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration';
import { isChatCaptureControlTask } from './channelCaptureHandlers';
import VoiceResponse = TwilioSDK.twiml.VoiceResponse;

// TODO: factor out
type TransferMeta = {
mode: 'COLD' | 'WARM';
transferStatus: 'transferring' | 'accepted' | 'rejected';
sidWithTaskControl: string;
};

const isTriggerPostStudioFlow = ({
taskAttributes,
}: {
eventType: EventType;
taskChannelUniqueName: string;
taskAttributes: {
transferMeta?: TransferMeta;
isChatCaptureControl?: boolean;
};
}) => {
return !isChatCaptureControlTask(taskAttributes);
};

const triggerPostStudioFlowTaskRouterListener: TaskRouterEventHandler = async (
event: EventFields,
accountSid: AccountSID,
client: Twilio,
) => {
const logPrefix = `[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]:`;
try {
const {
EventType: eventType,
TaskChannelUniqueName: taskChannelUniqueName,
TaskAttributes: taskAttributesString,
TaskSid: taskSid,
} = event;

const taskAttributes = JSON.parse(taskAttributesString);

if (isTriggerPostStudioFlow({ eventType, taskAttributes, taskChannelUniqueName })) {
console.info(`${logPrefix} Handling post studio flow trigger...`);
console.debug('[SENSITIVE] taskAttributes', taskAttributes);

// This task is a candidate to trigger post survey. Check feature flags for the account.
const serviceConfigAttributes =
await retrieveServiceConfigurationAttributes(client);
const { postStudioFlows } = serviceConfigAttributes;
const studioFlowIdentifier = postStudioFlows?.[taskChannelUniqueName] ?? '';

if (typeof studioFlowIdentifier === 'object') {
const { sipEndpoint, sipUsername, sipPassword } = studioFlowIdentifier;
const { conference, contactId } = taskAttributes;
if (taskChannelUniqueName === 'voice' && conference) {
const conferenceContext = client.conferences.get(conference.sid);
// 1. Fetch all active participants in the conference
const allParticipants = await conferenceContext.participants.list();
console.debug(
`${logPrefix} ${allParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`,
allParticipants,
);
const connectedParticipants = allParticipants.filter(
p => p.status === 'connected',
);
console.debug(
`${logPrefix} ${connectedParticipants.length} participants on conference: ${conference.sid} at ${eventType}.`,
connectedParticipants,
);
if (connectedParticipants.length === 1) {
const [participant] = connectedParticipants;
try {
await participant.update({
hold: true,
});
console.debug(
`${logPrefix} Put participant ${participant.callSid} from conference ${conference.sid} on hold.`,
);
const twiml = new VoiceResponse();
const dial = twiml.dial();
dial.sip(
{
username: sipUsername,
password: sipPassword,
},
`${sipEndpoint}?x-contactId=${contactId}&x-taskSid=${taskSid}`,
);
await client.calls.get(participant.callSid).update({
twiml,
});
console.debug(
`${logPrefix} Dialed ${studioFlowIdentifier} passing ${contactId} and ${taskSid} to start post survey.`,
);
} catch (err) {
await participant.remove();
console.debug(
`${logPrefix} Removed participant ${participant.callSid} from conference ${conference.sid}.`,
);
console.error(
`${logPrefix} triggerPostStudioFlowTaskRouterListener for participant ${participant.callSid} failed`,
err,
);
}
} else {
console.debug(
`${logPrefix} Only valid for redirecting to studio flow if there is only one connected participant on the conference`,
);
}
} else {
console.warn(
`${logPrefix} Only tasks with a taskChannelUniqueName of 'voice' and a conference object in the attributes are supported for post task studio flows`,
`taskChannelUniqueName: ${taskChannelUniqueName}`,
`conference: ${conference}`,
);
}

console.info(`${logPrefix} Finished handling post studio flow trigger.`);
} else {
console.debug(`No post studio flow configured for ${taskChannelUniqueName}`);
}
}
} catch (err) {
console.error(
`[Post Survey Studio Flow - ${accountSid}/${event.TaskSid}]: triggerPostStudioFlowTaskRouterListener failed`,
err,
);
}
};

registerTaskRouterEventHandler([TASK_WRAPUP], triggerPostStudioFlowTaskRouterListener);
Loading
Loading