437 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			437 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import SDK from "@ringcentral/sdk";
 | 
						|
import path from "path";
 | 
						|
import { Knex } from "knex";
 | 
						|
import { logger, getNationalNumber, setAsyncInterval } from "./util";
 | 
						|
import { getTicketSubject, getTicketBody } from "./template";
 | 
						|
import { Sonar, gql } from "./sonar";
 | 
						|
import type {
 | 
						|
  Contact,
 | 
						|
  RCExtension,
 | 
						|
  RCMessage,
 | 
						|
  RCAudioAttachment,
 | 
						|
  Recording,
 | 
						|
  Transcription,
 | 
						|
} from "./types";
 | 
						|
import type { StoredVoicemail, StoredRecording } from "knex/types/tables";
 | 
						|
 | 
						|
const SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY = gql`
 | 
						|
  query getContactByPhoneNumber($phoneNumber: String!) {
 | 
						|
    numbers: phone_numbers(general_search: $phoneNumber) {
 | 
						|
      entities {
 | 
						|
        contact {
 | 
						|
          id
 | 
						|
          name
 | 
						|
          contactable {
 | 
						|
            id
 | 
						|
            __typename
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
`;
 | 
						|
 | 
						|
const CREATE_TICKET_QUERY = gql`
 | 
						|
  mutation createTicket($input: CreateInternalTicketMutationInput!) {
 | 
						|
    ticket: createInternalTicket(input: $input) {
 | 
						|
      id
 | 
						|
    }
 | 
						|
  }
 | 
						|
`;
 | 
						|
 | 
						|
/**
 | 
						|
 * @param short  a path omitting the /restapi and version prefix
 | 
						|
 * @param version the API version to target, default: v1.0
 | 
						|
 * @returns the full path to give to the RingCentral SDK
 | 
						|
 */
 | 
						|
function rcapi(short: string, version = "v1.0") {
 | 
						|
  return path.posix.normalize(`/restapi/${version}/${short}`);
 | 
						|
}
 | 
						|
 | 
						|
