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 { 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; } /** * Attempts to download the transcription of a voicemail * @param message */ async function getVoicemailTranscription( message: RCMessage ): Promise { 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 { 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) { 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.info("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), ]; }