Compare commits

...

14 Commits

Author SHA1 Message Date
48a3e60623 Change env variables
RC_* to RINGCENTRAL_*
APP_KEY and APP_SECRET repalced with CLIENT_ID and CLIENT_SECRET to
reflect RingCentral's descriptions

Change RC_SANDBOX to RINGCENTRAL_SERVER and changed its behaviour.
2021-03-15 11:57:10 -06:00
9a61396db6 Use winston for logging 2021-03-15 09:46:40 -06:00
0fb6c9bcfb Add DEBUG RingCentral request logging 2021-03-14 15:47:29 -06:00
4cbfd1a4f8 Allow fromName to be null 2021-03-14 15:47:29 -06:00
bc7500ce3e Attempt to re-authenticate on auth refresh error 2021-03-14 15:47:12 -06:00
2668f4d814 Fix getMisisngTranscriptionVoicemails
Meant whereNull, not whereNotNull.
2021-03-14 15:46:33 -06:00
30d95657cf Add RingCentral token refresh error logging. 2021-03-11 15:11:11 -07:00
1c1682fe4f Move duration column to recordings table 2021-03-11 12:52:08 -07:00
0946158005 Add FIRST_RUN_AGE variable
Allows setting the maximum age of voicemails to fetch on the first run.

Other changes:
- Refactor `from` to `age` in function parameters
- Improve README
2021-03-11 11:55:50 -07:00
39cd664b3c Modify template to indicate when a transcription is partial 2021-03-11 11:15:43 -07:00
1052dfc1b1 Adjust intervals
We still fetch voicemails from RingCentral every 60 seconds, but now we
check for missing transcriptions every 15 seconds, and poll the database
for voicemails that are ready to be ticketized every second.
2021-03-11 11:04:32 -07:00
3452cd143a Add "recordings" table, save raw audio blobs in it
Other changes:
- Move Stored(Voicemail|Recording) interfaces into knex/types/tables
module for reduced boilerplate.
- Change updateStoredVoicemail to take Partial<StoredVoicemail>,
allowing to only update only some columns
2021-03-11 10:59:08 -07:00
dfabc13e8e Add README 2021-03-10 22:23:41 -07:00
fd54a8e4dd Initial commit 2021-03-10 22:10:47 -07:00
20 changed files with 8723 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules/
*.log
*.db

13
.eslintrc.json Normal file
View File