interface TicketizeConfig {
 | 
						|
  firstRunAge: number;
 | 
						|
  extensionToTicketGroup: { [key: string]: number };
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 *
 | 
						|
 * @param {Sonar} sonar
 | 
						|
 * @param {SDK} rcsdk
 | 
						|
 * @param {Knex} db
 | 
						|
 */
 | 
						|
export function ticketize(
 | 
						|
  sonar: Sonar,
 | 
						|
  rcsdk: SDK,
 | 
						|
  db: Knex,
 | 
						|
  { firstRunAge, extensionToTicketGroup }: TicketizeConfig
 | 
						|
) {
 | 
						|
  /**
 | 
						|
   * Uploads a file to Sonar, returning its ID.
 | 
						|
   * @param data
 | 
						|
   * @param fileName
 | 
						|
   */
 | 
						|
  async function uploadFile(
 | 
						|
    data: ArrayBuffer,
 | 
						|
    fileName: string
 | 
						|
  ): Promise<number> {
 | 
						|
    throw new Error("File uploading not yes implemented.");
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns all valid extensions (not disaled and included in
 | 
						|
   * extensionToTicketGroup).
 | 
						|
   */
 | 
						|
  async function getValidRCExtensions() {
 | 
						|
    const result = await rcsdk.get(rcapi("/account/~/extension"));
 | 
						|
    const { records } = (await result.json()) as { records: RCExtension[] };
 | 
						|
    return records.filter(
 | 
						|
      ({ extensionNumber, status, hidden }) =>
 | 
						|
        status === "Enabled" &&
 | 
						|
        !hidden &&
 | 
						|
        extensionToTicketGroup[extensionNumber] !== undefined
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns `extensionId`s messages that are up to `age` seconds old.
 | 
						|
   *
 | 
						|
   * @param extensionId
 | 
						|
   * @param age the maximum age (in seconds) of voicemails to fetch
 | 
						|
   */
 | 
						|
  async function getExtensionVoicemails(extensionId: number, age = 86000) {
 | 
						|
    const result = await rcsdk.get(
 | 
						|
      rcapi(`/account/~/extension/${extensionId}/message-store`),
 | 
						|
      {
 | 
						|
        messageType: "VoiceMail",
 | 
						|
        dateFrom: new Date(Date.now() - age * 1000).toISOString(),
 | 
						|
      }
 | 
						|
    );
 | 
						|
    return (await result.json()).records as RCMessage[];
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Return a specific messages, mainly for later retrieval of attachments
 | 
						|
   *
 | 
						|
   * @param extensionId
 | 
						|
   * @param messageId
 | 
						|
   */
 | 
						|
  async function getExtensionVoicemail(extensionId: number, messageId: number) {
 | 
						|
    const result = await rcsdk.get(
 | 
						|
      rcapi(`/account/~/extension/${extensionId}/message-store/${messageId}`)
 | 
						|
    );
 | 
						|
    return result.json() as Promise<RCMessage>;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Attempts to download the transcription of a voicemail
 | 
						|
   * @param message
 | 
						|
   */
 | 
						|
  async function getVoicemailTranscription(
 | 
						|
    message: RCMessage
 | 
						|
  ): Promise<Transcription> {
 | 
						|
    const status = message.vmTranscriptionStatus;
 | 
						|
    if (status !== "Completed" && status !== "CompletedPartially") {
 | 
						|
      return {
 | 
						|
        status,
 | 
						|
        text: null,
 | 
						|
      };
 | 
						|
    }
 | 
						|
 | 
						|
    const transcription = message.attachments.find(
 | 
						|
      (attachment) => attachment.type === "AudioTranscription"
 | 
						|
    );
 | 
						|
    if (!transcription) {
 | 
						|
      throw new Error(
 | 
						|
        `Transcription status is ${status} but no AudioTranscription attachment found on ${message.id}`
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    const response = await rcsdk.get(transcription.uri);
 | 
						|
    return {
 | 
						|
      status,
 | 
						|
      text: (await response.text()).trim(),
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Attempts to download the recording of a voicemail
 | 
						|
   * @param message
 | 
						|
   */
 | 
						|
  async function getVoicemailRecording(message: RCMessage): Promise<Recording> {
 | 
						|
    const audio = message.attachments.find(
 | 
						|
      (attachment) => attachment.type === "AudioRecording"
 | 
						|
    ) as RCAudioAttachment;
 | 
						|
    if (!audio) {
 | 
						|
      throw new Error(
 | 
						|
        `No AudioRecording attachment found on message ${message.id}`
 | 
						|
      );
 | 
						|
    }
 | 
						|
    const response = await rcsdk.get(audio.uri);
 | 
						|
    const result = {
 | 
						|
      duration: audio.vmDuration,
 | 
						|
      mimeType: audio.contentType,
 | 
						|
      audio: await response.arrayBuffer(),
 | 
						|
    };
 | 
						|
    return result;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Retrieve the first contact found associated with a phone number
 | 
						|
   * @param phoneNumber the phone number to search with
 | 
						|
   */
 | 
						|
  async function searchContactByPhoneNumber(phoneNumber: string) {
 | 
						|
    const result = await sonar.request(SEARCH_CONTACT_BY_PHONE_NUMBER_QUERY, {
 | 
						|
      phoneNumber: getNationalNumber(phoneNumber),
 | 
						|
    });
 | 
						|
    if (result.numbers.entities.length === 0) {
 | 
						|
      return undefined;
 | 
						|
    }
 | 
						|
    return result.numbers.entities[0].contact as Contact;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Creates a ticket out of a stored voicemail
 | 
						|
   * @param voicemail
 | 
						|
   * @param contact
 | 
						|
   */
 | 
						|
  async function createTicket(
 | 
						|
    voicemail: StoredVoicemail & StoredRecording,
 | 
						|
    contact?: Contact
 | 
						|
  ) {
 | 
						|
    const input: any = {
 | 
						|
      subject: getTicketSubject(voicemail, contact),
 | 
						|
      description: getTicketBody(voicemail, contact),
 | 
						|
      status: "OPEN",
 | 
						|
      priority: "MEDIUM",
 | 
						|
      ticket_group_id: extensionToTicketGroup[voicemail.extensionNumber],
 | 
						|
    };
 | 
						|
 | 
						|
    const ticket_group_id = extensionToTicketGroup[voicemail.extensionNumber];
 | 
						|
    if (ticket_group_id) {
 | 
						|
      input.ticket_group_id = ticket_group_id;
 | 
						|
    }
 | 
						|
 | 
						|
    if (contact) {
 | 
						|
      input.ticketable_type = contact.contactable.__typename;
 | 
						|
      input.ticketable_id = contact.contactable.id;
 | 
						|
    }
 | 
						|
 | 
						|
    const result = await sonar.request(CREATE_TICKET_QUERY, { input });
 | 
						|
    return parseInt(result.ticket.id);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Saves a voicemail to the database
 | 
						|
   * @param extension
 | 
						|
   * @param message
 | 
						|
   * @param recording
 | 
						|
   * @param transcription
 | 
						|
   */
 | 
						|
  async function storeVoicemail(
 | 
						|
    extension: RCExtension,
 | 
						|
    message: RCMessage,
 | 
						|
    recording: Recording,
 | 
						|
    transcription: Transcription
 | 
						|
  ) {
 | 
						|
    await db.transaction(async (trx) => {
 | 
						|
      await trx("voicemails").insert({
 | 
						|
        messageId: message.id,
 | 
						|
        extensionId: message.extensionId,
 | 
						|
        received: message.creationTime,
 | 
						|
        toNumber: message.to[0].phoneNumber,
 | 
						|
        extensionNumber: extension.extensionNumber,
 | 
						|
        extensionName: extension.name,
 | 
						|
        fromNumber: message.from.phoneNumber,
 | 
						|
        fromName: message.from.name,
 | 
						|
        transcriptionStatus: transcription.status,
 | 
						|
        transcription: transcription.text,
 | 
						|
      });
 | 
						|
      await trx("recordings").insert({
 | 
						|
        messageId: message.id,
 | 
						|
        mimeType: recording.mimeType,
 | 
						|
        audio: new Uint8Array(recording.audio),
 | 
						|
        duration: recording.duration,
 | 
						|
      });
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Updates a stored voicemail
 | 
						|
   * @param voicemail the voicemail to update
 | 
						|
   */
 | 
						|
  async function updateStoredVoicemail(voicemail: Partial<StoredVoicemail>) {
 | 
						|
    const messageId = voicemail.messageId;
 | 
						|
    if (!messageId) {
 | 
						|
      throw new Error("Missing required messageId property");
 | 
						|
    }
 | 
						|
    await db("voicemails").update(voicemail).where({ messageId });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @param messageId
 | 
						|
   * @returns whether the message by the given ID has been stored
 | 
						|
   */
 | 
						|
  async function isMessageStored(messageId: number) {
 | 
						|
    const result = await db("voicemails").where({ messageId }).first();
 | 
						|
    return result !== undefined;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns stored voicemails that haven't had tickets created for them yet
 | 
						|
   */
 | 
						|
  async function getUnprocessedVoicemails() {
 | 
						|
    return await db("voicemails")
 | 
						|
      .join("recordings", "voicemails.messageId", "recordings.messageId")
 | 
						|
      .whereNull("ticketId")
 | 
						|
      .whereIn("transcriptionStatus", [
 | 
						|
        "Completed",
 | 
						|
        "CompletedPartially",
 | 
						|
        "Failed",
 | 
						|
        "NotAvailable",
 | 
						|
      ]);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * @returns stored voicemails whose trranscriptions may still be in progress
 | 
						|
   */
 | 
						|
  async function getMissingTranscriptionVoicemails() {
 | 
						|
    return await db("voicemails")
 | 
						|
      .whereNull("transcription")
 | 
						|
      .whereNotIn("transcriptionStatus", [
 | 
						|
        // Don't include those whose transcriptions have failed or will not
 | 
						|
        // be completed.
 | 
						|
        "Failed",
 | 
						|
        "NotAvailable",
 | 
						|
      ]);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Retrieves and stores the voicemails for `extension` that are up to `age`
 | 
						|
   * seconds old.
 | 
						|
   * @param extension
 | 
						|
   * @param age
 | 
						|
   */
 | 
						|
  async function storeExtensionVoicemails(extension: RCExtension, age: number) {
 | 
						|
    const messages = await getExtensionVoicemails(extension.id, age);
 | 
						|
    const isStored = await Promise.all(
 | 
						|
      messages.map((message) => isMessageStored(message.id))
 | 
						|
    );
 | 
						|
    return Promise.all(
 | 
						|
      messages
 | 
						|
        .filter((_, i) => !isStored[i])
 | 
						|
        .map(async (message) => {
 | 
						|
          logger.info(
 | 
						|
            `New voicemail ${message.id} from ${message.from.phoneNumber} at ${message.creationTime}`
 | 
						|
          );
 | 
						|
          return storeVoicemail(
 | 
						|
            extension,
 | 
						|
            message,
 | 
						|
            await getVoicemailRecording(message),
 | 
						|
            await getVoicemailTranscription(message)
 | 
						|
          );
 | 
						|
        })
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Fetch and store new voicemails. If this is the first run, we get the last
 | 
						|
   * day's worth of voicemails. Otherwise, we fetch only the last 5 minutes.
 | 
						|
   *
 | 
						|
   * @param firstRun whether this is the first run
 | 
						|
   */
 | 
						|
  async function fetchAndStoreNewVoicemails(firstRun = false) {
 | 
						|
    logger.verbose("Checking for new voicemails");
 | 
						|
    const extensions = await getValidRCExtensions();
 | 
						|
    return Promise.all(
 | 
						|
      extensions.map((extension) =>
 | 
						|
        storeExtensionVoicemails(extension, firstRun ? firstRunAge : 300)
 | 
						|
      )
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Attempt to retrieve missing/incompleted voicemail transcriptions. If the
 | 
						|
   * messages was received > 5 minutes ago and there is still no transcription,
 | 
						|
   * we give up.
 | 
						|
   */
 | 
						|
  async function fetchMissingTranscriptions() {
 | 
						|
    const messages = await getMissingTranscriptionVoicemails();
 | 
						|
    return Promise.all(
 | 
						|
      messages.map(async (message) => {
 | 
						|
        const transcription = await getVoicemailTranscription(
 | 
						|
          await getExtensionVoicemail(message.extensionId, message.messageId)
 | 
						|
        );
 | 
						|
        if (
 | 
						|
          transcription.status == "Completed" ||
 | 
						|
          transcription.status == "CompletedPartially"
 | 
						|
        ) {
 | 
						|
          // we got the transcription, so update the stored voicemail with it
 | 
						|
          message.transcriptionStatus = transcription.status;
 | 
						|
          message.transcription = transcription.text;
 | 
						|
        } else if (
 | 
						|
          new Date(message.received) < new Date(Date.now() - 300 * 1000)
 | 
						|
        ) {
 | 
						|
          // else if the message is more than 5 minutes old, change status
 | 
						|
          // to NotAvailable and give up
 | 
						|
          message.transcriptionStatus = "NotAvailable";
 | 
						|
        } else {
 | 
						|
          // else we do nothing
 | 
						|
          return;
 | 
						|
        }
 | 
						|
        return updateStoredVoicemail({
 | 
						|
          messageId: message.messageId,
 | 
						|
          transcriptionStatus: message.transcriptionStatus,
 | 
						|
          transcription: message.transcription,
 | 
						|
        });
 | 
						|
      })
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Creates tickets from stored voicemails whose transcription has either
 | 
						|
   * failed or been completed, and which haven't already had a ticket created.
 | 
						|
   */
 | 
						|
  async function createTickets() {
 | 
						|
    const voicemails = await getUnprocessedVoicemails();
 | 
						|
    return Promise.all(
 | 
						|
      voicemails.map(async (voicemail) => {
 | 
						|
        const contact = await searchContactByPhoneNumber(voicemail.fromNumber);
 | 
						|
        const ticketId = await createTicket(voicemail, contact);
 | 
						|
        logger.info(
 | 
						|
          `Created ticket ${ticketId} from voicemail ${voicemail.messageId}`
 | 
						|
        );
 | 
						|
        return updateStoredVoicemail({
 | 
						|
          messageId: voicemail.messageId,
 | 
						|
          ticketId,
 | 
						|
          contactId: contact?.id,
 | 
						|
          contactableType: contact?.contactable.__typename,
 | 
						|
          contactableId: contact?.contactable.id,
 | 
						|
        });
 | 
						|
      })
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  const catchHandler = logger.error;
 | 
						|
 | 
						|
  let firstRun = true;
 | 
						|
  return [
 | 
						|
    setAsyncInterval(
 | 
						|
      () => {
 | 
						|
        const promise = fetchAndStoreNewVoicemails(firstRun).catch(
 | 
						|
          catchHandler
 | 
						|
        );
 | 
						|
        firstRun = false;
 | 
						|
        return promise;
 | 
						|
      },
 | 
						|
      60 * 1000,
 | 
						|
      true // immediate
 | 
						|
    ),
 | 
						|
    setAsyncInterval(
 | 
						|
      () => fetchMissingTranscriptions().catch(catchHandler),
 | 
						|
      15 * 1000,
 | 
						|
      true
 | 
						|
    ),
 | 
						|
    setAsyncInterval(() => createTickets().catch(catchHandler), 1000, true),
 | 
						|
  ];
 | 
						|
}
 |