feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4

Merged
jpbeldomenico merged 6 commits from feat/whatsapp-bot into main 2026-08-05 12:24:55 -03:00
6 changed files with 457 additions and 1 deletions
Showing only changes of commit 15d6e8a2ee - Show all commits
+10
View File
@@ -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=""
+51
View File
@@ -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).
+2 -1
View File
@@ -11,7 +11,8 @@
"db:push": "prisma db push",
"db:seed": "npx tsx prisma/seed.ts",
"db:studio": "prisma studio",
Review

JSON does not support comments; '# added' will cause parsing errors.

    "db:studio": "prisma studio",

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "db:studio": "prisma studio", ``` #ai-review-inline
"test:bot": "tsx scripts/test-whatsapp-bot.ts"
"test:bot": "tsx scripts/test-whatsapp-bot.ts",
Review

JSON does not support comments; '# added' will cause parsing errors.

    "test:bot": "tsx scripts/test-whatsapp-bot.ts",

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "test:bot": "tsx scripts/test-whatsapp-bot.ts", ``` #ai-review-inline
"test:envio": "tsx scripts/test-whatsapp-envio.ts"
Review

JSON does not support comments; '# added' will cause parsing errors.

    "test:envio": "tsx scripts/test-whatsapp-envio.ts"

#ai-review-inline

JSON does not support comments; '# added' will cause parsing errors. ```suggestion "test:envio": "tsx scripts/test-whatsapp-envio.ts" ``` #ai-review-inline
},
"prisma": {
"seed": "npx tsx prisma/seed.ts"
+151
View File
@@ -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);
});
+94
View File
@@ -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;
Review

Module-level variable 'ultimaLimpieza' is not reliable in serverless/multi-instance environments; each instance has its own memory and cold starts reset it. Consider a cron job or DB-based cleanup instead.

#ai-review-inline

Module-level variable 'ultimaLimpieza' is not reliable in serverless/multi-instance environments; each instance has its own memory and cold starts reset it. Consider a cron job or DB-based cleanup instead. #ai-review-inline
procesados.add(id);
// Set mantiene orden de inserción: soltamos los más viejos.
Review

Function name 'yaProcesado' suggests it returns true if already processed, but it returns false on successful insert (new message). Consider renaming to 'registrarProcesado' or inverting logic for clarity.

#ai-review-inline

Function name 'yaProcesado' suggests it returns true if already processed, but it returns false on successful insert (new message). Consider renaming to 'registrarProcesado' or inverting logic for clarity. #ai-review-inline
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");
Review

Cleanup runs on every POST request adding latency; with the unreliable timer it may run too often or not at all. Move to a scheduled job (e.g., Vercel Cron) or a separate background worker.

#ai-review-inline

Cleanup runs on every POST request adding latency; with the unreliable timer it may run too often or not at all. Move to a scheduled job (e.g., Vercel Cron) or a separate background worker. #ai-review-inline
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;
Review

Header 'x-hub-signature-256' may be undefined; firmaValida should handle missing header explicitly or check here before calling.

