diff --git a/.env.example b/.env.example index 5da1ef6..325c07b 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,13 @@ NEXTAUTH_URL="http://localhost:3000" GMAIL_USER="tu-email@gmail.com" GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx" MAGIC_LINK_BASE_URL="http://localhost:3000" + +# Bot de reservas por WhatsApp (Meta Cloud API). +# PHONE_NUMBER_ID: panel de Meta -> WhatsApp -> API Setup (es un id numérico, NO el teléfono). +# TOKEN: token permanente de un System User con whatsapp_business_messaging + _management. +# VERIFY_TOKEN: string inventado por vos; el mismo va acá y en el panel al dar de alta el webhook. +# APP_SECRET: App -> Settings -> Basic -> App Secret. Sin esto el webhook rechaza todo. +WHATSAPP_PHONE_NUMBER_ID="" +WHATSAPP_TOKEN="" +WHATSAPP_VERIFY_TOKEN="" +WHATSAPP_APP_SECRET="" diff --git a/docs/whatsapp.md b/docs/whatsapp.md new file mode 100644 index 0000000..c6cb0fe --- /dev/null +++ b/docs/whatsapp.md @@ -0,0 +1,51 @@ +# Bot de reservas por WhatsApp + +Flujo: el cliente escribe al número del taller → el bot lo identifica por teléfono (verifica +con el email registrado, o lo da de alta) → elige mecánico, trabajo y horario → confirma y se +crea el Turno. Todo pasa dentro de la ventana de 24hs que abre el cliente, así que **no hacen +falta plantillas aprobadas** ni tiene costo. + +## Piezas + +| Archivo | Qué hace | +|---|---| +| `src/lib/whatsapp-bot.ts` | Máquina de conversación. No sabe nada de Meta: devuelve `RespuestaBot[]` | +| `src/lib/whatsapp.ts` | Traduce a/desde la Graph API de Meta y valida la firma del webhook | +| `src/app/api/whatsapp/webhook/route.ts` | Endpoint: verificación (GET) y mensajes entrantes (POST) | + +Tests sin DB ni credenciales: `npm run test:bot` (conversación) y `npm run test:envio` (capa Meta). + +## Variables de entorno + +``` +WHATSAPP_PHONE_NUMBER_ID= # id numérico del número, NO el teléfono +WHATSAPP_TOKEN= # token permanente de System User +WHATSAPP_VERIFY_TOKEN= # string inventado, el mismo acá y en el panel de Meta +WHATSAPP_APP_SECRET= # App -> Settings -> Basic -> App Secret +``` + +Sin `WHATSAPP_APP_SECRET` el webhook rechaza todo con 403 (es a propósito: sin firma no se +puede confiar en el payload). + +## Alta en Meta (una sola vez) + +1. Meta Business Suite con la cuenta de Facebook del taller. +2. developers.facebook.com → crear App tipo **Business** → agregar producto **WhatsApp**. +3. **WhatsApp → API Setup**: de ahí salen `PHONE_NUMBER_ID` y un token temporal (24hs). + Para el token definitivo: Business Settings → System Users → crear usuario → Generate Token + con permisos `whatsapp_business_messaging` y `whatsapp_business_management`. +4. **App → Settings → Basic → App Secret** → `WHATSAPP_APP_SECRET`. +5. Cargar las 4 variables en el `.env` de producción y redeployar. +6. **WhatsApp → Configuration → Webhook**: + - Callback URL: `https://taller.beldo.duckdns.org/api/whatsapp/webhook` + - Verify token: el mismo `WHATSAPP_VERIFY_TOKEN` + - Suscribir el campo **`messages`** (sin esto no llega nada). +7. Probar con el número de prueba que da Meta gratis, antes de migrar el número real. + +Notas: +- El número del taller no puede estar activo en la app de WhatsApp normal/Business: hay que + borrar esa cuenta antes de migrarlo a Cloud API. +- Business Verification se saltea mientras estemos abajo de 1000 usuarios únicos/24hs; solo + hace falta la aprobación del **Display Name**. +- Meta permite 3 botones como máximo. Si hay más de 3 mecánicos activos, salen los 3 primeros + como botón y el resto se elige respondiendo con el número (el bot acepta las dos formas). diff --git a/package.json b/package.json index aaf82b3..6339fb5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "db:push": "prisma db push", "db:seed": "npx tsx prisma/seed.ts", "db:studio": "prisma studio", - "test:bot": "tsx scripts/test-whatsapp-bot.ts" + "test:bot": "tsx scripts/test-whatsapp-bot.ts", + "test:envio": "tsx scripts/test-whatsapp-envio.ts" }, "prisma": { "seed": "npx tsx prisma/seed.ts" diff --git a/scripts/test-whatsapp-envio.ts b/scripts/test-whatsapp-envio.ts new file mode 100644 index 0000000..3ef6ccd --- /dev/null +++ b/scripts/test-whatsapp-envio.ts @@ -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; + 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); +}); diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts new file mode 100644 index 0000000..230a658 --- /dev/null +++ b/src/app/api/whatsapp/webhook/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { procesarMensaje } from "@/lib/whatsapp-bot"; +import { + enviarRespuestas, + firmaValida, + marcarLeido, + mensajesDelPayload, + textoDeMensaje, + type PayloadWebhook, +} from "@/lib/whatsapp"; + +export const dynamic = "force-dynamic"; + +/** + * Webhook de WhatsApp Business Cloud API. + * GET → verificación del webhook al configurarlo en el panel de Meta. + * POST → mensajes entrantes: se los pasa a la máquina de conversación y se contesta. + */ + +// Meta reintenta el mismo mensaje si tardamos o si algo falla. Guardamos los ids ya +// procesados en memoria (un solo container) para no duplicar turnos ni respuestas. +const procesados = new Set(); +const MAX_PROCESADOS = 500; + +function yaProcesado(id: string): boolean { + if (procesados.has(id)) return true; + procesados.add(id); + // Set mantiene orden de inserción: soltamos los más viejos. + while (procesados.size > MAX_PROCESADOS) { + const viejo: string | undefined = procesados.values().next().value; + if (viejo === undefined) break; + procesados.delete(viejo); + } + return false; +} + +export async function GET(req: NextRequest) { + const params = req.nextUrl.searchParams; + const modo = params.get("hub.mode"); + const token = params.get("hub.verify_token"); + const challenge = params.get("hub.challenge"); + + const esperado = process.env.WHATSAPP_VERIFY_TOKEN; + if (modo === "subscribe" && esperado && token === esperado && challenge) + return new NextResponse(challenge, { status: 200, headers: { "Content-Type": "text/plain" } }); + + return new NextResponse("Forbidden", { status: 403 }); +} + +export async function POST(req: NextRequest) { + // La firma se calcula sobre el cuerpo CRUDO: leerlo como texto y recién ahí parsear. + const cuerpoCrudo = await req.text(); + + if (!firmaValida(cuerpoCrudo, req.headers.get("x-hub-signature-256"))) { + console.warn("[whatsapp] webhook con firma inválida — descartado"); + return new NextResponse("Forbidden", { status: 403 }); + } + + let body: PayloadWebhook; + try { + body = JSON.parse(cuerpoCrudo) as PayloadWebhook; + } catch { + return NextResponse.json({ ok: true }); + } + + try { + for (const mensaje of mensajesDelPayload(body)) { + const telefono = mensaje.from; + const messageId = mensaje.id; + if (!telefono || !messageId || yaProcesado(messageId)) continue; + + void marcarLeido(messageId).catch(() => {}); + + const texto = textoDeMensaje(mensaje); + if (texto === null) { + await enviarRespuestas(telefono, [ + { + tipo: "texto", + texto: "Por ahora solo entiendo mensajes de texto. Escribime qué necesitás. 🙂", + }, + ]); + continue; + } + + const respuestas = await procesarMensaje(telefono, texto); + await enviarRespuestas(telefono, respuestas); + } + } catch (e) { + // Nunca devolvemos error a Meta: reintentaría el mismo mensaje en loop. + console.error("[whatsapp] error procesando webhook:", e); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/lib/whatsapp.ts b/src/lib/whatsapp.ts new file mode 100644 index 0000000..ce8d715 --- /dev/null +++ b/src/lib/whatsapp.ts @@ -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): 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; +}