Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lambdas/account-scoped/src/taskrouter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import '../task/addCustomerExternalIdTaskRouterListener';
import '../task/addInitialHangUpByTaskRouterListener';
import '../conversation/addTaskSidToChannelAttributesTaskRouterListener';
import '../channelCapture/postSurveyListener';
import '../transfer/transferTaskRouterListener';

export { handleTaskRouterEvent } from './taskrouterEventHandler';

Expand Down
74 changes: 73 additions & 1 deletion lambdas/account-scoped/src/transfer/transferStart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,6 +171,68 @@ async function increaseChatCapacity(
}
}

export const coldTransferCallToQueue = async (
accountSid: AccountSID,
originalTask: TaskInstance,
newAttributes: any,
): Promise<TaskSID> => {
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,
Expand Down Expand Up @@ -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.
*/
Expand Down
53 changes: 53 additions & 0 deletions lambdas/account-scoped/src/transfer/transferTaskRouterListener.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
25 changes: 1 addition & 24 deletions plugin-hrm-form/src/services/ServerlessService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TrasferChatStartReturn> => {
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<string> => {
const res = await fetchProtectedApi('/issueSyncToken');
const syncToken = res.token;
Expand Down
33 changes: 33 additions & 0 deletions plugin-hrm-form/src/services/transferService.ts
Original file line number Diff line number Diff line change
@@ -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<TransferChatStartReturn> =>
fetchProtectedApi('transfer/transferStart', body, { useTwilioLambda: true });

export const serverlessChatTransferStart = async (body: TransferChatStartBody): Promise<TransferChatStartReturn> =>
fetchProtectedApi('transferChatStart', body, { useTwilioLambda: false });
2 changes: 1 addition & 1 deletion plugin-hrm-form/src/states/DomainConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export type ChannelColors = {
export const transferModes = {
cold: 'COLD',
warm: 'WARM',
};
} as const;

export const transferStatuses = {
transferring: 'transferring',
Expand Down
30 changes: 21 additions & 9 deletions plugin-hrm-form/src/transfer/setUpTransferActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getHrmConfig>;
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 = {
Expand Down Expand Up @@ -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;

/*
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
}

Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions plugin-hrm-form/src/types/FeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading