import { NextRequest, NextResponse } from "next/server"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; 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, y sin deduplicar eso // crea el turno dos veces. Se resuelve insertando el id en la base: el índice único // hace la operación atómica, así que sirve incluso con varias instancias corriendo. const DIAS_RETENCION = 7; let ultimaLimpieza = 0; async function yaProcesado(id: string): Promise { try { await prisma.mensajeWhatsappProcesado.create({ data: { id } }); return false; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") return true; throw e; } } /** Borra los ids viejos como mucho una vez por día. Best-effort: si falla, no importa. */ async function limpiarProcesadosViejos(): Promise { const unDia = 24 * 60 * 60 * 1000; if (Date.now() - ultimaLimpieza < unDia) return; ultimaLimpieza = Date.now(); try { await prisma.mensajeWhatsappProcesado.deleteMany({ where: { createdAt: { lt: new Date(Date.now() - DIAS_RETENCION * unDia) } }, }); } catch (e) { console.error("[whatsapp] no se pudieron limpiar los mensajes procesados:", e); } } 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 { await limpiarProcesadosViejos(); for (const mensaje of mensajesDelPayload(body)) { const telefono = mensaje.from; const messageId = mensaje.id; if (!telefono || !messageId || (await 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 }); }