Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,57 @@
* 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,
} 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;
};

const handler: ConferenceStatusEventHandler = async (event, _accountSid, client) => {
if (event.StatusCallbackEvent !== 'participant-leave') {
Expand All @@ -31,85 +77,73 @@ const handler: ConferenceStatusEventHandler = async (event, _accountSid, client)
ConferenceSid: conferenceSid,
CallSid: callSid,
CustomerCallSid: customerCallSid,
TaskSid: taskSid,
WorkspaceSid: workspaceSid,
} = event;
const remainingParticipants = await client.conferences
.get(conferenceSid)
.participants.list();
let agentStillInConference = false;
for (const participant of remainingParticipants) {
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`,
);
continue;
}
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`,
);
continue;
}
// 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`,
);
continue;
}
console.debug(
`Participant ${participant.label} (${participant.callSid}) not identified as the service user or an external party, so must be an agent, keep recording`,
);
agentStillInConference = true;
break;
const agentStillInConference = remainingParticipants.some(participant =>
isAgentInConference({ callSid, customerCallSid, participant }),
);

if (agentStillInConference) {
return;
}
if (!agentStillInConference) {
const conferenceRecordings = await client.conferences
.get(conferenceSid)
.recordings.list();
console.info(
`No participants identified as Aselo agents still in conference ${conferenceSid}, stopping all ${conferenceRecordings.length} recordings`,
);
await Promise.all(
conferenceRecordings.map(async recording => {
try {
if (['in-progress', 'processing'].includes(recording.status)) {
console.debug(
`Pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}`,
recording,
);
return await recording.update({
status: 'paused', // 'stopped' not supported for conferences
});
} else {
console.debug(
`Recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid} in status '${recording.status}' so not attempting to pause`,
recording,
);
}
} catch (error) {
const restError = error as RestException;
if (restError.status === 400 && restError.code === 21220) {
// Often errors of this type are thrown but the recording appears to pause at the correct point.
console.debug(
`An error was thrown pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}, but the pause operation would normally be successful or redundant when this type or error is thrown`,
error,
);
} else {
console.error(
`Error pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}`,
error,
);
}
}
}),
);

console.info(
`No participants identified as Aselo agents still in conference ${conferenceSid}, candidate to stop recordings`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`No participants identified as Aselo agents still in conference ${conferenceSid}, candidate to stop recordings`,
`No participants identified as Aselo agents still in conference ${conferenceSid}, will stop recordings if this is not a transfer`,

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also possibly more of a debug log?

);
const isTaskInControl = await hasTaskControl({
client,
taskSid,
workspaceSid,
});

if (!isTaskInControl) {
return;
}

console.info(`Task ${taskSid} is not a transfer, stopping recordings`);

const conferenceRecordings = await client.conferences
.get(conferenceSid)
.recordings.list();
console.info(`Stopping all ${conferenceRecordings.length} recordings`);
await Promise.all(
conferenceRecordings.map(async recording => {
try {
if (['in-progress', 'processing'].includes(recording.status)) {
console.debug(
`Pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}`,
recording,
);
return await recording.update({
status: 'paused', // 'stopped' not supported for conferences
});
} else {
console.debug(
`Recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid} in status '${recording.status}' so not attempting to pause`,
recording,
);
}
} catch (error) {
const restError = error as RestException;
if (restError.status === 400 && restError.code === 21220) {
// Often errors of this type are thrown but the recording appears to pause at the correct point.
console.debug(
`An error was thrown pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}, but the pause operation would normally be successful or redundant when this type or error is thrown`,
error,
);
} else {
console.error(
`Error pausing recording ${recording.sid} for call ${recording.callSid} on conference ${conferenceSid}`,
error,
);
}
}
}),
);
};

registerTaskRouterEventHandler(['participant-leave'], handler);
61 changes: 61 additions & 0 deletions lambdas/account-scoped/src/transfer/hasTaskControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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 { Twilio } from 'twilio';

// TODO: unify with Flex
export type TransferMeta = {
mode: 'COLD' | 'WARM';
transferStatus: 'transferring' | 'accepted' | 'rejected';
sidWithTaskControl: string;
};

export type ChatTransferTaskAttributes = {
transferMeta?: TransferMeta;
transferTargetType?: 'worker' | 'queue';
};

const hasTransferStarted = (taskAttributes: ChatTransferTaskAttributes) =>
Boolean(taskAttributes && taskAttributes.transferMeta);

export const hasTaskControl = async ({
client,
workspaceSid,
taskSid,
}: {
client: Twilio;
workspaceSid: string;
taskSid: string;
}) => {
const task = await client.taskrouter.v1.workspaces(workspaceSid).tasks(taskSid).fetch();
const taskAttributes = JSON.parse(task.attributes);
if (!hasTransferStarted(taskAttributes)) {
console.debug('hasTaskControl? Yes - Transfer has not started');
return true;
}
const reservations = await client.taskrouter.v1.workspaces
.get(workspaceSid)
.tasks.get(taskSid)
.reservations.list();
const res = Boolean(
reservations.find(r => r.sid === taskAttributes.transferMeta?.sidWithTaskControl),
);
console.debug(
`hasTaskControl? ${res ? 'Yes' : 'No'} - ${
taskAttributes.transferMeta?.sidWithTaskControl
} (taskAttributes.transferMeta?.sidWithTaskControl) IN (${reservations.map(r => r.sid)})`,
);
return res;
};
Loading