187 lines
4.3 KiB
TypeScript
187 lines
4.3 KiB
TypeScript
import { DeliveryStatus } from "@prisma/client";
|
|
|
|
export type OutboundMessageRequest = {
|
|
tenantId: string;
|
|
channelId: string;
|
|
channelProvider: string;
|
|
phoneNumberId: string | null;
|
|
to: string;
|
|
content: string;
|
|
messageId: string;
|
|
};
|
|
|
|
export type OutboundMessageResult = {
|
|
success: boolean;
|
|
provider: string;
|
|
deliveryStatus: DeliveryStatus;
|
|
providerMessageId?: string | null;
|
|
failureReason?: string;
|
|
};
|
|
|
|
type MetaResponse = {
|
|
messages?: Array<{ id?: string }>;
|
|
error?: {
|
|
message?: string;
|
|
};
|
|
};
|
|
|
|
function normalizeProvider(provider: string) {
|
|
return provider.trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeToPhone(phone: string) {
|
|
return phone.replace(/\D/g, "");
|
|
}
|
|
|
|
function randomMockMessageId() {
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto && typeof crypto.randomUUID === "function") {
|
|
return `mock-${crypto.randomUUID()}`;
|
|
}
|
|
|
|
return `mock-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
|
}
|
|
|
|
function isMockAllowed() {
|
|
const explicit = process.env.WHATSAPP_ALLOW_SIMULATED_SEND?.trim().toLowerCase();
|
|
if (process.env.NODE_ENV === "production") {
|
|
return false;
|
|
}
|
|
|
|
if (explicit === undefined) {
|
|
return true;
|
|
}
|
|
|
|
return explicit === "true" || explicit === "1" || explicit === "yes";
|
|
}
|
|
|
|
async function sendViaMeta({
|
|
to,
|
|
content,
|
|
phoneNumberId
|
|
}: {
|
|
to: string;
|
|
content: string;
|
|
phoneNumberId: string;
|
|
}) {
|
|
const token = process.env.WHATSAPP_API_TOKEN?.trim();
|
|
const version = process.env.WHATSAPP_API_VERSION?.trim() || "v22.0";
|
|
|
|
if (!token) {
|
|
return {
|
|
success: false,
|
|
failureReason: "WHATSAPP_API_TOKEN is not configured"
|
|
};
|
|
}
|
|
|
|
const normalizedTo = normalizeToPhone(to);
|
|
const response = await fetch(`https://graph.facebook.com/${version}/${phoneNumberId}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify({
|
|
messaging_product: "whatsapp",
|
|
to: normalizedTo,
|
|
type: "text",
|
|
text: {
|
|
preview_url: false,
|
|
body: content
|
|
}
|
|
})
|
|
});
|
|
|
|
const payloadText = await response.text();
|
|
let parsed: MetaResponse = {};
|
|
try {
|
|
parsed = JSON.parse(payloadText) as MetaResponse;
|
|
} catch {
|
|
// ignore parse error
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
success: false,
|
|
failureReason: parsed.error?.message
|
|
? `Meta API error: ${parsed.error.message}`
|
|
: `Meta API ${response.status}: ${payloadText.slice(0, 400)}`
|
|
};
|
|
}
|
|
|
|
const providerMessageId = parsed.messages?.[0]?.id?.trim();
|
|
if (!providerMessageId) {
|
|
return {
|
|
success: false,
|
|
failureReason: "Meta API success response does not include message id"
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
providerMessageId
|
|
};
|
|
}
|
|
|
|
export async function sendOutboundTextMessage(input: OutboundMessageRequest): Promise<OutboundMessageResult> {
|
|
const normalized = normalizeProvider(input.channelProvider);
|
|
const defaultResult: OutboundMessageResult = {
|
|
success: false,
|
|
provider: normalized || "unknown",
|
|
deliveryStatus: DeliveryStatus.FAILED
|
|
};
|
|
|
|
if (!input.phoneNumberId || !input.to || !input.content) {
|
|
return {
|
|
...defaultResult,
|
|
failureReason: "Missing recipient or message data"
|
|
};
|
|
}
|
|
|
|
if (!normalized.includes("meta")) {
|
|
if (isMockAllowed()) {
|
|
return {
|
|
success: true,
|
|
provider: "mock",
|
|
deliveryStatus: DeliveryStatus.SENT,
|
|
providerMessageId: randomMockMessageId()
|
|
};
|
|
}
|
|
|
|
return {
|
|
...defaultResult,
|
|
failureReason: `Unsupported provider "${input.channelProvider}". Configure WhatsApp provider or enable simulation.`
|
|
};
|
|
}
|
|
|
|
const metaResult = await sendViaMeta({
|
|
to: input.to,
|
|
content: input.content,
|
|
phoneNumberId: input.phoneNumberId
|
|
});
|
|
|
|
if (!metaResult.success) {
|
|
if (!isMockAllowed()) {
|
|
return {
|
|
...defaultResult,
|
|
provider: "meta",
|
|
failureReason: metaResult.failureReason
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
provider: "mock",
|
|
deliveryStatus: DeliveryStatus.SENT,
|
|
providerMessageId: randomMockMessageId(),
|
|
failureReason: metaResult.failureReason
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
provider: "meta",
|
|
deliveryStatus: DeliveryStatus.SENT,
|
|
providerMessageId: metaResult.providerMessageId
|
|
};
|
|
}
|