import * as Hapi from "@hapi/hapi"; import * as Joi from "joi"; import * as Boom from "@hapi/boom"; import Twilio from "twilio"; import { SavedVoiceProvider } from "db"; import pMemoize from "p-memoize"; import ms from "ms"; import * as Helpers from "../../helpers"; import workerUtils from "../../../../worker-utils"; import { SayLanguage, SayVoice } from "twilio/lib/twiml/VoiceResponse"; const queueRecording = async (meta) => { return workerUtils.addJob("twilio-recording", meta, { jobKey: meta.callSid }); }; const twilioClientFor = (provider: SavedVoiceProvider): Twilio.Twilio => { const { accountSid, apiKeySid, apiKeySecret } = provider.credentials; if (!accountSid || !apiKeySid || !apiKeySecret) throw new Error( `twilio provider ${provider.name} does not have credentials` ); return Twilio(apiKeySid, apiKeySecret, { accountSid, }); }; const _getOrCreateTTSTestApplication = async ( url, name, client: Twilio.Twilio ) => { const application = await client.applications.list({ friendlyName: name }); if (application[0] && application[0].voiceUrl === url) { return application[0]; } return client.applications.create({ voiceMethod: "POST", voiceUrl: url, friendlyName: name, }); }; const getOrCreateTTSTestApplication = pMemoize(_getOrCreateTTSTestApplication, { maxAge: ms("1h"), }); export const TwilioRoutes = Helpers.noAuth([ { method: "get", path: "/api/voice/twilio/prompt/{voiceLineId}", options: { description: "download the mp3 file to play as a prompt for the user", validate: { params: { voiceLineId: Joi.string().uuid().required(), }, }, handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { const { voiceLineId } = request.params; const voiceLine = await request .db() .voiceLines.findById({ id: voiceLineId }); if (!voiceLine) return Boom.notFound(); if (!voiceLine.audioPromptEnabled) return Boom.badRequest(); const mp3 = voiceLine.promptAudio["audio/mpeg"]; if (!mp3) { return Boom.serverUnavailable(); } return h .response(Buffer.from(mp3, "base64")) .header("Content-Type", "audio/mpeg") .header("Content-Disposition", "attachment; filename=prompt.mp3"); }, }, }, { method: "post", path: "/api/voice/twilio/record/{voiceLineId}", options: { description: "webhook for twilio to handle an incoming call", validate: { params: { voiceLineId: Joi.string().uuid().required(), }, }, handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { const { voiceLineId } = request.params; const { To } = request.payload as { To: string }; const voiceLine = await request.db().voiceLines.findBy({ number: To }); if (!voiceLine) return Boom.notFound(); if (voiceLine.id !== voiceLineId) return Boom.badRequest(); const frontendUrl = request.server.config().frontend.url; const useTextPrompt = !voiceLine.audioPromptEnabled; const twiml = new Twilio.twiml.VoiceResponse(); if (useTextPrompt) { let prompt = voiceLine.promptText; if (!prompt || prompt.length === 0) prompt = "The grabadora text prompt is unconfigured. Please set a prompt in the administration screen."; twiml.say( { language: voiceLine.language as SayLanguage, voice: voiceLine.voice as SayVoice, }, prompt ); } else { const promptUrl = `${frontendUrl}/api/v1/voice/twilio/prompt/${voiceLineId}`; twiml.play({ loop: 1 }, promptUrl); } twiml.record({ playBeep: true, finishOnKey: "1", recordingStatusCallback: `${frontendUrl}/api/v1/voice/twilio/recording-ready/${voiceLineId}`, }); return twiml.toString(); }, }, }, { method: "post", path: "/api/voice/twilio/recording-ready/{voiceLineId}", options: { description: "webhook for twilio to handle a recording", validate: { params: { voiceLineId: Joi.string().uuid().required(), }, }, handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { const { voiceLineId } = request.params; const voiceLine = await request .db() .voiceLines.findById({ id: voiceLineId }); if (!voiceLine) return Boom.notFound(); const { AccountSid, RecordingSid, CallSid } = request.payload as { AccountSid: string; RecordingSid: string; CallSid: string; }; await queueRecording({ voiceLineId, accountSid: AccountSid, callSid: CallSid, recordingSid: RecordingSid, }); return h.response().code(203); }, }, }, { method: "post", path: "/api/voice/twilio/text-to-speech/{providerId}", options: { description: "webook for twilio to test the twilio text-to-speech", validate: { params: { providerId: Joi.string().uuid().required(), }, }, handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => { const { language, voice, prompt } = request.payload as { language: SayLanguage; voice: SayVoice; prompt: string; }; const twiml = new Twilio.twiml.VoiceResponse(); twiml.say({ language, voice }, prompt); return twiml.toString(); }, }, }, { method: "get", path: "/api/voice/twilio/text-to-speech-token/{providerId}", options: { description: "generates a one time token to test the twilio text-to-speech", validate: { params: { providerId: Joi.string().uuid().required(), }, }, handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { const { providerId } = request.params as { providerId: string }; const provider: SavedVoiceProvider = await request .db() .voiceProviders.findById({ id: providerId }); if (!provider) return Boom.notFound(); const frontendUrl = request.server.config().frontend.url; const url = `${frontendUrl}/api/v1/voice/twilio/text-to-speech/${providerId}`; const name = `Grabadora text-to-speech tester: ${providerId}`; const app = await getOrCreateTTSTestApplication( url, name, twilioClientFor(provider) ); const { accountSid, apiKeySecret, apiKeySid } = provider.credentials; const token = new Twilio.jwt.AccessToken( accountSid, apiKeySid, apiKeySecret, { identity: "tts-test" } ); const grant = new Twilio.jwt.AccessToken.VoiceGrant({ outgoingApplicationSid: app.sid, incomingAllow: true, }); token.addGrant(grant); return h.response({ token: token.toJwt(), }); }, }, }, ]);