chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
186
lib/whatsapp-provider.ts
Normal file
186
lib/whatsapp-provider.ts
Normal file
@ -0,0 +1,186 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user