Initial commit

This commit is contained in:
2021-03-10 21:59:38 -07:00
parent 4e533f5e7b
commit fd54a8e4dd
16 changed files with 8096 additions and 0 deletions

22
src/db/index.ts Normal file
View File

@ -0,0 +1,22 @@
export default {
client: process.env.DB_ENGINE || "sqlite",
connection: (function () {
const engine = process.env.DB_ENGINE;
if (!engine || engine === "sqlite") {
return process.env.DB_FILE || "voicemails.db";
}
if (engine === "pg") {
if (!process.env.DB_URL) {
throw new Error(`When DB_ENGINE=pg, DB_URL must be set.`);
}
return process.env.DB_URL;
}
throw new Error(
`Unsupported DB_ENGINE: ${engine}. Supported: sqlite (default), pg`
);
})(),
useNullAsDefault: true,
migrations: {
directory: "src/db/migrations",
},
};

View File

@ -0,0 +1,25 @@
import { Knex } from "knex";
export async function up(knex: Knex) {
await knex.schema.createTable("voicemails", (table) => {
table.bigInteger("messageId").primary().notNullable();
table.bigInteger("extensionId").notNullable();
table.dateTime("received").notNullable();
table.string("toNumber", 32).notNullable();
table.string("extensionNumber", 16).notNullable();
table.string("extensionName", 64).notNullable();
table.string("fromNumber", 32).notNullable();
table.string("fromName", 64).notNullable();
table.integer("duration").notNullable();
table.string("transcriptionStatus", 32).notNullable();
table.text("transcription");
table.integer("ticketId");
table.integer("contactId");
table.string("contactableType", 32);
table.integer("contactableId");
});
}
export async function down(knex: Knex) {
await knex.schema.dropTableIfExists("voicemails");
}

108
src/index.ts Normal file
View File

@ -0,0 +1,108 @@
import knex from "knex";
import knexConfig from "./db";
import { Sonar, gql } from "./sonar";
import { SDK } from "@ringcentral/sdk";
import { ticketize } from "./ticketize";
function checkEnv() {
[
"SONAR_URL",
"SONAR_TOKEN",
"RC_APP_KEY",
"RC_APP_SECRET",
"RC_LOGIN_USERNAME",
"RC_LOGIN_EXT",
"RC_LOGIN_PASSWORD",
"EXTENSION_TICKET_GROUPS",
].forEach((env) => {
if (process.env[env] === undefined) {
throw new Error(`${env} environment variable is not set.`);
}
});
}
function getExtensionToTicketGroupMapping() {
const mapping: { [key: string]: number } = {};
process.env.EXTENSION_TICKET_GROUPS!.split(",").forEach((entry) => {
const [extension, ticketGroupId] = entry.split(":");
mapping[extension] = parseInt(ticketGroupId);
});
return mapping;
}
async function initSonar() {
const sonar = new Sonar(process.env.SONAR_URL!, process.env.SONAR_TOKEN!);
// simple query to test API cedentials
const user = await sonar.request(
gql`
{
me {
name
}
}
`
);
console.log(`Authenticated to Sonar as '${user.me.name}'.`);
return sonar;
}
async function initRingCentralSDK() {
const sdk = new SDK({
server: SDK.server[process.env.RC_SANDBOX ? "sandbox" : "production"],
clientId: process.env.RC_APP_KEY,
clientSecret: process.env.RC_APP_SECRET,
});
await sdk.login({
username: process.env.RC_LOGIN_USERNAME,
extension: process.env.RC_LOGIN_EXT,
password: process.env.RC_LOGIN_PASSWORD,
});
console.log("Authenticated to RingCentral.");
return sdk;
}
async function initDB() {
const db = knex(knexConfig);
if (!process.env.DB_SKIP_MIGRATIONS) {
await db.migrate.latest();
console.log("Database migrations run successfully.");
}
return db;
}
async function main() {
try {
checkEnv();
const sonar = await initSonar();
const rcsdk = await initRingCentralSDK();
const db = await initDB();
console.log("Starting ticketizer...");
const intervals = ticketize(sonar, rcsdk, db, {
extensionToTicketGroup: getExtensionToTicketGroupMapping(),
});
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((sig) => {
process.on(sig, async () => {
console.log(`\nCaught ${sig}, shutting down...`);
const results = await Promise.allSettled(
intervals.map((interval) => interval.clear())
);
let errors = false;
results.forEach((result) => {
if (result.status === "rejected") {
errors = true;
console.error(result.reason);
}
});
console.log("exiting now");
process.exit(errors ? 1 : 0);
});
});
} catch (err) {
console.error(err);
}
}
main();

56
src/sonar.ts Normal file
View File

