Initial commit
This commit is contained in:
		
							
								
								
									
										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"]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user