diff --git a/lambdas/account-scoped/src/taskrouter/index.ts b/lambdas/account-scoped/src/taskrouter/index.ts index b9aa40c8b1..74c6dd869a 100644 --- a/lambdas/account-scoped/src/taskrouter/index.ts +++ b/lambdas/account-scoped/src/taskrouter/index.ts @@ -21,6 +21,7 @@ import '../task/addCustomerExternalIdTaskRouterListener'; import '../task/addInitialHangUpByTaskRouterListener'; import '../conversation/addTaskSidToChannelAttributesTaskRouterListener'; import '../channelCapture/postSurveyListener'; +import '../transfer/transferTaskRouterListener'; export { handleTaskRouterEvent } from './taskrouterEventHandler'; diff --git a/lambdas/account-scoped/src/transfer/transferStart.ts b/lambdas/account-scoped/src/transfer/transferStart.ts index f0321309da..d5bd703006 100644 --- a/lambdas/account-scoped/src/transfer/transferStart.ts +++ b/lambdas/account-scoped/src/transfer/transferStart.ts @@ -26,6 +26,7 @@ import { isErr, newErr, newOk, Result } from '../Result'; import { WorkerInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/worker'; import { getSsmParameter } from '@tech-matters/ssm-cache'; import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration'; +import { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task'; export type Body = { taskSid: TaskSID; @@ -170,6 +171,68 @@ async function increaseChatCapacity( } } +export const coldTransferCallToQueue = async ( + accountSid: AccountSID, + originalTask: TaskInstance, + newAttributes: any, +): Promise => { + console.debug( + `[transfer: original task: ${originalTask.sid}] Cold transferring call to queue`, + ); + const client = await getTwilioClient(accountSid); + const programmableChatTransferWorkflowSid = + await getConversationsTransferWorkflow(accountSid); + const { conference: taskConferenceInfo } = newAttributes; + console.debug( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Found conference info`, + ); + const conferenceContext = client.conferences.get(taskConferenceInfo.sid); + console.debug( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Found conference instance`, + ); + const originalAgentCallSid = taskConferenceInfo.participants.worker; + const conferenceParticipants = await conferenceContext.participants.list(); + const originalAgentParticipant = conferenceParticipants.find( + p => p.callSid === originalAgentCallSid, + ); + if (originalAgentParticipant) { + await originalAgentParticipant.update({ endConferenceOnExit: false }); + console.debug( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Found original agent to remove from conference: ${originalAgentParticipant?.callSid}`, + ); + } else { + console.warn( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Could not find original agent to remove from conference`, + conferenceParticipants, + ); + } + await Promise.all( + conferenceParticipants + .filter(p => p.callSid !== originalAgentCallSid) + .map(p => { + console.debug( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Putting participant on hold: ${p.callSid}`, + p, + ); + return p.update({ hold: true }); + }), + ); + await originalAgentParticipant?.remove(); + // create New task + const newTask = await client.taskrouter.v1.workspaces + .get(await getWorkspaceSid(accountSid)) + .tasks.create({ + workflowSid: programmableChatTransferWorkflowSid, + taskChannel: originalTask.taskChannelUniqueName, + attributes: JSON.stringify(newAttributes), + priority: 100, + }); + console.debug( + `[transfer: original task: ${originalTask.sid}, conference: ${taskConferenceInfo.sid}] Transfer target task created with sid ${newTask.sid}`, + ); + return newTask.sid as TaskSID; +}; + export const transferStartHandler: AccountScopedHandler = async ( { body: event }: HttpRequest, accountSid: AccountSID, @@ -241,8 +304,17 @@ export const transferStartHandler: AccountScopedHandler = async ( transferTargetType, }; + if (originalTask.taskChannelUniqueName === 'voice') { + const newTaskSid = await coldTransferCallToQueue( + accountSid, + originalTask, + newAttributes, + ); + return newOk({ taskSid: newTaskSid }); + } + /** - * Check if is transfering a conversation. + * Check if is transferring a conversation. * It might be better to accept an `isConversation` parameter. * But for now, we can check if a conversation exists given a conversationId. */ diff --git a/lambdas/account-scoped/src/transfer/transferTaskRouterListener.ts b/lambdas/account-scoped/src/transfer/transferTaskRouterListener.ts new file mode 100644 index 0000000000..5c17252abc --- /dev/null +++ b/lambdas/account-scoped/src/transfer/transferTaskRouterListener.ts @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2021-2025 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 { EventFields } from '../taskrouter'; +import { AccountSID } from '@tech-matters/twilio-types'; +import { Twilio } from 'twilio'; +import { registerTaskRouterEventHandler } from '../taskrouter/taskrouterEventHandler'; +import { TASK_QUEUE_ENTERED } from '../taskrouter/eventTypes'; +import { getWorkspaceSid } from '@tech-matters/twilio-configuration'; + +export const handleQueueTransferEvent = async ( + { + TaskAttributes: taskAttributesString, + TaskSid: taskSid, + TaskChannelUniqueName: taskChannelUniqueName, + }: EventFields, + accountSid: AccountSID, + client: Twilio, +): Promise => { + const taskAttributes = JSON.parse(taskAttributesString); + if ( + taskChannelUniqueName !== 'voice' || + taskAttributes?.transferMeta?.transferStatus !== 'accepted' + ) { + return; + } + const { originalTask: originalTaskSid } = taskAttributes.transferMeta; + console.info( + `Handling ${taskChannelUniqueName} transfer for target task ${taskSid} to queue from original task ${originalTaskSid} entering target queue...`, + ); + + await client.taskrouter.v1.workspaces + .get(await getWorkspaceSid(accountSid)) + .tasks.get(originalTaskSid) + .update({ + assignmentStatus: 'completed', + reason: 'task transferred into queue', + }); +}; + +registerTaskRouterEventHandler([TASK_QUEUE_ENTERED], handleQueueTransferEvent); diff --git a/plugin-hrm-form/src/services/ServerlessService.ts b/plugin-hrm-form/src/services/ServerlessService.ts index c823298575..f5a59a65b3 100644 --- a/plugin-hrm-form/src/services/ServerlessService.ts +++ b/plugin-hrm-form/src/services/ServerlessService.ts @@ -20,36 +20,13 @@ /* eslint-disable sonarjs/prefer-immediate-return */ /* eslint-disable camelcase */ -import { ITask, Notifications } from '@twilio/flex-ui'; +import { ITask } from '@twilio/flex-ui'; import { DefinitionVersion, loadDefinition } from 'hrm-form-definitions'; import fetchProtectedApi from './fetchProtectedApi'; import type { ChildCSAMReportForm, CounselorCSAMReportForm } from '../states/csam-report/types'; import { getHrmConfig } from '../hrmConfig'; -type TransferChatStartBody = { - taskSid: string; - targetSid: string; - ignoreAgent: string; - mode: string; -}; - -type TrasferChatStartReturn = { closed: string; kept: string }; - -export const transferChatStart = async (body: TransferChatStartBody): Promise => { - try { - const result = await fetchProtectedApi('/transferChatStart', body); - return result; - } catch (err) { - Notifications.showNotification('TransferFailed', { - reason: `Worker ${body.targetSid} is not available.`, - }); - - // propagate the error - throw err; - } -}; - export const issueSyncToken = async (): Promise => { const res = await fetchProtectedApi('/issueSyncToken'); const syncToken = res.token; diff --git a/plugin-hrm-form/src/services/transferService.ts b/plugin-hrm-form/src/services/transferService.ts new file mode 100644 index 0000000000..49e0575454 --- /dev/null +++ b/plugin-hrm-form/src/services/transferService.ts @@ -0,0 +1,33 @@ +/** + * 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 fetchProtectedApi from './fetchProtectedApi'; +import { TaskSID, WorkerSID } from '../types/twilio'; + +type TransferChatStartBody = { + taskSid: TaskSID; + targetSid: string; + ignoreAgent: WorkerSID; + mode: 'WARM' | 'COLD'; +}; + +type TransferChatStartReturn = { closed: string; kept: string }; + +export const transferStart = async (body: TransferChatStartBody): Promise => + fetchProtectedApi('transfer/transferStart', body, { useTwilioLambda: true }); + +export const serverlessChatTransferStart = async (body: TransferChatStartBody): Promise => + fetchProtectedApi('transferChatStart', body, { useTwilioLambda: false }); diff --git a/plugin-hrm-form/src/states/DomainConstants.ts b/plugin-hrm-form/src/states/DomainConstants.ts index ed3325f916..e27caa7a4f 100644 --- a/plugin-hrm-form/src/states/DomainConstants.ts +++ b/plugin-hrm-form/src/states/DomainConstants.ts @@ -76,7 +76,7 @@ export type ChannelColors = { export const transferModes = { cold: 'COLD', warm: 'WARM', -}; +} as const; export const transferStatuses = { transferring: 'transferring', diff --git a/plugin-hrm-form/src/transfer/setUpTransferActions.tsx b/plugin-hrm-form/src/transfer/setUpTransferActions.tsx index f582e1b590..3028d36aa5 100644 --- a/plugin-hrm-form/src/transfer/setUpTransferActions.tsx +++ b/plugin-hrm-form/src/transfer/setUpTransferActions.tsx @@ -30,17 +30,20 @@ import { import * as TransferHelpers from './transferTaskState'; import { transferModes } from '../states/DomainConstants'; import { recordEvent } from '../fullStory'; -import { transferChatStart } from '../services/ServerlessService'; -import { getHrmConfig } from '../hrmConfig'; +import { getAseloFeatureFlags, getHrmConfig } from '../hrmConfig'; import { RootState } from '../states'; import { reactivateAseloListeners } from '../conversationListeners'; import selectContactByTaskSid from '../states/contacts/selectContactByTaskSid'; import { ContactState } from '../states/contacts/existingContacts'; import { saveFormSharedState } from './formDataTransfer'; +import { serverlessChatTransferStart, transferStart } from '../services/transferService'; type SetupObject = ReturnType; type ActionPayload = { task: ITask }; -type ActionPayloadWithOptions = ActionPayload & { options: { mode: string }; targetSid: string }; +type ActionPayloadWithOptions = ActionPayload & { + options: { mode: typeof transferModes[keyof typeof transferModes] }; + targetSid: string; +}; const DEFAULT_TRANSFER_MODE = transferModes.cold; export const TransfersNotifications = { @@ -71,12 +74,14 @@ const getStateContactForms = (taskSid: string): ContactState => { }; /** - * Custom override for TransferTask action. Saves the form to share with another counselor (if possible) and then starts the transfer + * Custom override for TransferTask action. Saves the form to ensure it's up to date on the backend (if possible) and then starts the transfer */ const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => async ( payload: ActionPayloadWithOptions, original: ActionFunction, + // eslint-disable-next-line sonarjs/cognitive-complexity ) => { + const featureFlags = getAseloFeatureFlags(); const mode = payload.options.mode || DEFAULT_TRANSFER_MODE; /* @@ -110,9 +115,9 @@ const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => const { conferenceSid } = payload.task.conference || {}; const conferenceSidFromAttributes = payload.task.attributes?.conference?.sid; if (!conferenceSid && !conferenceSidFromAttributes) { - console.log('>> Could not find any conferenceSid'); + console.debug('>> Could not find any conferenceSid'); } else if (conferenceSid && !conferenceSidFromAttributes) { - console.log('>> Updating task attributes with conferenceSid'); + console.debug('>> Updating task attributes with conferenceSid'); // const customer = payload.task.conference?.participants.find(p => p.participantType === 'customer').participantSid; await payload.task.setAttributes({ ...payload.task.attributes, @@ -125,13 +130,14 @@ const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => }, }); } - const disableTransfer = !TransferHelpers.canTransferConference(payload.task); if (disableTransfer) { window.alert(Manager.getInstance().strings['Transfer-CannotTransferTooManyParticipants']); } else { - return safeTransfer(() => original(payload), payload.task); + const targetType = payload.targetSid.startsWith('WK') ? 'worker' : 'queue'; + const featureFlag = `use_custom_voice_transfers_for_${mode.toLowerCase()}_${targetType}_transfers`; + if (!getAseloFeatureFlags()[featureFlag]) return safeTransfer(() => original(payload), payload.task); } } @@ -142,7 +148,13 @@ const customTransferTask = (setupObject: SetupObject): ReplacedActionFunction => ignoreAgent: workerSid, }; - return safeTransfer(() => transferChatStart(body), payload.task); + return safeTransfer( + () => + featureFlags.use_twilio_lambda_for_starting_chat_transfers + ? transferStart(body) + : serverlessChatTransferStart(body), + payload.task, + ); }; const afterCancelTransfer = (payload: ActionPayload) => TransferHelpers.clearTransferMeta(payload.task); diff --git a/plugin-hrm-form/src/types/FeatureFlags.ts b/plugin-hrm-form/src/types/FeatureFlags.ts index a92274a098..c203ea81f4 100644 --- a/plugin-hrm-form/src/types/FeatureFlags.ts +++ b/plugin-hrm-form/src/types/FeatureFlags.ts @@ -47,4 +47,6 @@ export type FeatureFlags = { use_twilio_lambda_for_conference_functions: boolean; // Use the twilio account scoped lambda for conferencing functions use_twilio_lambda_for_conversation_duration: boolean; // Use the twilio account scoped lambda to calculate conversationDuration use_twilio_lambda_for_task_assignment: boolean; // Use the twilio account scoped lambda for getTasksAndReservations, checkTaskAssignment, completeTaskAssignment + use_custom_voice_transfers_for_cold_queue_transfers: boolean; + use_twilio_lambda_for_starting_chat_transfers: boolean; };