@ -0,0 +1,56 @@
import fetch from "node-fetch";
// simply to allow for gql tag syntax highlighting
export function gql(strings: TemplateStringsArray) {
return strings.join("");
}
export class Sonar {
url: string;
token: string;
constructor(url: string, token: string) {
this.url = url;
this.token = token;
}
async request(query: string, variables: any = {}) {
const resp = await fetch(this.url, {
method: "POST",
body: JSON.stringify({ query, variables }),
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + this.token,
},
});
if (!resp.ok) {
throw new Error(
`${resp.status} ${resp.statusText} ${JSON.stringify(await resp.json())}`
);
}
const { data, errors } = await resp.json();
if (errors) {
throw new Error(errors[0].message);
}
return data;
}
async handlePagination(
query: string,
key: string,
callback: (entities: any) => void | Promise<void>
) {
let page = 1;
let morePages = false;
do {
const response = await this.request(query, { page });
const { entities, page_info } = response[key];
morePages = page_info.total_pages > page;
await callback(entities);
page++;
} while (morePages);
}
}

47
src/template.tsx Normal file
View File

@ -0,0 +1,47 @@
import React from "react";
import ReactDOMServer from "react-dom/server";
import type { Contact, StoredVoicemail } from "./types";
import { getNationalNumber, formatSeconds } from "./util";
import { DateTime } from "luxon";
export function getTicketSubject(
voicemail: StoredVoicemail,
contact?: Contact
) {
return `New Voicemail from ${getNationalNumber(voicemail.fromNumber)} (${
contact ? contact.name : voicemail.fromName
})`;
}
export function getTicketBody(vm: StoredVoicemail, contact?: Contact) {
return ReactDOMServer.renderToStaticMarkup(
<div>
<div>
<b>Received:</b>{" "}
{DateTime.fromISO(vm.received).toLocaleString(DateTime.DATETIME_MED)}
</div>
<div>
<b>From:</b> {getNationalNumber(vm.fromNumber)} (
{contact?.name ?? vm.fromName})
</div>
<div>
<b>To:</b> {getNationalNumber(vm.toNumber)}x{vm.extensionNumber} (
{vm.extensionName})
</div>
<div>
<b>Duration: </b> {formatSeconds(vm.duration)}
</div>
<br />
<div>
<strong>Transcription: </strong>
<p>
<i>
{vm.transcription
? `"${vm.transcription}"`
: vm.transcriptionStatus}
</i>
</p>
</div>
</div>
);
}

424
src/ticketize.ts Normal file
View File

