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
165 changes: 165 additions & 0 deletions lambdas/account-scoped/src/hrm/conversationMedia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Copyright (C) 2021-2026 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 { AccountSID, CallSid } from '@tech-matters/twilio-types';
import { HrmContact } from '@tech-matters/hrm-types';
import { isOk, Result } from '../Result';
import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location';
import { getDocsBucketName } from '@tech-matters/twilio-configuration';
import { postToInternalHrmEndpoint } from './internalHrmRequest';
import { HrmAccountId } from './hrmAccountId';

export type S3Location = {
bucket: string;
key: string;
};

export type TwilioStoredMedia = {
storeType: 'twilio';
storeTypeSpecificData: {
reservationSid: string;
};
};

export type S3StoredMedia = {
storeType: 'S3';
storeTypeSpecificData: {
type: 'transcript' | 'recording';
location?: S3Location;
};
};

export type ConversationMedia = TwilioStoredMedia | S3StoredMedia;

export const newTwilioStoredMedia = (reservationSid: string): TwilioStoredMedia => ({
storeType: 'twilio',
storeTypeSpecificData: {
reservationSid,
},
});

export const newPendingS3StoredTranscript = (): S3StoredMedia => ({
storeType: 'S3',
storeTypeSpecificData: {
type: 'transcript',
location: undefined,
},
});

export const newS3StoredRecording = (location: S3Location): S3StoredMedia => ({
storeType: 'S3',
storeTypeSpecificData: {
type: 'recording',
location,
},
});

/**
* Looks up the S3 location the external recording for a call will be written to and returns a
* conversation media item pointing at it, or undefined if no recording could be located.
*/
export const newS3StoredRecordingForCall = async ({
accountSid,
callSid,
}: {
accountSid: AccountSID;
callSid: CallSid | string;
}): Promise<S3StoredMedia | undefined> => {
const recordingResult = await getExternalRecordingS3Location({ accountSid, callSid });
if (!isOk(recordingResult)) {
console.warn(
`[${accountSid}] Could not find an external recording location for call ${callSid}, no recording conversation media will be added`,
recordingResult.message,
);
return undefined;
}
const { bucket, key } = recordingResult.data;
return newS3StoredRecording({ bucket, key });
};

/**
* Returns the recording conversation media for a voice task, using the recording location already
* attached to the task attributes if there is one, otherwise looking it up from the call.
*/
export const newS3StoredRecordingForVoiceTask = async ({
accountSid,
taskAttributes,
}: {
accountSid: AccountSID;
taskAttributes: {
conference?: { participants?: { worker?: CallSid } };
conversations?: { segment_link?: string };
};
}): Promise<S3StoredMedia | undefined> => {
const { conference, conversations } = taskAttributes;
const segmentLink = conversations?.segment_link;
if (segmentLink) {
// The recording location is already added to the task, no need to look it up
const { pathname } = new URL(segmentLink);
return newS3StoredRecording({
bucket: await getDocsBucketName(accountSid),
key: pathname.startsWith('/') ? pathname.substring(1) : pathname,
});
}
const callSid = conference?.participants?.worker;
if (!callSid) {
console.warn(
`[${accountSid}] Could not find a call sid for the worker in the conference attached to the task, no recording conversation media will be added`,
);
return undefined;
}
return newS3StoredRecordingForCall({ accountSid, callSid });
};

export const saveConversationMedia = async ({
hrmAccountId,
hrmApiVersion,
contactId,
conversationMedia,
}: {
hrmAccountId: HrmAccountId;
hrmApiVersion: string;
contactId: string | number;
conversationMedia: ConversationMedia[];
}): Promise<Result<Error, HrmContact> | undefined> => {
if (!conversationMedia.length) {
console.debug(
`[${hrmAccountId}] No conversation media to add to contact ${contactId}, skipping`,
);
return undefined;
}
const conversationMediaResult = await postToInternalHrmEndpoint<
ConversationMedia[],
HrmContact
>(
hrmAccountId,
hrmApiVersion,
`contacts/${contactId}/conversationMedia`,
conversationMedia,
);
if (isOk(conversationMediaResult)) {
console.info(
`[${hrmAccountId}] Added ${conversationMedia.length} conversation media item(s) to contact ${contactId}`,
);
} else {
console.error(
`[${hrmAccountId}] Failed to add conversation media to contact ${contactId}`,
conversationMediaResult.message,
conversationMediaResult.error,
);
}
return conversationMediaResult;
};
176 changes: 176 additions & 0 deletions lambdas/account-scoped/src/hrm/conversationMediaTaskRouterListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Copyright (C) 2021-2026 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, channelTypes, ChannelType } from '@tech-matters/twilio-types';
import { registerTaskRouterEventHandler } from '../taskrouter/taskrouterEventHandler';
import { TASK_COMPLETED } from '../taskrouter/eventTypes';
import { Twilio } from 'twilio';
import { getWorkspaceSid } from '@tech-matters/twilio-configuration';
import { retrieveServiceConfigurationAttributes } from '../configuration/aseloConfiguration';
import { inferHrmAccountId } from './hrmAccountId';
import {
ConversationMedia,
newPendingS3StoredTranscript,
newS3StoredRecordingForVoiceTask,
newTwilioStoredMedia,
saveConversationMedia,
} from './conversationMedia';

const CHAT_CHANNEL_TYPES: string[] = [
channelTypes.WEB,
channelTypes.CHAT,
channelTypes.SMS,
channelTypes.WHATSAPP,
channelTypes.MESSENGER,
channelTypes.INSTAGRAM,
channelTypes.LINE,
channelTypes.MODICA,
channelTypes.TELEGRAM,
];

