2021-08-08 06:37:53 +00:00
|
|
|
import type { BlitzApiRequest, BlitzApiResponse } from "blitz";
|
2021-08-13 17:16:27 +00:00
|
|
|
import twilio from "twilio";
|
2021-07-31 14:33:18 +00:00
|
|
|
|
2021-10-15 20:06:05 +00:00
|
|
|
import type { ApiError } from "app/core/types";
|
2021-10-20 23:03:32 +00:00
|
|
|
import db, { Direction, Prisma, SubscriptionStatus } from "db";
|
2021-10-15 20:06:05 +00:00
|
|
|
import appLogger from "integrations/logger";
|
|
|
|
import { translateCallStatus, voiceUrl } from "integrations/twilio";
|
2021-08-30 12:53:21 +00:00
|
|
|
import updateCallDurationQueue from "../queue/update-call-duration";
|
2021-08-13 17:16:27 +00:00
|
|
|
|
|
|
|
const logger = appLogger.child({ route: "/api/webhook/call" });
|
2021-08-08 06:37:53 +00:00
|
|
|
|
|
|
|
export default async function incomingCallHandler(req: BlitzApiRequest, res: BlitzApiResponse) {
|
|
|
|
console.log("req.body", req.body);
|
|
|
|
|
2021-08-13 17:16:27 +00:00
|
|
|
const twilioSignature = req.headers["X-Twilio-Signature"] || req.headers["x-twilio-signature"];
|
|
|
|
if (!twilioSignature || Array.isArray(twilioSignature)) {
|
|
|
|
const statusCode = 400;
|
|
|
|
const apiError: ApiError = {
|
|
|
|
statusCode,
|
|
|
|
errorMessage: "Invalid header X-Twilio-Signature",
|
|
|
|
};
|
|
|
|
logger.error(apiError);
|
|
|
|
|
|
|
|
res.status(statusCode).send(apiError);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const isOutgoingCall = req.body.From.startsWith("client:");
|
2021-08-08 06:37:53 +00:00
|
|
|
if (isOutgoingCall) {
|
|
|
|
const recipient = req.body.To;
|
|
|
|
const organizationId = req.body.From.slice("client:".length).split("__")[0];
|
|
|
|
const phoneNumber = await db.phoneNumber.findFirst({
|
2021-10-20 22:24:18 +00:00
|
|
|
// TODO: use the active number, not the first one
|
2021-08-08 06:37:53 +00:00
|
|
|
where: { organizationId },
|
2021-10-15 20:06:05 +00:00
|
|
|
include: {
|
|
|
|
organization: {
|
|
|
|
include: {
|
|
|
|
subscriptions: {
|
2021-10-20 23:03:32 +00:00
|
|
|
where: {
|
|
|
|
OR: [
|
|
|
|
{ status: { not: SubscriptionStatus.deleted } },
|
|
|
|
{
|
|
|
|
status: SubscriptionStatus.deleted,
|
|
|
|
cancellationEffectiveDate: { gt: new Date() },
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
orderBy: { lastEventTime: Prisma.SortOrder.desc },
|
2021-10-15 20:06:05 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2021-08-13 17:16:27 +00:00
|
|
|
});
|
2021-10-15 20:06:05 +00:00
|
|
|
if (phoneNumber?.organization.subscriptions.length === 0) {
|
|
|
|
// decline the outgoing call because
|
|
|
|
// the organization is on the free plan
|
|
|
|
res.status(402).end();
|
|
|
|
}
|
|
|
|
|
2021-08-13 17:16:27 +00:00
|
|
|
if (
|
|
|
|
!phoneNumber ||
|
|
|
|
!phoneNumber.organization.twilioAuthToken ||
|
2021-08-30 11:24:05 +00:00
|
|
|
!twilio.validateRequest(phoneNumber.organization.twilioAuthToken, twilioSignature, voiceUrl, req.body)
|
2021-08-13 17:16:27 +00:00
|
|
|
) {
|
|
|
|
const statusCode = 400;
|
|
|
|
const apiError: ApiError = {
|
|
|
|
statusCode,
|
|
|
|
errorMessage: "Invalid webhook",
|
|
|
|
};
|
|
|
|
logger.error(apiError);
|
|
|
|
|
|
|
|
res.status(statusCode).send(apiError);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await db.phoneCall.create({
|
|
|
|
data: {
|
|
|
|
id: req.body.CallSid,
|
|
|
|
from: phoneNumber.number,
|
|
|
|
to: req.body.To,
|
2021-08-30 12:53:21 +00:00
|
|
|
status: translateCallStatus(req.body.CallStatus),
|
2021-08-13 17:16:27 +00:00
|
|
|
direction: Direction.Outbound,
|
2021-08-30 12:53:21 +00:00
|
|
|
duration: "0",
|
2021-08-13 17:16:27 +00:00
|
|
|
organizationId: phoneNumber.organization.id,
|
|
|
|
phoneNumberId: phoneNumber.id,
|
|
|
|
},
|
2021-08-08 06:37:53 +00:00
|
|
|
});
|
2021-08-30 12:53:21 +00:00
|
|
|
await updateCallDurationQueue.enqueue(
|
|
|
|
{
|
|
|
|
organizationId: phoneNumber.organization.id,
|
|
|
|
callId: req.body.CallSid,
|
|
|
|
},
|
|
|
|
{ delay: "30s" },
|
|
|
|
);
|
|
|
|
|
2021-10-15 20:06:05 +00:00
|
|
|
const voiceResponse = new twilio.twiml.VoiceResponse();
|
|
|
|
const dial = voiceResponse.dial({
|
2021-08-08 06:37:53 +00:00
|
|
|
answerOnBridge: true,
|
|
|
|
callerId: phoneNumber!.number,
|
|
|
|
});
|
|
|
|
dial.number(recipient);
|
2021-10-15 20:06:05 +00:00
|
|
|
console.log("twiml voiceResponse", voiceResponse.toString());
|
2021-08-08 06:37:53 +00:00
|
|
|
|
|
|
|
res.setHeader("content-type", "text/xml");
|
2021-10-15 20:06:05 +00:00
|
|
|
return res.status(200).send(voiceResponse.toString());
|
2021-08-13 17:16:27 +00:00
|
|
|
} else {
|
|
|
|
const phoneNumbers = await db.phoneNumber.findMany({
|
|
|
|
where: { number: req.body.To },
|
2021-10-15 20:06:54 +00:00
|
|
|
include: {
|
|
|
|
organization: {
|
|
|
|
include: {
|
|
|
|
subscriptions: {
|
2021-10-20 23:03:32 +00:00
|
|
|
where: {
|
|
|
|
OR: [
|
|
|
|
{ status: { not: SubscriptionStatus.deleted } },
|
|
|
|
{
|
|
|
|
status: SubscriptionStatus.deleted,
|
|
|
|
cancellationEffectiveDate: { gt: new Date() },
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
orderBy: { lastEventTime: Prisma.SortOrder.desc },
|
2021-10-15 20:06:54 +00:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2021-08-13 17:16:27 +00:00
|
|
|
});
|
|
|
|
if (phoneNumbers.length === 0) {
|
|
|
|
// phone number is not registered by any organization
|
|
|
|
res.status(500).end();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-10-15 20:06:54 +00:00
|
|
|
const phoneNumbersWithActiveSub = phoneNumbers.filter(
|
|
|
|
(phoneNumber) => phoneNumber.organization.subscriptions.length > 0,
|
|
|
|
);
|
|
|
|
if (phoneNumbersWithActiveSub.length === 0) {
|
|
|
|
// accept the webhook but reject incoming call
|
|
|
|
// because the organization is on the free plan
|
|
|
|
const voiceResponse = new twilio.twiml.VoiceResponse();
|
|
|
|
voiceResponse.reject();
|
|
|
|
|
|
|
|
console.log("twiml voiceResponse", voiceResponse);
|
|
|
|
res.setHeader("content-type", "text/xml");
|
|
|
|
res.status(200).send(voiceResponse.toString());
|
|
|
|
}
|
|
|
|
const phoneNumber = phoneNumbersWithActiveSub.find((phoneNumber) => {
|
2021-08-13 17:16:27 +00:00
|
|
|
// if multiple organizations have the same number
|
|
|
|
// find the organization currently using that phone number
|
2021-10-15 20:06:54 +00:00
|
|
|
// maybe we shouldn't let that happen by restricting a phone number to one org?
|
2021-08-13 17:16:27 +00:00
|
|
|
const authToken = phoneNumber.organization.twilioAuthToken ?? "";
|
2021-08-30 11:24:05 +00:00
|
|
|
return twilio.validateRequest(authToken, twilioSignature, voiceUrl, req.body);
|
2021-08-13 17:16:27 +00:00
|
|
|
});
|
|
|
|
if (!phoneNumber) {
|
|
|
|
const statusCode = 400;
|
|
|
|
const apiError: ApiError = {
|
|
|
|
statusCode,
|
|
|
|
errorMessage: "Invalid webhook",
|
|
|
|
};
|
|
|
|
logger.error(apiError);
|
|
|
|
|
|
|
|
res.status(statusCode).send(apiError);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-08-20 00:20:03 +00:00
|
|
|
// TODO dial.client("unique id of device user is picking up with");
|
2021-08-13 17:16:27 +00:00
|
|
|
// TODO send notification
|
|
|
|
// TODO db.phoneCall.create(...);
|
2021-08-30 20:13:40 +00:00
|
|
|
// TODO subscribe to status updates to update duration when call ends
|
2021-08-08 06:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
res.status(500).end();
|
|
|
|
}
|
|
|
|
|
|
|
|
const outgoingBody = {
|
|
|
|
AccountSid: "ACa886d066be0832990d1cf43fb1d53362",
|
|
|
|
ApiVersion: "2010-04-01",
|
|
|
|
ApplicationSid: "AP6334c6dd54f5808717b37822de4e4e14",
|
|
|
|
CallSid: "CA3b639875693fd8f563e07937780c9f5f",
|
|
|
|
CallStatus: "ringing",
|
|
|
|
Called: "",
|
|
|
|
Caller: "client:95267d60-3d35-4c36-9905-8543ecb4f174__673b461a-11ba-43a4-89d7-9e29403053d4",
|
|
|
|
Direction: "inbound",
|
|
|
|
From: "client:95267d60-3d35-4c36-9905-8543ecb4f174__673b461a-11ba-43a4-89d7-9e29403053d4",
|
|
|
|
To: "+33613370787",
|
|
|
|
};
|
2021-08-30 20:13:40 +00:00
|
|
|
|
|
|
|
const incomingBody = {
|
|
|
|
AccountSid: "ACa886d066be0832990d1cf43fb1d53362",
|
|
|
|
ApiVersion: "2010-04-01",
|
|
|
|
ApplicationSid: "APa43d85150ad6f6cf9869fbe1c1e36a66",
|
|
|
|
CallSid: "CA09a5d9a4cfacf2b56d66f8f743d2881a",
|
|
|
|
CallStatus: "ringing",
|
|
|
|
Called: "+33757592025",
|
|
|
|
CalledCity: "",
|
|
|
|
CalledCountry: "FR",
|
|
|
|
CalledState: "",
|
|
|
|
CalledZip: "",
|
|
|
|
Caller: "+33613370787",
|
|
|
|
CallerCity: "",
|
|
|
|
CallerCountry: "FR",
|
|
|
|
CallerState: "",
|
|
|
|
CallerZip: "",
|
|
|
|
Direction: "inbound",
|
|
|
|
From: "+33613370787",
|
|
|
|
FromCity: "",
|
|
|
|
FromCountry: "FR",
|
|
|
|
FromState: "",
|
|
|
|
FromZip: "",
|
|
|
|
To: "+33757592025",
|
|
|
|
ToCity: "",
|
|
|
|
ToCountry: "FR",
|
|
|
|
ToState: "",
|
|
|
|
ToZip: "",
|
|
|
|
};
|