@ -0,0 +1,13 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 12
},
"rules": {
"no-unused-vars": "warn"
}
}

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
node_modules/
logs
*.log
npm-debug.log*
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# dotenv environment variables file
.env*
# databases
*.db
dist/

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# stage: dev
FROM node:15-alpine as dev
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# stage: build
FROM dev as build
RUN npm prune --production
# stage: production
FROM node:15-alpine as production
WORKDIR /app
RUN printf "%b" '#!'"/bin/sh\n\
set -e\n\
if [ ! -z \"\$RUN_MIGRATIONS\" ]; then\n\
echo \"Running migrations.\"\n\
npm run knex:migrate:latest\n\
fi\n\
exec \"\$@\"\n" > docker-entrypoint.sh && chmod +x docker-entrypoint.sh
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY --from=build /app/*.json ./
COPY --from=build /app/*.ts ./
ENTRYPOINT [ "./docker-entrypoint.sh" ]
CMD [ "npm", "start" ]

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# voicemail-ticketizer
`voicemail-ticketizer` creates Sonar tickets of RingCentral voicemails.
## Configuration
```shell
# Required API endpoint/authentication variables
SONAR_URL=https://instance.sonar.software/api/graphql
SONAR_TOKEN=
RINGCENTRAL_CLIENT_ID=
RINGCENTRAL_CLIENT_SECRET=
RINGCENTRAL_USERNAME=
RINGCENTRAL_EXTENSION=
RINGCENTRAL_PASSWORD=
# Set to 'sandbox' to use RingCentral's sandbox API
# Any other value will result in using RingCentral's production API
RINGCENTRAL_SERVER=production
# The database to use
# valid options: pg, sqlite
# default: sqlite
DB_ENGINE=sqlite # can be pg
# Only used when DB_ENGINE=pg
DB_URL=
# Only used when DB_ENGINE=sqlite
# default: voicemails.db
DB_FILE=voicemails.db
# A mapping of extension number to Sonar Ticket Group
# Only the voicemail boxes of these extensions will be checked
EXTENSION_TICKET_GROUPS=1:1,2:2,2:3
# Upon first run, query RingCentral voicemails up to FIRST_RUN_AGE seconds old.
# Useful when the application is restarted after not running for some time.
# default: 86400 (1 day)
FIRST_RUN_AGE=86400
```
## Deployment
Via `docker-compose`:
```yaml
version: "3.8"
volumes:
data:
services:
voicemail-ticketizer:
build: https://git.esis.network/esis/voicemail-ticketizer.git#master
restart: always
environment:
# ... see Configuration above
DB_FILE: /data/voicemails.db
# so the created tickets show the correct 'Received' date & time
TZ: America/Creston
volumes:
- data:/data
```

2
knexfile.ts Normal file
View File

@ -0,0 +1,2 @@
import config from "./src/db";
export default config;

7428
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "voicemail-ticketizer",
"version": "1.0.0",
"description": "Creates Sonar support tickets from RingCentral voicemails",
"main": "src/index.ts",
"scripts": {
"start": "node -r dotenv/config -r ts-node/register/transpile-only src/index.ts",
"watch": "nodemon",
"knex:migrate:status": "knex --knexfile knexfile.ts migrate:status",
"knex:migrate:up": "knex --knexfile knexfile.ts migrate:up",
"knex:migrate:down": "knex --knexfile knexfile.ts migrate:down",
"knex:migrate:latest": "knex --knexfile knexfile.ts migrate:latest",
"knex:migrate:rollback": "knex --knexfile knexfile.ts migrate:rollback"
},
"author": "Matt Low <matt@mlow.ca>",
"license": "ISC",
"dependencies": {
"@ringcentral/sdk": "^4.4.1",
"awesome-phonenumber": "^2.47.0",
"dotenv": "^8.2.0",
"knex": "^0.95.1",
"luxon": "^1.26.0",
"node-fetch": "^1.6.3",
"pg": "^8.5.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"sqlite3": "^5.0.2",
"ts-node": "^9.1.1",
"winston": "^3.3.3"
},
"devDependencies": {
"@types/luxon": "^1.26.2",
"@types/node-fetch": "^2.5.8",
"@types/react-dom": "^17.0.2",
"eslint": "^7.21.0",
"nodemon": "^2.0.7"
},
"nodemonConfig": {
"watch": [
"src"
],
"exec": "npm start",
"ext": "ts"
}
}

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");
}

View File

@ -0,0 +1,13 @@
import { Knex } from "knex";
export async function up(knex: Knex) {
await knex.schema.createTable("recordings", (table) => {
table.bigInteger("messageId").primary().references("voicemails.messageId");
table.string("mimeType", 32);
table.binary("audio");
});
}
export async function down(knex: Knex) {
await knex.schema.dropTable("recordings");
}

View File

@ -0,0 +1,47 @@
import { Knex } from "knex";
export async function up(knex: Knex) {
return knex.transaction(async (trx) => {
await trx.schema.alterTable("recordings", (table) => {
table.integer("duration");
});
// transfer recording durations from voicemails table
await trx("recordings").update({
duration: knex("voicemails")
.select("duration")
.where("messageId", knex.raw("??", "recordings.messageId")),
});
// now we can make duration column not-nullable
await trx.schema.alterTable("recordings", (table) => {
table.integer("duration").notNullable().alter();
});
await trx.schema.alterTable("voicemails", (table) => {
table.dropColumn("duration");
});
});
}
export async function down(knex: Knex) {
return knex.transaction(async (trx) => {
await trx.schema.alterTable("voicemails", (table) => {
table.integer("duration");
});
await trx("voicemails").update({
duration: knex("recordings")
.select("duration")
.where("messageId", knex.raw("??", "voicemails.messageId")),
});
await trx.schema.alterTable("voicemails", (table) => {
table.integer("duration").notNullable().alter();
});
await trx.schema.alterTable("recordings", (table) => {
table.dropColumn("duration");
});
});
}

View File

@ -0,0 +1,17 @@
import { Knex } from "knex";
export async function up(knex: Knex) {
return knex.transaction((trx) =>
trx.schema.alterTable("voicemails", (table) => {
table.string("fromName", 64).alter();
})
);
}
export async function down(knex: Knex) {
return knex.transaction((trx) =>
trx.schema.alterTable("voicemails", (table) => {
table.string("fromName", 64).notNullable().alter();
})
);
}

146
src/index.ts Normal file
View File

@ -0,0 +1,146 @@
import knex from "knex";
import knexConfig from "./db";
import { Sonar, gql } from "./sonar";
import { SDK } from "@ringcentral/sdk";
import { ticketize } from "./ticketize";
import { logger, DEBUG } from "./util";
function checkEnv() {
[
"SONAR_URL",
"SONAR_TOKEN",
"RINGCENTRAL_CLIENT_ID",
"RINGCENTRAL_CLIENT_SECRET",
"RINGCENTRAL_USERNAME",
"RINGCENTRAL_EXTENSION",
"RINGCENTRAL_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;
}
const DEFAULT_FIRST_RUN_AGE = 86400;
function getTicketizeConfig() {
const firstRunAge = process.env.FIRST_RUN_AGE
? parseInt(process.env.FIRST_RUN_AGE)
: DEFAULT_FIRST_RUN_AGE;
if (isNaN(firstRunAge) || firstRunAge <= 0) {
throw new Error("FIRST_RUN_AGE must be a valid positive integer");
}
return {
firstRunAge,
extensionToTicketGroup: getExtensionToTicketGroupMapping(),
};
}
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
}
}
`
);
logger.info(`Authenticated to Sonar as '${user.me.name}'`);
return sonar;
}
async function initRingCentralSDK() {
const sdk = new SDK({
server:
SDK.server[
process.env.RINGCENTRAL_SERVER === "sandbox" ? "sandbox" : "production"
],
clientId: process.env.RINGCENTRAL_CLIENT_ID,
clientSecret: process.env.RINGCENTRAL_CLIENT_SECRET,
});
const login = () =>
sdk.login({
username: process.env.RINGCENTRAL_USERNAME,
extension: process.env.RINGCENTRAL_EXTENSION,
password: process.env.RINGCENTRAL_PASSWORD,
});
if (DEBUG) {
const client = sdk.client();
client.on(client.events.beforeRequest, (req) => {
logger.debug(req.url);
});
}
const platform = sdk.platform();
platform.on(platform.events.refreshError, async (err) => {
logger.error("Refresh token error:", err);
await login();
logger.info("RingCentral re-authentication successful");
});
await login();
logger.info("Authenticated to RingCentral");
return sdk;
}
async function initDB() {
const db = knex(knexConfig);
if (!process.env.DB_SKIP_MIGRATIONS) {
await db.migrate.latest();
logger.info("Database migrations run successfully");
}
return db;
}
async function main() {
try {
checkEnv();
const sonar = await initSonar();
const rcsdk = await initRingCentralSDK();
const db = await initDB();
logger.info("Starting");
const intervals = ticketize(sonar, rcsdk, db, getTicketizeConfig());
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((sig) => {
process.on(sig, async () => {
logger.info(`Caught ${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;
logger.error(result.reason);
}
});
await rcsdk.logout();
process.exit(errors ? 1 : 0);
});
});
} catch (err) {
logger.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);
}
}

57
src/template.tsx Normal file
View File

@ -0,0 +1,57 @@
import React from "react";
import ReactDOMServer from "react-dom/server";
import { getNationalNumber, formatSeconds } from "./util";
import { DateTime } from "luxon";
import type { Contact } from "./types";
import type { StoredVoicemail, StoredRecording } from "knex/types/tables";
function fromName(vm: StoredVoicemail, contact?: Contact) {
return contact?.name ?? vm.fromName ?? "unknown";
}
export function getTicketSubject(vm: StoredVoicemail, contact?: Contact) {
const name = fromName(vm, contact);
return `New Voicemail from ${getNationalNumber(vm.fromNumber)} (${name})`;
}
export function getTicketBody(
vm: StoredVoicemail & StoredRecording,
contact?: Contact
) {
const name = fromName(vm, 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)} ({name})
</div>
<div>
<b>To:</b> {getNationalNumber(vm.toNumber)}x{vm.extensionNumber} (
{vm.extensionName})
</div>
<div>
<b>Duration: </b> {formatSeconds(vm.duration)}
</div>
<br />
<div>
<span>
<b>Transcription:</b>{" "}
{vm.transcriptionStatus === "CompletedPartially" ? (
<i>(partial)</i>
) : undefined}
</span>
<p>
<i>
{vm.transcription
? `"${vm.transcription}"`
: vm.transcriptionStatus}
</i>
</p>
</div>
</div>
);
}

436
src/ticketize.ts Normal file
View File

@ -0,0 +1,436 @@
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),
];
}

138
src/types.ts Normal file
View File

@ -0,0 +1,138 @@
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: ArrayBuffer;
}
export interface Transcription {
status: TranscriptionStatus;
text: string | null;
}
declare module "knex/types/tables" {
interface StoredVoicemail {
messageId: number;
extensionId: number;
received: string;
toNumber: string;
extensionNumber: string;
extensionName: string;
fromNumber: string;
fromName: string;
transcriptionStatus: TranscriptionStatus;
transcription: string | null;
ticketId?: number;
contactId?: number;
contactableType?: string;
contactableId?: number;
}
interface StoredRecording {
messageId: number;
mimeType: string;
audio: ArrayBuffer;
duration: number;
}
interface Tables {
voicemails: StoredVoicemail;
recordings: StoredRecording;
}
}

87
src/util.ts Normal file
View File

@ -0,0 +1,87 @@
import PhoneNumber from "awesome-phonenumber";
import winston, { format } from "winston";
export const DEBUG = !!process.env.DEBUG;
export const logger = winston.createLogger({
level: DEBUG ? "debug" : process.env.LOG_LEVEL ?? "info",
transports: [new winston.transports.Console()],
format: format.combine(
format.errors({ stack: true }),
format.printf(
({ level, message, stack }) =>
`${level}: ${message}${stack ? "\n" + stack : ""}`
)
),
});
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;
}

72
tsconfig.json Normal file
View File

@ -0,0 +1,72 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */,
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./dist" /* Redirect output structure to the directory. */,
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["./src"]
}