if (!req.headers.get('x-hub-signature-256') || !firmaValida(cuerpoCrudo, req.headers.get('x-hub-signature-256'))) {

#ai-review-inline

Header 'x-hub-signature-256' may be undefined; firmaValida should handle missing header explicitly or check here before calling. ```suggestion if (!req.headers.get('x-hub-signature-256') || !firmaValida(cuerpoCrudo, req.headers.get('x-hub-signature-256'))) { ``` #ai-review-inline
if (!telefono || !messageId || yaProcesado(messageId)) continue;
void marcarLeido(messageId).catch(() => {});
const texto = textoDeMensaje(mensaje);
if (texto === null) {
await enviarRespuestas(telefono, [
Review

Type assertion without runtime validation; if Meta changes payload structure this will fail silently. Use a schema validator (e.g., zod) to parse and validate the webhook payload.

#ai-review-inline

Type assertion without runtime validation; if Meta changes payload structure this will fail silently. Use a schema validator (e.g., zod) to parse and validate the webhook payload. #ai-review-inline
{
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.
Review

Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability.

marcarLeido(messageId).catch((e) => console.error('[whatsapp] failed to mark read:', e));

#ai-review-inline

Fire-and-forget with empty catch hides potential issues marking messages as read; at least log the error for observability. ```suggestion marcarLeido(messageId).catch((e) => console.error('[whatsapp] failed to mark read:', e)); ``` #ai-review-inline
console.error("[whatsapp] error procesando webhook:", e);
}
return NextResponse.json({ ok: true });
}
+149
View File
@@ -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 {
Review

firmaValida returns false when WHATSAPP_APP_SECRET is missing, which may hide configuration errors in production.

const appSecret = process.env.WHATSAPP_APP_SECRET;
  if (!appSecret) {
    console.error('[whatsapp] WHATSAPP_APP_SECRET not set; webhook validation will fail');
    return false;
  }

#ai-review-inline

firmaValida returns false when WHATSAPP_APP_SECRET is missing, which may hide configuration errors in production. ```suggestion const appSecret = process.env.WHATSAPP_APP_SECRET; if (!appSecret) { console.error('[whatsapp] WHATSAPP_APP_SECRET not set; webhook validation will fail'); return false; } ``` #ai-review-inline
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}`,
Review

postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent.

async function postMensaje(payload: Record<string, unknown>): Promise<void> {
  const cred = credenciales();
  if (!cred) {
    const err = new Error('WhatsApp credentials not configured');
    console.error('[whatsapp]', err.message);
    throw err;
  }
  // ... rest unchanged

#ai-review-inline

postMensaje fails silently when credentials are missing; callers have no way to know the message wasn't sent. ```suggestion async function postMensaje(payload: Record<string, unknown>): Promise<void> { const cred = credenciales(); if (!cred) { const err = new Error('WhatsApp credentials not configured'); console.error('[whatsapp]', err.message); throw err; } // ... rest unchanged ``` #ai-review-inline
"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,
Review

Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents.

body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),

#ai-review-inline

Spread operator allows payload to overwrite messaging_product; use explicit object to avoid accidents. ```suggestion body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }), ``` #ai-review-inline
type: "text",
text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },
});
}
async function enviarBotones(
telefono: string,
Review

fetch has no timeout; a stalled request can hang indefinitely.

const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 10_000);
      try {
        const res = await fetch(url, { ...init, signal: controller.signal });
      } finally {
        clearTimeout(timeout);
      }

#ai-review-inline

fetch has no timeout; a stalled request can hang indefinitely. ```suggestion const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const res = await fetch(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timeout); } ``` #ai-review-inline
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). */
Review

preview_url hardcoded to false; URLs in text messages won't show previews. Make configurable if needed.

text: { body: texto.slice(0, MAX_TEXTO), preview_url: false },

#ai-review-inline

preview_url hardcoded to false; URLs in text messages won't show previews. Make configurable if needed. ```suggestion text: { body: texto.slice(0, MAX_TEXTO), preview_url: false }, ``` #ai-review-inline
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;
Review

enviarRespuestas ignores individual send failures; a failed message won't stop the loop but errors are lost.

export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> {
  for (const r of respuestas) {
    try {
      if (r.tipo === 'botones') await enviarBotones(telefono, r.texto, r.botones);
      else await enviarTexto(telefono, r.texto);
    } catch (e) {
      console.error('[whatsapp] failed to send response:', e);
      // decide: continue or break
    }
  }
}

#ai-review-inline

enviarRespuestas ignores individual send failures; a failed message won't stop the loop but errors are lost. ```suggestion export async function enviarRespuestas(telefono: string, respuestas: RespuestaBot[]): Promise<void> { for (const r of respuestas) { try { if (r.tipo === 'botones') await enviarBotones(telefono, r.texto, r.botones); else await enviarTexto(telefono, r.texto); } catch (e) { console.error('[whatsapp] failed to send response:', e); // decide: continue or break } } } ``` #ai-review-inline
}
/** 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.
Review

marcarLeido reuses postMensaje which is semantically for sending messages; consider a dedicated low-level POST helper.

async function postRaw(payload: Record<string, unknown>): Promise<void> {
  // same retry logic as postMensaje but without messaging_product injection
}

export async function marcarLeido(messageId: string): Promise<void> {
  await postRaw({ messaging_product: 'whatsapp', status: 'read', message_id: messageId });
}

#ai-review-inline

marcarLeido reuses postMensaje which is semantically for sending messages; consider a dedicated low-level POST helper. ```suggestion async function postRaw(payload: Record<string, unknown>): Promise<void> { // same retry logic as postMensaje but without messaging_product injection } export async function marcarLeido(messageId: string): Promise<void> { await postRaw({ messaging_product: 'whatsapp', status: 'read', message_id: messageId }); } ``` #ai-review-inline
return i.button_reply?.id ?? i.list_reply?.id ?? null;
}
if (mensaje.type === "button") return mensaje.button?.text ?? null;
return null;
}