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:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Test de la capa que habla con Meta (F0) SIN base de datos ni credenciales reales.
|
||||
*
|
||||
* Cubre las dos direcciones de `src/lib/whatsapp.ts`:
|
||||
* - salida: RespuestaBot → payload de la Graph API (con `fetch` stubbeado)
|
||||
* - entrada: payload del webhook → mensajes/texto
|
||||
* - firma X-Hub-Signature-256
|
||||
*
|
||||
* Correr con: npx tsx scripts/test-whatsapp-envio.ts
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
process.env.WHATSAPP_TOKEN = "token-de-prueba";
|
||||
process.env.WHATSAPP_PHONE_NUMBER_ID = "111222333";
|
||||
process.env.WHATSAPP_APP_SECRET = "secreto-de-prueba";
|
||||
|
||||
import {
|
||||
enviarRespuestas,
|
||||
firmaValida,
|
||||
mensajesDelPayload,
|
||||
textoDeMensaje,
|
||||
whatsappConfigurado,
|
||||
} from "../src/lib/whatsapp";
|
||||
|
||||
let fallos = 0;
|
||||
function ok(cond: boolean, msg: string) {
|
||||
console.log(` ${cond ? "✅" : "❌"} ${msg}`);
|
||||
if (!cond) fallos++;
|
||||
}
|
||||
|
||||
// ── Stub de fetch: guarda lo que se le manda a Meta en vez de salir a internet ──
|
||||
type Llamada = { url: string; auth: string; body: any };
|
||||
let llamadas: Llamada[] = [];
|
||||
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
llamadas.push({
|
||||
url: String(url),
|
||||
auth: headers["Authorization"] ?? "",
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return { ok: true, status: 200, text: async () => "" } as Response;
|
||||
}) as typeof fetch;
|
||||
|
||||
async function main() {
|
||||
console.log("\n── Config ──");
|
||||
ok(whatsappConfigurado(), "detecta las credenciales presentes");
|
||||
|
||||
console.log("\n── Salida: texto ──");
|
||||
llamadas = [];
|
||||
await enviarRespuestas("5493511234567", [{ tipo: "texto", texto: "Hola *mundo*" }]);
|
||||
ok(llamadas.length === 1, "manda un solo request");
|
||||
ok(llamadas[0].url.includes("/111222333/messages"), "pega al phone_number_id configurado");
|
||||
ok(llamadas[0].auth === "Bearer token-de-prueba", "manda el bearer token");
|
||||
ok(llamadas[0].body.messaging_product === "whatsapp", "incluye messaging_product");
|
||||
ok(llamadas[0].body.type === "text", "type=text");
|
||||
ok(llamadas[0].body.to === "5493511234567", "va al teléfono correcto");
|
||||
ok(llamadas[0].body.text.body === "Hola *mundo*", "respeta el markup de WhatsApp");
|
||||
|
||||
console.log("\n── Salida: botones ──");
|
||||
llamadas = [];
|
||||
await enviarRespuestas("549351", [
|
||||
{
|
||||
tipo: "botones",
|
||||
texto: "¿Con qué mecánico?",
|
||||
botones: [
|
||||
{ id: "j1", titulo: "Carlos" },
|
||||
{ id: "j2", titulo: "Un nombre larguísimo que no entra" },
|
||||
{ id: "j3", titulo: "Ana" },
|
||||
{ id: "j4", titulo: "Cuarto" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const inter = llamadas[0].body.interactive;
|
||||
ok(llamadas[0].body.type === "interactive", "type=interactive");
|
||||
ok(inter.type === "button", "interactive.type=button");
|
||||
ok(inter.body.text === "¿Con qué mecánico?", "el texto va en body.text");
|
||||
ok(inter.action.buttons.length === 3, "recorta a los 3 botones que permite Meta");
|
||||
ok(inter.action.buttons[1].reply.title.length <= 20, "trunca títulos a 20 caracteres");
|
||||
ok(inter.action.buttons[0].reply.id === "j1", "conserva el id de la opción");
|
||||
ok(inter.action.buttons[0].type === "reply", "los botones son de tipo reply");
|
||||
|
||||
console.log("\n── Salida: casos borde ──");
|
||||
llamadas = [];
|
||||
await enviarRespuestas("549351", [{ tipo: "botones", texto: "sin opciones", botones: [] }]);
|
||||
ok(llamadas[0].body.type === "text", "sin botones cae a mensaje de texto");
|
||||
|
||||
llamadas = [];
|
||||
await enviarRespuestas("549351", [{ tipo: "texto", texto: "x".repeat(5000) }]);
|
||||
ok(llamadas[0].body.text.body.length === 4096, "trunca el texto al máximo de Meta");
|
||||
|
||||
llamadas = [];
|
||||
await enviarRespuestas("549351", [
|
||||
{ tipo: "texto", texto: "uno" },
|
||||
{ tipo: "texto", texto: "dos" },
|
||||
]);
|
||||
ok(llamadas.length === 2, "manda las respuestas múltiples en orden");
|
||||
ok(llamadas[0].body.text.body === "uno" && llamadas[1].body.text.body === "dos", "respeta el orden");
|
||||
|
||||
console.log("\n── Firma del webhook ──");
|
||||
const cuerpo = JSON.stringify({ hola: "chau", acento: "ñandú" });
|
||||
const firma = "sha256=" + crypto.createHmac("sha256", "secreto-de-prueba").update(cuerpo, "utf8").digest("hex");
|
||||
ok(firmaValida(cuerpo, firma), "acepta la firma correcta");
|
||||
ok(!firmaValida(cuerpo + " ", firma), "rechaza si el cuerpo fue alterado");
|
||||
ok(!firmaValida(cuerpo, "sha256=" + "0".repeat(64)), "rechaza una firma inválida");
|
||||
ok(!firmaValida(cuerpo, null), "rechaza si no viene el header");
|
||||
ok(!firmaValida(cuerpo, firma.slice("sha256=".length)), "rechaza sin el prefijo sha256=");
|
||||
|
||||
console.log("\n── Entrada: payload del webhook ──");
|
||||
const payload = {
|
||||
entry: [
|
||||
{
|
||||
changes: [
|
||||
{
|
||||
value: {
|
||||
messages: [
|
||||
{ from: "549351", id: "wamid.1", type: "text", text: { body: "hola" } },
|
||||
{
|
||||
from: "549351",
|
||||
id: "wamid.2",
|
||||
type: "interactive",
|
||||
interactive: { button_reply: { id: "jefe-abc", title: "Carlos" } },
|
||||
},
|
||||
{ from: "549351", id: "wamid.3", type: "audio", audio: { id: "x" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const msgs = mensajesDelPayload(payload);
|
||||
ok(msgs.length === 3, "aplana los 3 mensajes del payload");
|
||||
ok(textoDeMensaje(msgs[0]) === "hola", "extrae el texto plano");
|
||||
ok(textoDeMensaje(msgs[1]) === "jefe-abc", "de un botón extrae el id (lo que espera el bot)");
|
||||
ok(textoDeMensaje(msgs[2]) === null, "un audio devuelve null → el bot no lo procesa");
|
||||
|
||||
ok(mensajesDelPayload({}).length === 0, "payload vacío no rompe");
|
||||
ok(
|
||||
mensajesDelPayload({ entry: [{ changes: [{ value: {} }] }] }).length === 0,
|
||||
"un evento de status (sin messages) no genera mensajes"
|
||||
);
|
||||
|
||||
console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`);
|
||||
process.exit(fallos === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user