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": {