voicemail-ticketizer/src/ticketize.ts
2021-03-15 09:46:40 -06:00

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),
];
}