15d6e8a2ee
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>
95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
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<string>();
|
|
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 });
|
|
}
|