import crypto from "crypto"; import type { RespuestaBot } from "./whatsapp-bot"; /** * Cliente de la WhatsApp Business Cloud API (Graph API de Meta). * Traduce las `RespuestaBot` que emite la máquina de conversación a los payloads * que espera Meta. El bot no sabe nada de Meta: todo el acople vive acá. */ const GRAPH_VERSION = "v21.0"; // Límites de la API de Meta — si nos pasamos, el mensaje se rechaza entero. const MAX_TEXTO = 4096; const MAX_BODY_INTERACTIVO = 1024; const MAX_BOTONES = 3; const MAX_TITULO_BOTON = 20; function credenciales() { const token = process.env.WHATSAPP_TOKEN; const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; if (!token || !phoneNumberId) return null; return { token, phoneNumberId }; } export function whatsappConfigurado(): boolean { return credenciales() !== null; } /** Valida la firma X-Hub-Signature-256 del webhook contra el cuerpo crudo. */ export function firmaValida(cuerpoCrudo: string, firmaHeader: string | null): boolean { const appSecret = process.env.WHATSAPP_APP_SECRET; if (!appSecret) return false; if (!firmaHeader?.startsWith("sha256=")) return false; const esperada = crypto.createHmac("sha256", appSecret).update(cuerpoCrudo, "utf8").digest("hex"); const recibida = firmaHeader.slice("sha256=".length); // Comparación de tiempo constante; timingSafeEqual explota si difieren los largos. const a = Buffer.from(esperada, "utf8"); const b = Buffer.from(recibida, "utf8"); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } async function postMensaje(payload: Record): Promise { const cred = credenciales(); if (!cred) { console.error("[whatsapp] falta WHATSAPP_TOKEN o WHATSAPP_PHONE_NUMBER_ID; no se envía nada"); return; } const res = await fetch(`https://graph.facebook.com/${GRAPH_VERSION}/${cred.phoneNumberId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${cred.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ messaging_product: "whatsapp", ...payload }), }); if (!res.ok) { const detalle = await res.text().catch(() => ""); console.error(`[whatsapp] error ${res.status} al enviar mensaje: ${detalle}`); } } async function enviarTexto(telefono: string, texto: string): Promise { await postMensaje({ to: telefono, type: "text", text: { body: texto.slice(0, MAX_TEXTO), preview_url: false }, }); } async function enviarBotones( telefono: string, texto: string, botones: { id: string; titulo: string }[] ): Promise { // Meta banca 3 botones como máximo. La máquina de conversación siempre deja las // mismas opciones disponibles por número/letra, así que recortar no rompe el flujo. const recortados = botones.slice(0, MAX_BOTONES).map((b) => ({ type: "reply", reply: { id: b.id, title: b.titulo.slice(0, MAX_TITULO_BOTON) }, })); if (recortados.length === 0) { await enviarTexto(telefono, texto); return; } await postMensaje({ to: telefono, type: "interactive", interactive: { type: "button", body: { text: texto.slice(0, MAX_BODY_INTERACTIVO) }, action: { buttons: recortados }, }, }); } /** Envía en orden las respuestas del bot (secuencial: WhatsApp respeta el orden de llegada). */ export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise { for (const r of respuestas) { if (r.tipo === "botones") await enviarBotones(telefono, r.texto, r.botones); else await enviarTexto(telefono, r.texto); } } /** Marca el mensaje como leído (los tildes azules). Best-effort: si falla, no importa. */ export async function marcarLeido(messageId: string): Promise { await postMensaje({ status: "read", message_id: messageId }); } // ── Entrada: payload del webhook → mensajes ── // Forma (parcial) de lo que manda Meta; solo lo que consumimos. export type MensajeMeta = { from?: string; id?: string; type?: string; text?: { body?: string }; button?: { text?: string }; interactive?: { button_reply?: { id?: string }; list_reply?: { id?: string } }; }; export type PayloadWebhook = { entry?: { changes?: { value?: { messages?: MensajeMeta[] } }[] }[]; }; /** Aplana el payload del webhook a la lista de mensajes entrantes (ignora `statuses`). */ export function mensajesDelPayload(body: PayloadWebhook): MensajeMeta[] { const salida: MensajeMeta[] = []; for (const entry of body?.entry ?? []) for (const change of entry?.changes ?? []) salida.push(...(change?.value?.messages ?? [])); return salida; } /** Saca el texto de un mensaje según su tipo. `null` = tipo que el bot no maneja. */ export function textoDeMensaje(mensaje: MensajeMeta): string | null { if (mensaje.type === "text") return mensaje.text?.body ?? ""; if (mensaje.type === "interactive") { const i = mensaje.interactive ?? {}; // El id del botón/opción es el que puso el bot en las opciones → matchea directo. return i.button_reply?.id ?? i.list_reply?.id ?? null; } if (mensaje.type === "button") return mensaje.button?.text ?? null; return null; }