const isChatChannel = (channel?: ChannelType | string): boolean =>
Boolean(channel && CHAT_CHANNEL_TYPES.includes(channel));

const isVoiceChannel = (channel?: ChannelType | string): boolean =>
channel === channelTypes.VOICE;

/**
* Finds the sid of the reservation that currently has control of the task, so it can be used to
* look up the conversation in the Twilio Insights overlay.
*/
const findReservationSidWithTaskControl = async (
accountSid: AccountSID,
client: Twilio,
taskSid: string,
taskAttributes: Record<string, any>,
): Promise<string | undefined> => {
const sidWithTaskControl = taskAttributes?.transferMeta?.sidWithTaskControl;
if (sidWithTaskControl) {
return sidWithTaskControl;
}
try {
const reservations = await client.taskrouter.v1.workspaces
.get(await getWorkspaceSid(accountSid))
.tasks.get(taskSid)
.reservations.list();
const activeReservation =
reservations.find(r =>
['wrapping', 'accepted', 'completed'].includes(r.reservationStatus),
) ?? reservations[0];
return activeReservation?.sid;
} catch (error) {
console.error(
`[${accountSid}] Failed to look up reservations for task ${taskSid}, no Twilio stored conversation media will be added`,
error,
);
return undefined;
}
};

export const handleEvent = async (
{
TaskAttributes: taskAttributesString,
TaskSid: taskSid,
WorkerName: workerName,
}: EventFields,
accountSid: AccountSID,
client: Twilio,
): Promise<void> => {
const serviceConfigurationAttributes =
await retrieveServiceConfigurationAttributes(client);
const {
hrm_api_version: hrmApiVersion,
feature_flags: {
use_twilio_lambda_for_conversation_media: useTwilioLambdaForConversationMedia,
},
} = serviceConfigurationAttributes;

if (!useTwilioLambdaForConversationMedia) {
console.debug(
`use_twilio_lambda_for_conversation_media is not set, the conversation media for the contact associated with task ${taskSid} will be created in Flex.`,
);
return;
}

const taskAttributes = taskAttributesString ? JSON.parse(taskAttributesString) : {};
const { contactId, channelType, customChannelType } = taskAttributes;

if (!contactId) {
console.debug(
`No contactId set on task ${taskSid}, cannot add conversation media to a contact.`,
);
return;
}

const channel = customChannelType || channelType;

if (channel === channelTypes.VOICEMAIL) {
console.debug(
`Task ${taskSid} is a voicemail task, its conversation media is added when the voicemail contact is created.`,
);
return;
}

const enforceZeroTranscriptRetention = Boolean(
serviceConfigurationAttributes.enforceZeroTranscriptRetention,
);
const externalRecordingsEnabled = Boolean(
serviceConfigurationAttributes.external_recordings_enabled,
);

const isChatTask = isChatChannel(channel);
const isVoiceTask = isVoiceChannel(channel);
const retainTranscript = isChatTask && !enforceZeroTranscriptRetention;

const conversationMedia: ConversationMedia[] = [];

if (retainTranscript) {
conversationMedia.push(newPendingS3StoredTranscript());
}

if (retainTranscript || isVoiceTask) {
// Store reservation sid to use Twilio insights overlay (recordings/transcript)
const reservationSid = await findReservationSidWithTaskControl(
accountSid,
client,
taskSid,
taskAttributes,
);
if (reservationSid) {
conversationMedia.push(newTwilioStoredMedia(reservationSid));
}
}

if (isVoiceTask && externalRecordingsEnabled) {
const recordingMedia = await newS3StoredRecordingForVoiceTask({
accountSid,
taskAttributes,
});
if (recordingMedia) {
conversationMedia.push(recordingMedia);
}
}

await saveConversationMedia({
hrmAccountId: inferHrmAccountId(accountSid, workerName),
hrmApiVersion,
contactId,
conversationMedia,
});
};

registerTaskRouterEventHandler([TASK_COMPLETED], handleEvent);
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { HrmContact } from '@tech-matters/hrm-types';
import { populateHrmContactFormFromTaskByMappings } from './populateHrmContactFormFromTaskByMappings';
import { parseISO } from 'date-fns/parseISO';
import { HttpClientError } from '../httpErrors';
import { getExternalRecordingS3Location } from '../conversation/getExternalRecordingS3Location';
import { newS3StoredRecordingForCall, saveConversationMedia } from './conversationMedia';
import { patchTaskAttributes } from '../task/patchTaskAttributes';

// Temporarily copied to this repo, will share the flex types when we move them into the same repo
Expand Down Expand Up @@ -260,37 +260,18 @@ export const handleEvent = async (
console.info(
`Channel type is ${channel}, adding conversation media with call sid ${taskAttributes.callSid}`,
);
const recordingResult = await getExternalRecordingS3Location({
const recordingMedia = await newS3StoredRecordingForCall({
accountSid,
callSid: taskAttributes.callSid,
});

if (isOk(recordingResult)) {
const conversationMedia = [
{
storeType: 'S3',
storeTypeSpecificData: {
type: 'recording',
location: {
bucket: recordingResult.data.bucket,
key: recordingResult.data.key,
},
},
},
];

const conversationMediaResult = await postToInternalHrmEndpoint<
HrmContact['conversationMedia'],
HrmContact
>(
if (recordingMedia) {
await saveConversationMedia({
hrmAccountId,
hrmApiVersion,
`contacts/${id}/conversationMedia`,
conversationMedia,
);
console.debug(
`Conversation media result ${conversationMediaResult.status} ${isOk(conversationMediaResult) ? JSON.stringify(conversationMediaResult.data) : JSON.stringify(conversationMediaResult.error)}`,
);
contactId: id,
conversationMedia: [recordingMedia],
});
}
}
};
Expand Down
Loading