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,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<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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user