Compare commits

..

No commits in common. "30d95657cfece4efc1712637a1bfc23a5ff1eed7" and "dfabc13e8e024fb0c60b53e3dccab34ba925f236" have entirely different histories.

7 changed files with 79 additions and 201 deletions

View File

@ -5,7 +5,7 @@
## Configuration ## Configuration
```shell ```shell
# Required API endpoint/authentication variables # Required API/authentication variables:
SONAR_URL=https://instance.sonar.software/api/graphql SONAR_URL=https://instance.sonar.software/api/graphql
SONAR_TOKEN= SONAR_TOKEN=
RC_APP_KEY= RC_APP_KEY=
@ -17,25 +17,15 @@ RC_LOGIN_PASSWORD=
# Set to any value to enable use of RingCentral's sandbox API # Set to any value to enable use of RingCentral's sandbox API
RC_SANDBOX= RC_SANDBOX=
# The database to use
# valid options: pg, sqlite
# default: sqlite
DB_ENGINE=sqlite # can be pg DB_ENGINE=sqlite # can be pg
# only used when DB_ENGINE=pg
# Only used when DB_ENGINE=pg
DB_URL= DB_URL=
# Only used when DB_ENGINE=sqlite # only used when DB_ENGINE=sqlite
# default: voicemails.db
DB_FILE=voicemails.db DB_FILE=voicemails.db
# A mapping of extension number to Sonar Ticket Group # A mapping of extension number to Sonar Ticket Group
# Only the voicemail boxes of these extensions will be checked # Only the voicemail boxes of these extensions will be checked
EXTENSION_TICKET_GROUPS=1:1,2:2,2:3 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 ## Deployment
@ -55,8 +45,6 @@ services:
environment: environment:
# ... see Configuration above # ... see Configuration above
DB_FILE: /data/voicemails.db DB_FILE: /data/voicemails.db
# so the created tickets show the correct 'Received' date & time
TZ: America/Creston
volumes: volumes:
- data:/data - data:/data
``` ```

View File

