Organize directories
This commit is contained in:
parent
8a91c9b89b
commit
4898382f78
433 changed files with 0 additions and 0 deletions
124
apps/metamigo-api/app/routes/voice/index.ts
Normal file
124
apps/metamigo-api/app/routes/voice/index.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import * as Hapi from "@hapi/hapi";
|
||||
import * as Joi from "joi";
|
||||
import * as Boom from "@hapi/boom";
|
||||
import * as R from "remeda";
|
||||
import * as Helpers from "../helpers";
|
||||
import Twilio from "twilio";
|
||||
import { crudRoutesFor, CrudControllerBase } from "common";
|
||||
import { VoiceLineRecord, SavedVoiceLine } from "db";
|
||||
|
||||
const TwilioHandlers = {
|
||||
freeNumbers: async (provider, request: Hapi.Request) => {
|
||||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
const client = Twilio(apiKeySid, apiKeySecret, {
|
||||
accountSid,
|
||||
});
|
||||
const numbers = R.pipe(
|
||||
await client.incomingPhoneNumbers.list({ limit: 100 }),
|
||||
R.filter((n) => n.capabilities.voice),
|
||||
R.map(R.pick(["sid", "phoneNumber"]))
|
||||
);
|
||||
const numberSids = R.map(numbers, R.prop("sid"));
|
||||
const voiceLineRepo = request.db().voiceLines;
|
||||
const voiceLines: SavedVoiceLine[] =
|
||||
await voiceLineRepo.findAllByProviderLineSids(numberSids);
|
||||
const voiceLineSids = new Set(R.map(voiceLines, R.prop("providerLineSid")));
|
||||
|
||||
return R.pipe(
|
||||
numbers,
|
||||
R.reject((n) => voiceLineSids.has(n.sid)),
|
||||
R.map((n) => ({ id: n.sid, name: n.phoneNumber }))
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const VoiceProviderRoutes = Helpers.withDefaults([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/voice/providers/{providerId}/freeNumbers",
|
||||
options: {
|
||||
description:
|
||||
"get a list of the incoming numbers for a provider account that aren't assigned to a voice line",
|
||||
validate: {
|
||||
params: {
|
||||
providerId: Joi.string().uuid().required(),
|
||||
},
|
||||
},
|
||||
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
|
||||
const { providerId } = request.params;
|
||||
const voiceProvidersRepo = request.db().voiceProviders;
|
||||
const provider = await voiceProvidersRepo.findById(providerId);
|
||||
if (!provider) return Boom.notFound();
|
||||
switch (provider.kind) {
|
||||
case "TWILIO":
|
||||
return TwilioHandlers.freeNumbers(provider, request);
|
||||
default:
|
||||
return Boom.badImplementation();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
class VoiceLineRecordController extends CrudControllerBase(VoiceLineRecord) { }
|
||||
|
||||
const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
|
||||
create: {
|
||||
payload: Joi.object({
|
||||
providerType: Joi.string().required(),
|
||||
providerId: Joi.string().required(),
|
||||
number: Joi.string().required(),
|
||||
language: Joi.string().required(),
|
||||
voice: Joi.string().required(),
|
||||
promptText: Joi.string().optional(),
|
||||
promptRecording: Joi.binary()
|
||||
.encoding("base64")
|
||||
.max(50 * 1000 * 1000)
|
||||
.optional(),
|
||||
}).label("VoiceLineCreate"),
|
||||
},
|
||||
updateById: {
|
||||
params: {
|
||||
id: Joi.string().uuid().required(),
|
||||
},
|
||||
payload: Joi.object({
|
||||
providerType: Joi.string().optional(),
|
||||
providerId: Joi.string().optional(),
|
||||
number: Joi.string().optional(),
|
||||
language: Joi.string().optional(),
|
||||
voice: Joi.string().optional(),
|
||||
promptText: Joi.string().optional(),
|
||||
promptRecording: Joi.binary()
|
||||
.encoding("base64")
|
||||
.max(50 * 1000 * 1000)
|
||||
.optional(),
|
||||
}).label("VoiceLineUpdate"),
|
||||
},
|
||||
deleteById: {
|
||||
params: {
|
||||
id: Joi.string().uuid().required(),
|
||||
},
|
||||
},
|
||||
getById: {
|
||||
params: {
|
||||
id: Joi.string().uuid().required(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const VoiceLineRoutes = async (
|
||||
_server: Hapi.Server
|
||||
): Promise<Hapi.ServerRoute[]> => {
|
||||
const controller = new VoiceLineRecordController("voiceLines", "id");
|
||||
return Helpers.withDefaults(
|
||||
crudRoutesFor(
|
||||
"voice-line",
|
||||
"/api/voice/voice-line",
|
||||
controller,
|
||||
"id",
|
||||
validator()
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export * from "./twilio";
|
||||
230
apps/metamigo-api/app/routes/voice/twilio/index.ts
Normal file
230
apps/metamigo-api/app/routes/voice/twilio/index.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
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(),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
Loading…
Add table
Add a link
Reference in a new issue