Files
oneuptime/App/FeatureSet/Notification/API/WhatsApp.ts

497 lines
14 KiB
TypeScript

import WhatsAppService from "../Services/WhatsAppService";
import BadDataException from "Common/Types/Exception/BadDataException";
import GlobalConfig from "Common/Models/DatabaseModels/GlobalConfig";
import { JSONArray, JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import Phone from "Common/Types/Phone";
import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage";
import {
WhatsAppTemplateId,
WhatsAppTemplateIds,
WhatsAppTemplateLanguage,
} from "Common/Types/WhatsApp/WhatsAppTemplates";
import WhatsAppStatus from "Common/Types/WhatsAppStatus";
import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization";
import UserMiddleware from "Common/Server/Middleware/UserAuthorization";
import WhatsAppAuthorization from "Common/Server/Middleware/WhatsAppAuthorization";
import WhatsAppLogService from "Common/Server/Services/WhatsAppLogService";
import GlobalConfigService from "Common/Server/Services/GlobalConfigService";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
const router: ExpressRouter = Express.getRouter();
const MAX_STATUS_MESSAGE_LENGTH: number = 500;
export const mapWebhookStatusToWhatsAppStatus: (
status?: string,
) => WhatsAppStatus = (status?: string): WhatsAppStatus => {
switch ((status || "").toLowerCase()) {
case "sent":
return WhatsAppStatus.Sent;
case "delivered":
return WhatsAppStatus.Delivered;
case "read":
return WhatsAppStatus.Read;
case "failed":
return WhatsAppStatus.Failed;
case "deleted":
case "removed":
return WhatsAppStatus.Deleted;
case "warning":
return WhatsAppStatus.Warning;
case "queued":
case "pending":
return WhatsAppStatus.Queued;
case "error":
return WhatsAppStatus.Error;
case "success":
return WhatsAppStatus.Success;
default:
return WhatsAppStatus.Unknown;
}
};
export const buildStatusMessage: (payload: JSONObject) => string | undefined = (
payload: JSONObject,
): string | undefined => {
const messageParts: Array<string> = [];
const rawStatus: string | undefined = payload["status"]
? String(payload["status"])
: undefined;
if (rawStatus) {
messageParts.push(`Status: ${rawStatus}`);
}
const timestamp: string | undefined = payload["timestamp"]
? String(payload["timestamp"])
: undefined;
if (timestamp) {
const numericTimestamp: number = Number(timestamp);
if (!isNaN(numericTimestamp)) {
messageParts.push(
`Timestamp: ${new Date(numericTimestamp * 1000).toISOString()}`,
);
} else {
messageParts.push(`Timestamp: ${timestamp}`);
}
}
const conversation: JSONObject | undefined =
(payload["conversation"] as JSONObject | undefined) || undefined;
if (conversation) {
if (conversation["id"]) {
messageParts.push(`Conversation: ${conversation["id"]}`);
}
const origin: JSONObject | undefined =
(conversation["origin"] as JSONObject | undefined) || undefined;
if (origin?.["type"]) {
messageParts.push(`Origin: ${origin["type"]}`);
}
if (conversation["expiration_timestamp"]) {
const expirationTimestamp: number = Number(
conversation["expiration_timestamp"],
);
if (!isNaN(expirationTimestamp)) {
messageParts.push(
`Conversation expires: ${new Date(expirationTimestamp * 1000).toISOString()}`,
);
}
}
}
const pricing: JSONObject | undefined =
(payload["pricing"] as JSONObject | undefined) || undefined;
if (pricing) {
const pricingParts: Array<string> = [];
if (pricing["billable"] !== undefined) {
pricingParts.push(`billable=${pricing["billable"]}`);
}
if (pricing["category"]) {
pricingParts.push(`category=${pricing["category"]}`);
}
if (pricing["pricing_model"]) {
pricingParts.push(`model=${pricing["pricing_model"]}`);
}
if (pricingParts.length > 0) {
messageParts.push(`Pricing: ${pricingParts.join(", ")}`);
}
}
const errors: JSONArray | undefined =
(payload["errors"] as JSONArray | undefined) || undefined;
if (Array.isArray(errors) && errors.length > 0) {
const firstError: JSONObject = errors[0] as JSONObject;
const errorParts: Array<string> = [];
if (firstError["title"]) {
errorParts.push(String(firstError["title"]));
}
if (firstError["code"]) {
errorParts.push(`code=${firstError["code"]}`);
}
if (firstError["detail"]) {
errorParts.push(String(firstError["detail"]));
}
if (errorParts.length > 0) {
messageParts.push(`Error: ${errorParts.join(" - ")}`);
}
}
if (messageParts.length === 0) {
return undefined;
}
const statusMessage: string = messageParts.join(" | ");
if (statusMessage.length <= MAX_STATUS_MESSAGE_LENGTH) {
return statusMessage;
}
return `${statusMessage.substring(0, MAX_STATUS_MESSAGE_LENGTH - 3)}...`;
};
const updateWhatsAppLogStatus: (
statusPayload: JSONObject,
) => Promise<void> = async (statusPayload: JSONObject): Promise<void> => {
const messageId: string | undefined = statusPayload["id"]
? String(statusPayload["id"])
: undefined;
if (!messageId) {
logger.warn(
`[Meta WhatsApp Webhook] Received status payload without message id. Payload: ${JSON.stringify(statusPayload)}`,
);
return;
}
const rawStatus: string | undefined = statusPayload["status"]
? String(statusPayload["status"])
: undefined;
const derivedStatus: WhatsAppStatus =
mapWebhookStatusToWhatsAppStatus(rawStatus);
const statusMessage: string | undefined = buildStatusMessage(statusPayload);
const updateResult: number = await WhatsAppLogService.updateOneBy({
query: {
whatsAppMessageId: messageId,
},
data: {
status: derivedStatus,
...(statusMessage ? { statusMessage } : {}),
},
props: {
isRoot: true,
},
});
if (updateResult === 0) {
logger.warn(
`[Meta WhatsApp Webhook] No WhatsApp log found for message id ${messageId}. Payload: ${JSON.stringify(statusPayload)}`,
);
} else {
logger.debug(
`[Meta WhatsApp Webhook] Updated WhatsApp log for message id ${messageId} with status ${derivedStatus}.`,
);
}
};
const toTemplateVariables: (
rawVariables: JSONObject | undefined,
) => Record<string, string> | undefined = (
rawVariables: JSONObject | undefined,
): Record<string, string> | undefined => {
if (!rawVariables) {
return undefined;
}
const result: Record<string, string> = {};
for (const key of Object.keys(rawVariables)) {
const value: unknown = rawVariables[key];
if (value !== null && value !== undefined) {
result[key] = String(value);
}
}
return Object.keys(result).length > 0 ? result : undefined;
};
router.post(
"/send",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
const body: JSONObject = req.body as JSONObject;
if (!body["to"]) {
throw new BadDataException("`to` phone number is required");
}
const toPhone: Phone = new Phone(body["to"] as string);
const message: WhatsAppMessage = {
to: toPhone,
body: (body["body"] as string) || "",
templateKey: (body["templateKey"] as string) || undefined,
templateVariables: toTemplateVariables(
body["templateVariables"] as JSONObject | undefined,
),
templateLanguageCode:
(body["templateLanguageCode"] as string) || undefined,
};
try {
await WhatsAppService.sendWhatsApp(message, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId: body["userOnCallLogTimelineId"]
? new ObjectID(body["userOnCallLogTimelineId"] as string)
: undefined,
incidentId: body["incidentId"]
? new ObjectID(body["incidentId"] as string)
: undefined,
alertId: body["alertId"]
? new ObjectID(body["alertId"] as string)
: undefined,
scheduledMaintenanceId: body["scheduledMaintenanceId"]
? new ObjectID(body["scheduledMaintenanceId"] as string)
: undefined,
statusPageId: body["statusPageId"]
? new ObjectID(body["statusPageId"] as string)
: undefined,
statusPageAnnouncementId: body["statusPageAnnouncementId"]
? new ObjectID(body["statusPageAnnouncementId"] as string)
: undefined,
userId: body["userId"]
? new ObjectID(body["userId"] as string)
: undefined,
onCallPolicyId: body["onCallPolicyId"]
? new ObjectID(body["onCallPolicyId"] as string)
: undefined,
onCallPolicyEscalationRuleId: body["onCallPolicyEscalationRuleId"]
? new ObjectID(body["onCallPolicyEscalationRuleId"] as string)
: undefined,
onCallDutyPolicyExecutionLogTimelineId: body[
"onCallDutyPolicyExecutionLogTimelineId"
]
? new ObjectID(
body["onCallDutyPolicyExecutionLogTimelineId"] as string,
)
: undefined,
onCallScheduleId: body["onCallScheduleId"]
? new ObjectID(body["onCallScheduleId"] as string)
: undefined,
teamId: body["teamId"]
? new ObjectID(body["teamId"] as string)
: undefined,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.get("/webhook", async (req: ExpressRequest, res: ExpressResponse) => {
const mode: string | undefined = req.query["hub.mode"]
? String(req.query["hub.mode"])
: undefined;
const verifyToken: string | undefined = req.query["hub.verify_token"]
? String(req.query["hub.verify_token"])
: undefined;
const challenge: string | undefined = req.query["hub.challenge"]
? String(req.query["hub.challenge"])
: undefined;
if (mode === "subscribe" && challenge) {
const globalConfigTokenResponse: GlobalConfig | null =
await GlobalConfigService.findOneBy({
query: {
_id: ObjectID.getZeroObjectID().toString(),
},
props: {
isRoot: true,
},
select: {
metaWhatsAppWebhookVerifyToken: true,
},
});
const configuredVerifyToken: string | undefined =
globalConfigTokenResponse?.metaWhatsAppWebhookVerifyToken?.trim() ||
undefined;
if (!configuredVerifyToken) {
logger.error(
"Meta WhatsApp webhook verify token is not configured. Rejecting verification request.",
);
res.sendStatus(403);
return;
}
if (verifyToken === configuredVerifyToken) {
res.status(200).send(challenge);
return;
}
logger.warn(
"Meta WhatsApp webhook verification failed due to token mismatch.",
);
res.sendStatus(403);
return;
}
res.sendStatus(400);
});
router.post(
"/webhook",
WhatsAppAuthorization.isAuthorizedWhatsAppRequest,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body as JSONObject;
if (
(body["object"] as string | undefined) !== "whatsapp_business_account"
) {
logger.debug(
`[Meta WhatsApp Webhook] Received event for unsupported object: ${JSON.stringify(body)}`,
);
return Response.sendEmptySuccessResponse(req, res);
}
const entries: JSONArray | undefined = body["entry"] as
| JSONArray
| undefined;
if (!Array.isArray(entries)) {
logger.warn(
`[Meta WhatsApp Webhook] Payload did not include entries array. Payload: ${JSON.stringify(body)}`,
);
return Response.sendEmptySuccessResponse(req, res);
}
const statusUpdatePromises: Array<Promise<void>> = [];
for (const entry of entries) {
const entryObject: JSONObject = entry as JSONObject;
const changes: JSONArray | undefined =
(entryObject["changes"] as JSONArray | undefined) || undefined;
if (!Array.isArray(changes)) {
continue;
}
for (const change of changes) {
const changeObject: JSONObject = change as JSONObject;
const value: JSONObject | undefined =
(changeObject["value"] as JSONObject | undefined) || undefined;
if (!value) {
continue;
}
const statuses: JSONArray | undefined =
(value["statuses"] as JSONArray | undefined) || undefined;
if (Array.isArray(statuses)) {
for (const statusItem of statuses) {
statusUpdatePromises.push(
updateWhatsAppLogStatus(statusItem as JSONObject),
);
}
}
}
}
if (statusUpdatePromises.length > 0) {
await Promise.allSettled(statusUpdatePromises);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.post(
"/test",
UserMiddleware.getUserMiddleware,
UserMiddleware.requireUserAuthentication,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body as JSONObject;
if (!body["toPhone"]) {
throw new BadDataException("toPhone is required");
}
const toPhone: Phone = new Phone(body["toPhone"] as string);
const templateKey: WhatsAppTemplateId =
WhatsAppTemplateIds.TestNotification;
const templateLanguageCode: string =
WhatsAppTemplateLanguage[templateKey] || "en";
const message: WhatsAppMessage = {
to: toPhone,
body: "",
templateKey,
templateVariables: undefined,
templateLanguageCode,
};
try {
await WhatsAppService.sendWhatsApp(message, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
isSensitive: false,
});
} catch (err) {
const errorMsg: string =
err instanceof Error && err.message
? err.message
: "Failed to send test WhatsApp message.";
throw new BadDataException(errorMsg);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
export default router;