feat(whatsapp): webhook + envio por Graph API (F0)

Ultima fase del bot de reservas: conecta la maquina de conversacion ya
existente (whatsapp-bot.ts) con la WhatsApp Business Cloud API.

- src/lib/whatsapp.ts: traduce RespuestaBot -> payloads de Meta (texto e
  interactive/button, con los limites de 3 botones / 20 chars / 4096 chars),
  aplana el payload del webhook y valida X-Hub-Signature-256 en tiempo
  constante.
- src/app/api/whatsapp/webhook/route.ts: GET de verificacion y POST de
  mensajes. Valida la firma sobre el cuerpo crudo, deduplica por message id
  (Meta reintenta) y siempre devuelve 200 para no entrar en loop de retries.
- scripts/test-whatsapp-envio.ts (npm run test:envio): 30 asserts sobre la
  capa Meta con fetch stubbeado, sin DB ni credenciales.
- docs/whatsapp.md con el alta en el panel de Meta y las 4 env vars.

Falta solo cargar las credenciales de Meta en el .env de produccion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jpbeldomenico
2026-08-04 19:00:20 -03:00
parent c1b4ad5ad2
commit 91f5fba49f
6 changed files with 457 additions and 1 deletions
+149
View File
@@ -0,0 +1,149 @@
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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;
}