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