@ -0,0 +1,424 @@
import SDK from "@ringcentral/sdk";
import path from "path";
import { Knex } from "knex";
import { getNationalNumber, setAsyncInterval } from "./util";
import { getTicketSubject, getTicketBody } from "./template";
import { Sonar, gql } from "./sonar";
import type {
Contact,
RCExtension,
RCMessage,
RCAudioAttachment,
Recording,
Transcription,
StoredVoicemail,
} from "./types";
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 {
extensionToTicketGroup: { [key: string]: number };
}
/**
*
* @param {Sonar} sonar
* @param {SDK} rcsdk
* @param {Knex} db
*/
export function ticketize(
sonar: Sonar,
rcsdk: SDK,
db: Knex,
{ 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 `from` seconds old.
*
* @param extensionId
* @param from how many seconds ago to retrieve messages from
*/
async function getExtensionVoicemails(extensionId: number, from = 86000) {
const result = await rcsdk.get(
rcapi(`/account/~/extension/${extensionId}/message-store`),
{
messageType: "VoiceMail",
dateFrom: new Date(Date.now() - from * 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.blob(),
};
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, 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
) {
return db<StoredVoicemail>("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,
duration: recording.duration,
transcriptionStatus: transcription.status,
transcription: transcription.text,
});
}
/**
* Updates a stored voicemail using its current properties
* @param voicemail the voicemail to update
*/
async function updateStoredVoicemail(voicemail: StoredVoicemail) {
await db<StoredVoicemail>("voicemails")
.update({ ...voicemail })
.where({ messageId: voicemail.messageId });
}
/**
* @param messageId
* @returns whether the message by the given ID has been stored
*/
async function isMessageStored(messageId: number) {
const result = await db<StoredVoicemail>("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<StoredVoicemail>("voicemails")
.whereNull("ticketId")
.whereIn("transcriptionStatus", [
"Completed",
"CompletedPartially",
"Failed",
"NotAvailable",
]);
}
/**
* @returns stored voicemails whose trranscriptions may still be in progress
*/
async function getMissingTranscriptionVoicemails() {
return await db<StoredVoicemail>("voicemails")
.whereNotNull("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 `from`
* seconds old.
* @param extension
* @param from
*/
async function storeExtensionVoicemails(
extension: RCExtension,
from: number
) {
const messages = await getExtensionVoicemails(extension.id, from);
const isStored = await Promise.all(
messages.map((message) => isMessageStored(message.id))
);
return Promise.all(
messages
.filter((_, i) => !isStored[i])
.map(async (message) => {
console.log("Saving voicemail", message.id);
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 15 minutes.
*
* @param firstRun whether this is the first run or not
*/
async function fetchAndStoreNewVoicemails(firstRun = false) {
const extensions = await getValidRCExtensions();
return Promise.all(
extensions.map((extension) =>
storeExtensionVoicemails(extension, firstRun ? 86400 : 900)
)
);
}
/**
* 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(message);
})
);
}
/**
* 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);
console.log(
`Created ticket ${ticketId} from voicemail ${voicemail.messageId}`
);
return updateStoredVoicemail({
...voicemail,
ticketId,
contactId: contact?.id,
contactableType: contact?.contactable.__typename,
contactableId: contact?.contactable.id,
});
})
);
}
function catchHandler(reason: any) {
console.error(reason);
}
let firstRun = true;
return [
setAsyncInterval(
() => {
const promise = fetchAndStoreNewVoicemails(firstRun);
firstRun = false;
return promise.catch(catchHandler);
},
60 * 1000,
true // immediate
),
setAsyncInterval(
() => fetchMissingTranscriptions().catch(catchHandler),
60 * 1000,
true
),
setAsyncInterval(
() => createTickets().catch(catchHandler),
60 * 1000,
true
),
];
}

126
src/types.ts Normal file
View File

@ -0,0 +1,126 @@
export type Contact = {
id: number;
name: string;
contactable: {
id: number;
__typename: string;
};
};
export type RCExtension = {
id: number;
uri: string;
extensionNumber: string;
name: string;
type:
| "User"
| "FaxUser"
| "VirtualUser"
| "DigitalUser"
| "Department"
| "Announcement"
| "Voicemail"
| "SharedLinesGroup"
| "PagingOnly"
| "IvrMenu"
| "ApplicationExtension"
| "ParkLocation"
| "Bot"
| "Room"
| "Limited"
| "Site"
| "ProxyAdmin"
| "DelegatedLinesGroup"
| "GroupCallPickup";
hidden: boolean;
status: "Enabled" | "Disabled" | "Frozen" | "NotActivated" | "Unassigned";
};
export type Recipient = {
extensionId?: number;
extensionNumber?: string;
location: string;
name: string;
phoneNumber: string;
};
export type Sender = Recipient & {
extensionId: number;
extensionNumber: string;
};
type BaseAttachment = {
id: string;
uri: string;
contentType: string;
fileName: string;
size: number;
};
export type RCAudioAttachment = BaseAttachment & {
type: "AudioRecording";
vmDuration: number;
};
export type RCAttachment =
| (BaseAttachment & {
type:
| "AudioTranscription"
| "Text"
| "SourceDocument"
| "RenderedDocument"
| "MmsAttachment";
})
| RCAudioAttachment;
type TranscriptionStatus =
| "NotAvailable"
| "InProgress"
| "TimedOut"
| "Completed"
| "CompletedPartially"
| "Failed"
| "Unknown";
export interface RCMessage {
id: number;
uri: string;
extensionId: number;
availability: "Alive" | "Deleted" | "Purged";
creationTime: string;
from: Sender;
to: Recipient[];
type: "Fax" | "SMS" | "VoiceMail" | "Pager" | "Text";
vmTranscriptionStatus: TranscriptionStatus;
attachments: RCAttachment[];
}
export interface Recording {
duration: number;
mimetype: string;
audio: Blob;
}
export interface Transcription {
status: TranscriptionStatus;
text: string | null;
}
export interface StoredVoicemail {
messageId: number;
extensionId: number;
processed: boolean;
received: string;
toNumber: string;
extensionNumber: string;
extensionName: string;
fromNumber: string;
fromName?: string;
duration: number;
transcriptionStatus: TranscriptionStatus;
transcription: string | null;
ticketId?: number;
contactId?: number;
contactableType?: string;
contactableId?: number;
}

73
src/util.ts Normal file
View File

@ -0,0 +1,73 @@
import PhoneNumber from "awesome-phonenumber";
export function getNationalNumber(input: string) {
const number = new PhoneNumber(input);
if (!number.isValid()) {
throw new Error(`Invalid number: ${input}`);
}
return number.getNumber("national");
}
export function formatSeconds(input: number) {
const minutes = String(Math.trunc(input / 60)).padStart(2, "0");
const seconds = String(input % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
}
type AsyncInterval = {
promise?: Promise<any>;
timeout?: NodeJS.Timeout;
stop: boolean;
clear(): Promise<any> | undefined;
};
/**
* Similar to setInterval, except the next execution of `cb` will not happen
* until `interval` ms after the Promise returned by `cb` has settled.
*
* @param cb a callback which returns a Promise
* @param interval ms delay between executions of cb
* @param immediate whether to do an immediate execution of cb (don't first
* wait for interval)
*
* @returns an AsyncInterval object which tracks the state of the interval,
* and provides a clear() method to clear it similar to clearInterval
*/
export function setAsyncInterval(
cb: () => Promise<any>,
interval: number,
immediate = false
) {
const asyncInterval: AsyncInterval = {
stop: false,
clear() {
this.stop = true;
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
return this.promise;
},
};
function refreshTimeout() {
if (!asyncInterval.stop) {
asyncInterval.timeout = setTimeout(run, interval);
}
}
function run() {
const promise = cb();
asyncInterval.promise = promise;
promise.finally(refreshTimeout);
return promise;
}
if (immediate) {
asyncInterval.promise = cb().finally(refreshTimeout);
} else {
refreshTimeout();
}
return asyncInterval;
}