oneuptime/App/FeatureSet/Notification/Services/CallService.ts

365 lines
12 KiB
TypeScript

import {
CallDefaultCostInCentsPerMinute,
CallHighRiskCostInCentsPerMinute,
getTwilioConfig,
} from "../Config";
import CallRequest, {
GatherInput,
Say,
isHighRiskPhoneNumber,
} from "Common/Types/Call/CallRequest";
import CallStatus from "Common/Types/Call/CallStatus";
import TwilioConfig from "Common/Types/CallAndSMS/TwilioConfig";
import OneUptimeDate from "Common/Types/Date";
import BadDataException from "Common/Types/Exception/BadDataException";
import JSONFunctions from "Common/Types/JSONFunctions";
import ObjectID from "Common/Types/ObjectID";
import UserNotificationStatus from "Common/Types/UserNotification/UserNotificationStatus";
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import CallLogService from "Common/Server/Services/CallLogService";
import NotificationService from "Common/Server/Services/NotificationService";
import ProjectService from "Common/Server/Services/ProjectService";
import UserOnCallLogTimelineService from "Common/Server/Services/UserOnCallLogTimelineService";
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
import logger from "Common/Server/Utils/Logger";
import CallLog from "Common/Models/DatabaseModels/CallLog";
import Project from "Common/Models/DatabaseModels/Project";
import Twilio from "twilio";
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
export default class CallService {
public static async makeCall(
callRequest: CallRequest,
options: {
projectId?: ObjectID | undefined; // project id for sms log
isSensitive?: boolean; // if true, message will not be logged
userOnCallLogTimelineId?: ObjectID | undefined; // user notification log timeline id
customTwilioConfig?: TwilioConfig | undefined;
},
): Promise<void> {
let callError: Error | null = null;
const callLog: CallLog = new CallLog();
try {
logger.debug("Call Request received.");
let callCost: number = 0;
// is no custom twilio config is provided, use default twilio config and charge for call.
const shouldChargeForCall: boolean =
IsBillingEnabled && !options.customTwilioConfig;
if (shouldChargeForCall) {
callCost = CallDefaultCostInCentsPerMinute / 100;
if (isHighRiskPhoneNumber(callRequest.to)) {
callCost = CallHighRiskCostInCentsPerMinute / 100;
}
}
logger.debug("Call Cost: " + callCost);
const twilioConfig: TwilioConfig | null =
options.customTwilioConfig || (await getTwilioConfig());
if (!twilioConfig) {
throw new BadDataException("Twilio Config not found");
}
const client: Twilio.Twilio = Twilio(
twilioConfig.accountSid,
twilioConfig.authToken,
);
callLog.toNumber = callRequest.to;
callLog.fromNumber = twilioConfig.phoneNumber;
callLog.callData =
options && options.isSensitive
? { message: "This call is sensitive and is not logged" }
: JSON.parse(JSON.stringify(callRequest));
callLog.callCostInUSDCents = 0;
if (options.projectId) {
callLog.projectId = options.projectId;
}
let project: Project | null = null;
// make sure project has enough balance.
if (options.projectId) {
project = await ProjectService.findOneById({
id: options.projectId,
select: {
smsOrCallCurrentBalanceInUSDCents: true,
enableCallNotifications: true,
lowCallAndSMSBalanceNotificationSentToOwners: true,
name: true,
notEnabledSmsOrCallNotificationSentToOwners: true,
},
props: {
isRoot: true,
},
});
logger.debug("Project found.");
if (!project) {
callLog.status = CallStatus.Error;
callLog.statusMessage = `Project ${options.projectId.toString()} not found.`;
logger.error(callLog.statusMessage);
await CallLogService.create({
data: callLog,
props: {
isRoot: true,
},
});
return;
}
if (!project.enableCallNotifications) {
callLog.status = CallStatus.Error;
callLog.statusMessage = `Call notifications are not enabled for this project. Please enable Call notifications in Project Settings.`;
logger.error(callLog.statusMessage);
await CallLogService.create({
data: callLog,
props: {
isRoot: true,
},
});
if (!project.notEnabledSmsOrCallNotificationSentToOwners) {
await ProjectService.updateOneById({
data: {
notEnabledSmsOrCallNotificationSentToOwners: true,
},
id: project.id!,
props: {
isRoot: true,
},
});
await ProjectService.sendEmailToProjectOwners(
project.id!,
"Call notifications not enabled for " + (project.name || ""),
`We tried to make a call to ${callRequest.to.toString()}. <br/> <br/> This Call was not sent because call notifications are not enabled for this project. Please enable call notifications in Project Settings.`,
);
}
return;
}
if (shouldChargeForCall) {
// check if auto recharge is enabled and current balance is low.
let updatedBalance: number =
project.smsOrCallCurrentBalanceInUSDCents!;
try {
updatedBalance = await NotificationService.rechargeIfBalanceIsLow(
project.id!,
);
} catch (err) {
logger.error(err);
}
project.smsOrCallCurrentBalanceInUSDCents = updatedBalance;
if (!project.smsOrCallCurrentBalanceInUSDCents) {
callLog.status = CallStatus.LowBalance;
callLog.statusMessage = `Project ${options.projectId.toString()} does not have enough Call balance.`;
logger.error(callLog.statusMessage);
await CallLogService.create({
data: callLog,
props: {
isRoot: true,
},
});
if (!project.lowCallAndSMSBalanceNotificationSentToOwners) {
await ProjectService.updateOneById({
data: {
lowCallAndSMSBalanceNotificationSentToOwners: true,
},
id: project.id!,
props: {
isRoot: true,
},
});
await ProjectService.sendEmailToProjectOwners(
project.id!,
"Low SMS and Call Balance for " + (project.name || ""),
`We tried to make a call to ${callRequest.to.toString()}. This call was not made because project does not have enough balance to make calls. Current balance is ${
(project.smsOrCallCurrentBalanceInUSDCents || 0) / 100
} USD. Required balance to send this SMS should is ${callCost} USD. Please enable auto recharge or recharge manually.`,
);
}
return;
}
if (project.smsOrCallCurrentBalanceInUSDCents < callCost * 100) {
callLog.status = CallStatus.LowBalance;
callLog.statusMessage = `Project does not have enough balance to make this call. Current balance is ${
project.smsOrCallCurrentBalanceInUSDCents / 100
} USD. Required balance is ${callCost} USD to make this call.`;
logger.error(callLog.statusMessage);
await CallLogService.create({
data: callLog,
props: {
isRoot: true,
},
});
if (!project.lowCallAndSMSBalanceNotificationSentToOwners) {
await ProjectService.updateOneById({
data: {
lowCallAndSMSBalanceNotificationSentToOwners: true,
},
id: project.id!,
props: {
isRoot: true,
},
});
await ProjectService.sendEmailToProjectOwners(
project.id!,
"Low SMS and Call Balance for " + (project.name || ""),
`We tried to make a call to ${callRequest.to.toString()}. This call was not made because project does not have enough balance to make a call. Current balance is ${
project.smsOrCallCurrentBalanceInUSDCents / 100
} USD. Required balance is ${callCost} USD to make this call. Please enable auto recharge or recharge manually.`,
);
}
return;
}
}
}
logger.debug("Sending Call Request.");
const twillioCall: CallInstance = await client.calls.create({
twiml: this.generateTwimlForCall(callRequest),
to: callRequest.to.toString(),
from: twilioConfig.phoneNumber.toString(), // From a valid Twilio number
});
logger.debug("Call Request sent successfully.");
callLog.status = CallStatus.Success;
callLog.statusMessage = "Call ID: " + twillioCall.sid;
logger.debug("Call ID: " + twillioCall.sid);
logger.debug(callLog.statusMessage);
if (shouldChargeForCall && project) {
logger.debug("Updating Project Balance.");
callLog.callCostInUSDCents = callCost * 100;
if (twillioCall && parseInt(twillioCall.duration) > 60) {
callLog.callCostInUSDCents = Math.ceil(
Math.ceil(parseInt(twillioCall.duration) / 60) * (callCost * 100),
);
}
logger.debug("Call Cost: " + callLog.callCostInUSDCents);
project.smsOrCallCurrentBalanceInUSDCents = Math.floor(
project.smsOrCallCurrentBalanceInUSDCents! - callCost * 100,
);
await ProjectService.updateOneById({
data: {
smsOrCallCurrentBalanceInUSDCents:
project.smsOrCallCurrentBalanceInUSDCents,
notEnabledSmsOrCallNotificationSentToOwners: false, // reset this flag
},
id: project.id!,
props: {
isRoot: true,
},
});
logger.debug("Project's current balance updated.");
logger.debug(
"Current Balance: " + project.smsOrCallCurrentBalanceInUSDCents,
);
}
} catch (e: any) {
callLog.callCostInUSDCents = 0;
callLog.status = CallStatus.Error;
callLog.statusMessage =
e && e.message ? e.message.toString() : e.toString();
logger.error("Call Request failed.");
logger.error(callLog.statusMessage);
callError = e;
}
logger.debug("Saving Call Log if project id is provided.");
if (options.projectId) {
logger.debug("Saving Call Log.");
await CallLogService.create({
data: callLog,
props: {
isRoot: true,
},
});
logger.debug("Call Log saved.");
} else {
logger.debug("Project Id is not provided. Call Log not saved.");
}
if (options.userOnCallLogTimelineId) {
await UserOnCallLogTimelineService.updateOneById({
data: {
status:
callLog.status === CallStatus.Success
? UserNotificationStatus.Sent
: UserNotificationStatus.Error,
statusMessage: callLog.statusMessage!,
},
id: options.userOnCallLogTimelineId,
props: {
isRoot: true,
},
});
}
if (callError) {
throw callError;
}
}
public static generateTwimlForCall(callRequest: CallRequest): string {
const response: Twilio.twiml.VoiceResponse =
new Twilio.twiml.VoiceResponse();
for (const item of callRequest.data) {
if ((item as Say).sayMessage) {
response.say((item as Say).sayMessage);
}
if ((item as GatherInput) && (item as GatherInput).numDigits > 0) {
response.say((item as GatherInput).introMessage);
response.gather({
numDigits: (item as GatherInput).numDigits,
timeout: (item as GatherInput).timeoutInSeconds || 5,
action: (item as GatherInput).responseUrl
.addQueryParam(
"token",
JSONWebToken.signJsonPayload(
JSONFunctions.serialize(
(item as GatherInput).onInputCallRequest as any,
),
OneUptimeDate.getDayInSeconds(),
),
)
.toString(),
method: "POST",
});
response.say((item as GatherInput).noInputMessage);
}
}
response.hangup();
return response.toString();
}
}