Initial commit
This commit is contained in:
parent
4e533f5e7b
commit
fd54a8e4dd
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
*.db
|
13
.eslintrc.json
Normal file
13
.eslintrc.json
Normal 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
22
.gitignore
vendored
Normal 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
31
Dockerfile
Normal 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" ]
|
2
knexfile.ts
Normal file
2
knexfile.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import config from "./src/db";
|
||||||
|
export default config;
|
7028
package-lock.json
generated
Normal file
7028
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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
22
src/db/index.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
25
src/db/migrations/20210308232030_create_voicemails_table.ts
Normal file
25
src/db/migrations/20210308232030_create_voicemails_table.ts
Normal 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
108
src/index.ts
Normal 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
56
src/sonar.ts
Normal 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
47
src/template.tsx
Normal 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
424
src/ticketize.ts
Normal 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
126
src/types.ts
Normal 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
73
src/util.ts
Normal 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;
|
||||||
|
}
|
72
tsconfig.json
Normal file
72
tsconfig.json
Normal 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"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user