230 lines
7 KiB
TypeScript
230 lines
7 KiB
TypeScript
import * as Hapi from "@hapi/hapi";
|
|
import * as Joi from "joi";
|
|
import * as Boom from "@hapi/boom";
|
|
import Twilio from "twilio";
|
|
import { SavedVoiceProvider } from "@digiresilience/metamigo-db";
|
|
import pMemoize from "p-memoize";
|
|
import ExpiryMap from "expiry-map";
|
|
import ms from "ms";
|
|
import * as Helpers from "../../helpers";
|
|
import workerUtils from "../../../../worker-utils";
|
|
|
|
const queueRecording = async (meta) =>
|
|
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 cache = new ExpiryMap(ms("1h"));
|
|
const getOrCreateTTSTestApplication = pMemoize(_getOrCreateTTSTestApplication, {
|
|
cache,
|
|
});
|
|
|
|
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(),
|
|
},
|
|
},
|
|
async handler(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(),
|
|
},
|
|
},
|
|
async handler(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 any,
|
|
voice: voiceLine.voice as any,
|
|
},
|
|
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(),
|
|
},
|
|
},
|
|
async handler(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(),
|
|
},
|
|
},
|
|
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
|
const { language, voice, prompt } = request.payload as {
|
|
language: any;
|
|
voice: any;
|
|
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(),
|
|
},
|
|
},
|
|
async handler(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(),
|
|
});
|
|
},
|
|
},
|
|
},
|
|
]);
|