@ -1,13 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -30,22 +30,6 @@ function getExtensionToTicketGroupMapping() {
return mapping; 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() { async function initSonar() {
const sonar = new Sonar(process.env.SONAR_URL!, process.env.SONAR_TOKEN!); const sonar = new Sonar(process.env.SONAR_URL!, process.env.SONAR_TOKEN!);
// simple query to test API cedentials // simple query to test API cedentials
@ -68,10 +52,6 @@ async function initRingCentralSDK() {
clientId: process.env.RC_APP_KEY, clientId: process.env.RC_APP_KEY,
clientSecret: process.env.RC_APP_SECRET, clientSecret: process.env.RC_APP_SECRET,
}); });
const platform = sdk.platform();
platform.on(platform.events.refreshError, (err) => {
console.error(err);
});
await sdk.login({ await sdk.login({
username: process.env.RC_LOGIN_USERNAME, username: process.env.RC_LOGIN_USERNAME,
extension: process.env.RC_LOGIN_EXT, extension: process.env.RC_LOGIN_EXT,
@ -99,7 +79,9 @@ async function main() {
const db = await initDB(); const db = await initDB();
console.log("Starting ticketizer..."); console.log("Starting ticketizer...");
const intervals = ticketize(sonar, rcsdk, db, getTicketizeConfig()); const intervals = ticketize(sonar, rcsdk, db, {
extensionToTicketGroup: getExtensionToTicketGroupMapping(),
});
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((sig) => { ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((sig) => {
process.on(sig, async () => { process.on(sig, async () => {

View File

@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import ReactDOMServer from "react-dom/server"; import ReactDOMServer from "react-dom/server";
import type { Contact, StoredVoicemail } from "./types";
import { getNationalNumber, formatSeconds } from "./util"; import { getNationalNumber, formatSeconds } from "./util";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import type { Contact } from "./types";
import type { StoredVoicemail, StoredRecording } from "knex/types/tables";
export function getTicketSubject( export function getTicketSubject(
voicemail: StoredVoicemail, voicemail: StoredVoicemail,
@ -14,10 +13,7 @@ export function getTicketSubject(
})`; })`;
} }
export function getTicketBody( export function getTicketBody(vm: StoredVoicemail, contact?: Contact) {
vm: StoredVoicemail & StoredRecording,
contact?: Contact
) {
return ReactDOMServer.renderToStaticMarkup( return ReactDOMServer.renderToStaticMarkup(
<div> <div>
<div> <div>
@ -37,12 +33,7 @@ export function getTicketBody(
</div> </div>
<br /> <br />
<div> <div>
<span> <strong>Transcription: </strong>
<b>Transcription:</b>{" "}
{vm.transcriptionStatus === "CompletedPartially" ? (
<i>(partial)</i>
) : undefined}
</span>
<p> <p>
<i> <i>
{vm.transcription {vm.transcription

View File

@ -11,8 +11,8 @@ import type {
RCAudioAttachment, RCAudioAttachment,
Recording, Recording,
Transcription, Transcription,
StoredVoicemail,
} from "./types"; } from "./types";
import type { StoredVoicemail, StoredRecording } from "knex/types/tables";
const SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY = gql` const SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY = gql`
query getContactByPhoneNumber($phoneNumber: String!) { query getContactByPhoneNumber($phoneNumber: String!) {
@ -49,7 +49,6 @@ function rcapi(short: string, version = "v1.0") {
} }
interface TicketizeConfig { interface TicketizeConfig {
firstRunAge: number;
extensionToTicketGroup: { [key: string]: number }; extensionToTicketGroup: { [key: string]: number };
} }
@ -63,7 +62,7 @@ export function ticketize(
sonar: Sonar, sonar: Sonar,
rcsdk: SDK, rcsdk: SDK,
db: Knex, db: Knex,
{ firstRunAge, extensionToTicketGroup }: TicketizeConfig { extensionToTicketGroup }: TicketizeConfig
) { ) {
/** /**
* Uploads a file to Sonar, returning its ID. * Uploads a file to Sonar, returning its ID.
@ -93,17 +92,17 @@ export function ticketize(
} }
/** /**
* Returns `extensionId`s messages that are up to `age` seconds old. * Returns `extensionId`s messages that are up to `from` seconds old.
* *
* @param extensionId * @param extensionId
* @param age the maximum age (in seconds) of voicemails to fetch * @param from how many seconds ago to retrieve messages from
*/ */
async function getExtensionVoicemails(extensionId: number, age = 86000) { async function getExtensionVoicemails(extensionId: number, from = 86000) {
const result = await rcsdk.get( const result = await rcsdk.get(
rcapi(`/account/~/extension/${extensionId}/message-store`), rcapi(`/account/~/extension/${extensionId}/message-store`),
{ {
messageType: "VoiceMail", messageType: "VoiceMail",
dateFrom: new Date(Date.now() - age * 1000).toISOString(), dateFrom: new Date(Date.now() - from * 1000).toISOString(),
} }
); );
return (await result.json()).records as RCMessage[]; return (await result.json()).records as RCMessage[];
@ -169,8 +168,8 @@ export function ticketize(
const response = await rcsdk.get(audio.uri); const response = await rcsdk.get(audio.uri);
const result = { const result = {
duration: audio.vmDuration, duration: audio.vmDuration,
mimeType: audio.contentType, mimetype: audio.contentType,
audio: await response.arrayBuffer(), audio: await response.blob(),
}; };
return result; return result;
} }
@ -194,10 +193,7 @@ export function ticketize(
* @param voicemail * @param voicemail
* @param contact * @param contact
*/ */
async function createTicket( async function createTicket(voicemail: StoredVoicemail, contact?: Contact) {
voicemail: StoredVoicemail & StoredRecording,
contact?: Contact
) {
const input: any = { const input: any = {
subject: getTicketSubject(voicemail, contact), subject: getTicketSubject(voicemail, contact),
description: getTicketBody(voicemail, contact), description: getTicketBody(voicemail, contact),
@ -233,38 +229,29 @@ export function ticketize(
recording: Recording, recording: Recording,
transcription: Transcription transcription: Transcription
) { ) {
await db.transaction(async (trx) => { return db<StoredVoicemail>("voicemails").insert({
await trx("voicemails").insert({ messageId: message.id,
messageId: message.id, extensionId: message.extensionId,
extensionId: message.extensionId, received: message.creationTime,
received: message.creationTime, toNumber: message.to[0].phoneNumber,
toNumber: message.to[0].phoneNumber, extensionNumber: extension.extensionNumber,
extensionNumber: extension.extensionNumber, extensionName: extension.name,
extensionName: extension.name, fromNumber: message.from.phoneNumber,
fromNumber: message.from.phoneNumber, fromName: message.from.name,
fromName: message.from.name, duration: recording.duration,
transcriptionStatus: transcription.status, transcriptionStatus: transcription.status,
transcription: transcription.text, 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 * Updates a stored voicemail using its current properties
* @param voicemail the voicemail to update * @param voicemail the voicemail to update
*/ */
async function updateStoredVoicemail(voicemail: Partial<StoredVoicemail>) { async function updateStoredVoicemail(voicemail: StoredVoicemail) {
const messageId = voicemail.messageId; await db<StoredVoicemail>("voicemails")
if (!messageId) { .update({ ...voicemail })
throw new Error("Missing required messageId property"); .where({ messageId: voicemail.messageId });
}
await db("voicemails").update(voicemail).where({ messageId });
} }
/** /**
@ -272,7 +259,9 @@ export function ticketize(
* @returns whether the message by the given ID has been stored * @returns whether the message by the given ID has been stored
*/ */
async function isMessageStored(messageId: number) { async function isMessageStored(messageId: number) {
const result = await db("voicemails").where({ messageId }).first(); const result = await db<StoredVoicemail>("voicemails")
.where({ messageId })
.first();
return result !== undefined; return result !== undefined;
} }
@ -280,8 +269,7 @@ export function ticketize(
* @returns stored voicemails that haven't had tickets created for them yet * @returns stored voicemails that haven't had tickets created for them yet
*/ */
async function getUnprocessedVoicemails() { async function getUnprocessedVoicemails() {
return await db("voicemails") return await db<StoredVoicemail>("voicemails")
.join("recordings", "voicemails.messageId", "recordings.messageId")
.whereNull("ticketId") .whereNull("ticketId")
.whereIn("transcriptionStatus", [ .whereIn("transcriptionStatus", [
"Completed", "Completed",
@ -295,7 +283,7 @@ export function ticketize(
* @returns stored voicemails whose trranscriptions may still be in progress * @returns stored voicemails whose trranscriptions may still be in progress
*/ */
async function getMissingTranscriptionVoicemails() { async function getMissingTranscriptionVoicemails() {
return await db("voicemails") return await db<StoredVoicemail>("voicemails")
.whereNotNull("transcription") .whereNotNull("transcription")
.whereNotIn("transcriptionStatus", [ .whereNotIn("transcriptionStatus", [
// Don't include those whose transcriptions have failed or will not // Don't include those whose transcriptions have failed or will not
@ -306,13 +294,16 @@ export function ticketize(
} }
/** /**
* Retrieves and stores the voicemails for `extension` that are up to `age` * Retrieves and stores the voicemails for `extension` that are up to `from`
* seconds old. * seconds old.
* @param extension * @param extension
* @param age * @param from
*/ */
async function storeExtensionVoicemails(extension: RCExtension, age: number) { async function storeExtensionVoicemails(
const messages = await getExtensionVoicemails(extension.id, age); extension: RCExtension,
from: number
) {
const messages = await getExtensionVoicemails(extension.id, from);
const isStored = await Promise.all( const isStored = await Promise.all(
messages.map((message) => isMessageStored(message.id)) messages.map((message) => isMessageStored(message.id))
); );
@ -333,15 +324,15 @@ export function ticketize(
/** /**
* Fetch and store new voicemails. If this is the first run, we get the last * 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. * day's worth of voicemails. Otherwise, we fetch only the last 15 minutes.
* *
* @param firstRun whether this is the first run * @param firstRun whether this is the first run or not
*/ */
async function fetchAndStoreNewVoicemails(firstRun = false) { async function fetchAndStoreNewVoicemails(firstRun = false) {
const extensions = await getValidRCExtensions(); const extensions = await getValidRCExtensions();
return Promise.all( return Promise.all(
extensions.map((extension) => extensions.map((extension) =>
storeExtensionVoicemails(extension, firstRun ? firstRunAge : 300) storeExtensionVoicemails(extension, firstRun ? 86400 : 900)
) )
); );
} }
@ -375,11 +366,7 @@ export function ticketize(
// else we do nothing // else we do nothing
return; return;
} }
return updateStoredVoicemail({ return updateStoredVoicemail(message);
messageId: message.messageId,
transcriptionStatus: message.transcriptionStatus,
transcription: message.transcription,
});
}) })
); );
} }
@ -398,7 +385,7 @@ export function ticketize(
`Created ticket ${ticketId} from voicemail ${voicemail.messageId}` `Created ticket ${ticketId} from voicemail ${voicemail.messageId}`
); );
return updateStoredVoicemail({ return updateStoredVoicemail({
messageId: voicemail.messageId, ...voicemail,
ticketId, ticketId,
contactId: contact?.id, contactId: contact?.id,
contactableType: contact?.contactable.__typename, contactableType: contact?.contactable.__typename,
@ -416,20 +403,22 @@ export function ticketize(
return [ return [
setAsyncInterval( setAsyncInterval(
() => { () => {
const promise = fetchAndStoreNewVoicemails(firstRun).catch( const promise = fetchAndStoreNewVoicemails(firstRun);
catchHandler
);
firstRun = false; firstRun = false;
return promise; return promise.catch(catchHandler);
}, },
60 * 1000, 60 * 1000,
true // immediate true // immediate
), ),
setAsyncInterval( setAsyncInterval(
() => fetchMissingTranscriptions().catch(catchHandler), () => fetchMissingTranscriptions().catch(catchHandler),
15 * 1000, 60 * 1000,
true
),
setAsyncInterval(
() => createTickets().catch(catchHandler),
60 * 1000,
true true
), ),
setAsyncInterval(() => createTickets().catch(catchHandler), 1000, true),
]; ];
} }

View File

@ -97,8 +97,8 @@ export interface RCMessage {
export interface Recording { export interface Recording {
duration: number; duration: number;
mimeType: string; mimetype: string;
audio: ArrayBuffer; audio: Blob;
} }
export interface Transcription { export interface Transcription {
@ -106,33 +106,21 @@ export interface Transcription {
text: string | null; text: string | null;
} }
declare module "knex/types/tables" { export interface StoredVoicemail {
interface StoredVoicemail { messageId: number;
messageId: number; extensionId: number;
extensionId: number; processed: boolean;
received: string; received: string;
toNumber: string; toNumber: string;
extensionNumber: string; extensionNumber: string;
extensionName: string; extensionName: string;
fromNumber: string; fromNumber: string;
fromName: string; fromName?: string;
transcriptionStatus: TranscriptionStatus; duration: number;
transcription: string | null; transcriptionStatus: TranscriptionStatus;
ticketId?: number; transcription: string | null;
contactId?: number; ticketId?: number;
contactableType?: string; contactId?: number;
contactableId?: number; contactableType?: string;
} contactableId?: number;
interface StoredRecording {
messageId: number;
mimeType: string;
audio: ArrayBuffer;
duration: number;
}
interface Tables {
voicemails: StoredVoicemail;
recordings: StoredRecording;
}
} }