437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import SDK from "@ringcentral/sdk";
|
|
import path from "path";
|
|
import { Knex } from "knex";
|
|
import { logger, getNationalNumber, setAsyncInterval } from "./util";
|
|
import { getTicketSubject, getTicketBody } from "./template";
|
|
import { Sonar, gql } from "./sonar";
|
|
import type {
|
|
Contact,
|
|
RCExtension,
|
|
RCMessage,
|
|
RCAudioAttachment,
|
|
Recording,
|
|
Transcription,
|
|
} from "./types";
|
|
import type { StoredVoicemail, StoredRecording } from "knex/types/tables";
|
|
|
|
const SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY = gql`
|
|
query getContactByPhoneNumber($phoneNumber: String!) {
|
|
numbers: phone_numbers(general_search: $phoneNumber) {
|
|
entities {
|
|
contact {
|
|
id
|
|
name
|
|
contactable {
|
|
id
|
|
__typename
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const CREATE_TICKET_QUERY = gql`
|
|
mutation createTicket($input: CreateInternalTicketMutationInput!) {
|
|
ticket: createInternalTicket(input: $input) {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* @param short a path omitting the /restapi and version prefix
|
|
* @param version the API version to target, default: v1.0
|
|
* @returns the full path to give to the RingCentral SDK
|
|
*/
|
|
function rcapi(short: string, version = "v1.0") {
|
|
return path.posix.normalize(`/restapi/${version}/${short}`);
|
|
}
|
|
|
|
interface TicketizeConfig {
|
|
firstRunAge: number;
|
|
extensionToTicketGroup: { [key: string]: number };
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Sonar} sonar
|
|
* @param {SDK} rcsdk
|
|
* @param {Knex} db
|
|
*/
|
|
export function ticketize(
|
|
sonar: Sonar,
|
|
rcsdk: SDK,
|
|
db: Knex,
|
|
{ firstRunAge, extensionToTicketGroup }: TicketizeConfig
|
|
) {
|
|
/**
|
|
* Uploads a file to Sonar, returning its ID.
|
|
* @param data
|
|
* @param fileName
|
|
*/
|
|
async function uploadFile(
|
|
data: ArrayBuffer,
|
|
fileName: string
|
|
): Promise<number> {
|
|
throw new Error("File uploading not yes implemented.");
|
|
}
|
|
|
|
/**
|
|
* Returns all valid extensions (not disaled and included in
|
|
* extensionToTicketGroup).
|
|
*/
|
|
async function getValidRCExtensions() {
|
|
const result = await rcsdk.get(rcapi("/account/~/extension"));
|
|
const { records } = (await result.json()) as { records: RCExtension[] };
|
|
return records.filter(
|
|
({ extensionNumber, status, hidden }) =>
|
|
status === "Enabled" &&
|
|
!hidden &&
|
|
extensionToTicketGroup[extensionNumber] !== undefined
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns `extensionId`s messages that are up to `age` seconds old.
|
|
*
|
|
* @param extensionId
|
|
* @param age the maximum age (in seconds) of voicemails to fetch
|
|
*/
|
|
async function getExtensionVoicemails(extensionId: number, age = 86000) {
|
|
const result = await rcsdk.get(
|
|
rcapi(`/account/~/extension/${extensionId}/message-store`),
|
|
{
|
|
messageType: "VoiceMail",
|
|
dateFrom: new Date(Date.now() - age * 1000).toISOString(),
|
|
}
|
|
);
|
|
return (await result.json()).records as RCMessage[];
|
|
}
|
|
|
|
/**
|
|
* Return a specific messages, mainly for later retrieval of attachments
|
|
*
|
|
* @param extensionId
|
|
* @param messageId
|
|
*/
|
|
async function getExtensionVoicemail(extensionId: number, messageId: number) {
|
|
const result = await rcsdk.get(
|
|
rcapi(`/account/~/extension/${extensionId}/message-store/${messageId}`)
|
|
);
|
|
return result.json() as Promise<RCMessage>;
|
|
}
|
|
|
|
/**
|
|
* Attempts to download the transcription of a voicemail
|
|
* @param message
|
|
*/
|
|
async function getVoicemailTranscription(
|
|
message: RCMessage
|
|
): Promise<Transcription> {
|
|
const status = message.vmTranscriptionStatus;
|
|
if (status !== "Completed" && status !== "CompletedPartially") {
|
|
return {
|
|
status,
|
|
text: null,
|
|
};
|
|
}
|
|
|
|
const transcription = message.attachments.find(
|
|
(attachment) => attachment.type === "AudioTranscription"
|
|
);
|
|
if (!transcription) {
|
|
throw new Error(
|
|
`Transcription status is ${status} but no AudioTranscription attachment found on ${message.id}`
|
|
);
|
|
}
|
|
|
|
const response = await rcsdk.get(transcription.uri);
|
|
return {
|
|
status,
|
|
text: (await response.text()).trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempts to download the recording of a voicemail
|
|
* @param message
|
|
*/
|
|
async function getVoicemailRecording(message: RCMessage): Promise<Recording> {
|
|
const audio = message.attachments.find(
|
|
(attachment) => attachment.type === "AudioRecording"
|
|
) as RCAudioAttachment;
|
|
if (!audio) {
|
|
throw new Error(
|
|
`No AudioRecording attachment found on message ${message.id}`
|
|
);
|
|
}
|
|
const response = await rcsdk.get(audio.uri);
|
|
const result = {
|
|
duration: audio.vmDuration,
|
|
mimeType: audio.contentType,
|
|
audio: await response.arrayBuffer(),
|
|
};
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Retrieve the first contact found associated with a phone number
|
|
* @param phoneNumber the phone number to search with
|
|
*/
|
|
async function searchContactByPhoneNumber(phoneNumber: string) {
|
|
const result = await sonar.request(SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY, {
|
|
phoneNumber: getNationalNumber(phoneNumber),
|
|
});
|
|
if (result.numbers.entities.length === 0) {
|
|
return undefined;
|
|
}
|
|
return result.numbers.entities[0].contact as Contact;
|
|
}
|
|
|
|
/**
|
|
* Creates a ticket out of a stored voicemail
|
|
* @param voicemail
|
|
* @param contact
|
|
*/
|
|
async function createTicket(
|
|
voicemail: StoredVoicemail & StoredRecording,
|
|
contact?: Contact
|
|
) {
|
|
const input: any = {
|
|
subject: getTicketSubject(voicemail, contact),
|
|
description: getTicketBody(voicemail, contact),
|
|
status: "OPEN",
|
|
priority: "MEDIUM",
|
|
ticket_group_id: extensionToTicketGroup[voicemail.extensionNumber],
|
|
};
|
|
|
|
const ticket_group_id = extensionToTicketGroup[voicemail.extensionNumber];
|
|
if (ticket_group_id) {
|
|
input.ticket_group_id = ticket_group_id;
|
|
}
|
|
|
|
if (contact) {
|
|
input.ticketable_type = contact.contactable.__typename;
|
|
input.ticketable_id = contact.contactable.id;
|
|
}
|
|
|
|
const result = await sonar.request(CREATE_TICKET_QUERY, { input });
|
|
return parseInt(result.ticket.id);
|
|
}
|
|
|
|
/**
|
|
* Saves a voicemail to the database
|
|
* @param extension
|
|
* @param message
|
|
* @param recording
|
|
* @param transcription
|
|
*/
|
|
async function storeVoicemail(
|
|
extension: RCExtension,
|
|
message: RCMessage,
|
|
recording: Recording,
|
|
transcription: Transcription
|
|
) {
|
|
await db.transaction(async (trx) => {
|
|
await trx("voicemails").insert({
|
|
messageId: message.id,
|
|
extensionId: message.extensionId,
|
|
received: message.creationTime,
|
|
toNumber: message.to[0].phoneNumber,
|
|
extensionNumber: extension.extensionNumber,
|
|
extensionName: extension.name,
|
|
fromNumber: message.from.phoneNumber,
|
|
fromName: message.from.name,
|
|
transcriptionStatus: transcription.status,
|
|
transcription: transcription.text,
|
|
});
|
|
await trx("recordings").insert({
|
|
messageId: message.id,
|
|
mimeType: recording.mimeType,
|
|
audio: new Uint8Array(recording.audio),
|
|
duration: recording.duration,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates a stored voicemail
|
|
* @param voicemail the voicemail to update
|
|
*/
|
|
async function updateStoredVoicemail(voicemail: Partial<StoredVoicemail>) {
|
|
const messageId = voicemail.messageId;
|
|
if (!messageId) {
|
|
throw new Error("Missing required messageId property");
|
|
}
|
|
await db("voicemails").update(voicemail).where({ messageId });
|
|
}
|
|
|
|
/**
|
|
* @param messageId
|
|
* @returns whether the message by the given ID has been stored
|
|
*/
|
|
async function isMessageStored(messageId: number) {
|
|
const result = await db("voicemails").where({ messageId }).first();
|
|
return result !== undefined;
|
|
}
|
|
|
|
/**
|
|
* @returns stored voicemails that haven't had tickets created for them yet
|
|
*/
|
|
async function getUnprocessedVoicemails() {
|
|
return await db("voicemails")
|
|
.join("recordings", "voicemails.messageId", "recordings.messageId")
|
|
.whereNull("ticketId")
|
|
.whereIn("transcriptionStatus", [
|
|
"Completed",
|
|
"CompletedPartially",
|
|
"Failed",
|
|
"NotAvailable",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @returns stored voicemails whose trranscriptions may still be in progress
|
|
*/
|
|
async function getMissingTranscriptionVoicemails() {
|
|
return await db("voicemails")
|
|
.whereNull("transcription")
|
|
.whereNotIn("transcriptionStatus", [
|
|
// Don't include those whose transcriptions have failed or will not
|
|
// be completed.
|
|
"Failed",
|
|
"NotAvailable",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Retrieves and stores the voicemails for `extension` that are up to `age`
|
|
* seconds old.
|
|
* @param extension
|
|
* @param age
|
|
*/
|
|
async function storeExtensionVoicemails(extension: RCExtension, age: number) {
|
|
const messages = await getExtensionVoicemails(extension.id, age);
|
|
const isStored = await Promise.all(
|
|
messages.map((message) => isMessageStored(message.id))
|
|
);
|
|
return Promise.all(
|
|
messages
|
|
.filter((_, i) => !isStored[i])
|
|
.map(async (message) => {
|
|
logger.info(
|
|
`New voicemail ${message.id} from ${message.from.phoneNumber} at ${message.creationTime}`
|
|
);
|
|
return storeVoicemail(
|
|
extension,
|
|
message,
|
|
await getVoicemailRecording(message),
|
|
await getVoicemailTranscription(message)
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch and store new voicemails. If this is the first run, we get the last
|
|
* day's worth of voicemails. Otherwise, we fetch only the last 5 minutes.
|
|
*
|
|
* @param firstRun whether this is the first run
|
|
*/
|
|
async function fetchAndStoreNewVoicemails(firstRun = false) {
|
|
logger.verbose("Checking for new voicemails");
|
|
const extensions = await getValidRCExtensions();
|
|
return Promise.all(
|
|
extensions.map((extension) =>
|
|
storeExtensionVoicemails(extension, firstRun ? firstRunAge : 300)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attempt to retrieve missing/incompleted voicemail transcriptions. If the
|
|
* messages was received > 5 minutes ago and there is still no transcription,
|
|
* we give up.
|
|
*/
|
|
async function fetchMissingTranscriptions() {
|
|
const messages = await getMissingTranscriptionVoicemails();
|
|
return Promise.all(
|
|
messages.map(async (message) => {
|
|
const transcription = await getVoicemailTranscription(
|
|
await getExtensionVoicemail(message.extensionId, message.messageId)
|
|
);
|
|
if (
|
|
transcription.status == "Completed" ||
|
|
transcription.status == "CompletedPartially"
|
|
) {
|
|
// we got the transcription, so update the stored voicemail with it
|
|
message.transcriptionStatus = transcription.status;
|
|
message.transcription = transcription.text;
|
|
} else if (
|
|
new Date(message.received) < new Date(Date.now() - 300 * 1000)
|
|
) {
|
|
// else if the message is more than 5 minutes old, change status
|
|
// to NotAvailable and give up
|
|
message.transcriptionStatus = "NotAvailable";
|
|
} else {
|
|
// else we do nothing
|
|
return;
|
|
}
|
|
return updateStoredVoicemail({
|
|
messageId: message.messageId,
|
|
transcriptionStatus: message.transcriptionStatus,
|
|
transcription: message.transcription,
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates tickets from stored voicemails whose transcription has either
|
|
* failed or been completed, and which haven't already had a ticket created.
|
|
*/
|
|
async function createTickets() {
|
|
const voicemails = await getUnprocessedVoicemails();
|
|
return Promise.all(
|
|
voicemails.map(async (voicemail) => {
|
|
const contact = await searchContactByPhoneNumber(voicemail.fromNumber);
|
|
const ticketId = await createTicket(voicemail, contact);
|
|
logger.info(
|
|
`Created ticket ${ticketId} from voicemail ${voicemail.messageId}`
|
|
);
|
|
return updateStoredVoicemail({
|
|
messageId: voicemail.messageId,
|
|
ticketId,
|
|
contactId: contact?.id,
|
|
contactableType: contact?.contactable.__typename,
|
|
contactableId: contact?.contactable.id,
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
const catchHandler = logger.error;
|
|
|
|
let firstRun = true;
|
|
return [
|
|
setAsyncInterval(
|
|
() => {
|
|
const promise = fetchAndStoreNewVoicemails(firstRun).catch(
|
|
catchHandler
|
|
);
|
|
firstRun = false;
|
|
return promise;
|
|
},
|
|
60 * 1000,
|
|
true // immediate
|
|
),
|
|
setAsyncInterval(
|
|
() => fetchMissingTranscriptions().catch(catchHandler),
|
|
15 * 1000,
|
|
true
|
|
),
|
|
setAsyncInterval(() => createTickets().catch(catchHandler), 1000, true),
|
|
];
|
|
}
|