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/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/channelCaptureHandlers.ts b/lambdas/account-scoped/src/channelCapture/channelCaptureHandlers.ts
index 3c387e17a4..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,105 +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;
-
- try {
- if (!postSurveyConfigSpecs?.length) {
- const errorMEssage = `No defined or invalid postSurveyConfigJson found for account ${accountSid}.`;
- throw new Error(errorMEssage);
- }
-
- 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,
- }),
- ]);
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- console.error(`Error accessing to the post survey form definitions: ${message}`);
- } finally {
- // 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' });
- }
-};
-
export const handleChannelRelease = async ({
accountSid,
capturedChannelAttributes,
@@ -729,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(
@@ -743,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..e347006462
--- /dev/null
+++ b/lambdas/account-scoped/src/channelCapture/postStudioFlowTaskRouterListener.ts
@@ -0,0 +1,212 @@
+/**
+ * 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;
+import { getSyncServiceSid } from '@tech-matters/twilio-configuration';
+import { getPostSurveySyncDocUniqueName } from '../hrm/savePostSurvey';
+import RestException from 'twilio/lib/base/RestException';
+
+// 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);
+
+ // 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 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 { from } = await client.calls.get(participant.callSid).fetch();
+ console.debug(
+ `${logPrefix} Retrieved call ${participant.callSid} from conference ${conference.sid}.`,
+ );
+ const uniqueName = getPostSurveySyncDocUniqueName(from);
+ const docList = client.sync.v1.services.get(
+ await getSyncServiceSid(accountSid),
+ ).documents;
+ try {
+ await docList.get(uniqueName).update({
+ data: {
+ taskSid,
+ contactId,
+ },
+ ttl: 24 * 60 * 60,
+ });
+ console.debug(
+ `${logPrefix} Updated existing sync document ${uniqueName}.`,
+ );
+ } catch (err) {
+ if ((err as RestException).status === 404) {
+ console.debug(
+ `${logPrefix} No existing sync document ${uniqueName} to update.`,
+ );
+ await docList.create({
+ uniqueName,
+ data: {
+ taskSid,
+ contactId,
+ },
+ ttl: 24 * 60 * 60,
+ });
+ console.debug(
+ `${logPrefix} Before dialing participant ${participant.callSid} from conference ${conference.sid} into a new call, contact ID ${contactId} and task SID ${taskSid} were stashed under sync doc ${uniqueName} for use in the post flow.`,
+ );
+ } else {
+ console.error(
+ `${logPrefix} Error updating sync document ${uniqueName}`,
+ err,
+ );
+ }
+ }
+ const twiml = new VoiceResponse();
+ twiml.dial(studioFlowIdentifier);
+ await client.calls.get(participant.callSid).update({
+ twiml,
+ });
+ console.debug(
+ `${logPrefix} Dialed ${studioFlowIdentifier} 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 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: {
+ contactId,
+ contactTaskSid: taskSid,
+ },
+ 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(
+ `${logPrefix} No post studio flow configured for ${taskChannelUniqueName}`,
+ );
+ }
+
+ console.info(`${logPrefix} Finished handling post studio flow trigger.`);
+ } 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}`,
+ );
+ }
+ }
+ } 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/channelCapture/postSurveyListener.ts b/lambdas/account-scoped/src/channelCapture/postSurveyListener.ts
index 0f9c7cdcb2..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,
@@ -32,10 +35,13 @@ import {
getChatServiceSid,
getHelplineCode,
getSurveyWorkflowSid,
- getTwilioWorkspaceSid,
+ getTwilioClient,
+ getWorkspaceSid,
} 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';
@@ -54,7 +60,6 @@ type TransferMeta = {
};
const isTriggerPostSurvey = ({
- eventType,
taskAttributes,
taskChannelUniqueName,
}: {
@@ -65,8 +70,6 @@ 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;
@@ -87,6 +90,7 @@ const postSurveyInitHandler = async ({
webhookBaseUrl,
channelSid,
conversationSid,
+ contactId,
}: {
accountSid: AccountSID;
client: Twilio;
@@ -99,6 +103,7 @@ const postSurveyInitHandler = async ({
helplineCode: string;
surveyWorkflowSid: string;
twilioWorkspaceSid: string;
+ contactId: string;
} & (
| {
channelSid: string;
@@ -127,6 +132,7 @@ 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,
@@ -157,8 +163,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 serviceConfig = await retrieveServiceConfiguration(client);
@@ -175,7 +181,7 @@ const triggerPostSurvey: TaskRouterEventHandler = async (
throw new Error(errorMessage);
}
- const { channelSid, conversationSid, channelType, customChannelType } =
+ const { channelSid, conversationSid, channelType, customChannelType, contactId } =
taskAttributes;
const taskLanguage = getTaskLanguage(helplineLanguage)(taskAttributes);
@@ -185,10 +191,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,
@@ -203,9 +210,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) {
@@ -214,3 +223,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/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..7a74dc8fb3
--- /dev/null
+++ b/lambdas/account-scoped/src/hrm/savePostSurvey.ts
@@ -0,0 +1,208 @@
+/**
+ * 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.replaceAll('+', '')}`;
+
+export const handleSavePostSurvey: AccountScopedHandler = async (
+ request,
+ accountSid,
+): Promise> => {
+ let { postSurveyAnswers, clientIdentifier, contactId, contactTaskSid } = request.body;
+ const twilioClient = await getTwilioClient(accountSid);
+ 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: 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' });
+ 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 ae6b846509..2426e5fd59 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,8 @@ 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 {startPostSurveyChatbotHandler} from "./channelCapture/postSurveyListener";
/**
* Super simple router sufficient for directly ported Twilio Serverless functions
@@ -95,6 +98,10 @@ const ACCOUNTSID_ROUTES: Record<
requestPipeline: [validateWebhookRequest],
handler: handleTaskRouterEvent,
},
+ 'hrm/savePostSurvey': {
+ requestPipeline: [validateWebhookRequest],
+ handler: handleSavePostSurvey,
+ },
getProfileFlagsForIdentifier: {
requestPipeline: [validateWebhookRequest],
handler: handleGetProfileFlagsForIdentifier,
@@ -111,6 +118,10 @@ const ACCOUNTSID_ROUTES: Record<
requestPipeline: [validateWebhookRequest],
handler: handleChatbotCallbackCleanup,
},
+ 'channelCapture/startPostSurveyChatbot': {
+ requestPipeline: [validateWebhookRequest],
+ handler: startPostSurveyChatbotHandler,
+ },
'conference/conferenceStatusCallback': {
requestPipeline: [validateWebhookRequest],
handler: conferenceStatusCallbackHandler,
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/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 97f104a225..05041c4e94 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 8d1664347d..279caa6c7f 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": {