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:
@@ -4,3 +4,13 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
GMAIL_USER="tu-email@gmail.com"
|
GMAIL_USER="tu-email@gmail.com"
|
||||||
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
|
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
|
||||||
MAGIC_LINK_BASE_URL="http://localhost:3000"
|
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=""
|
||||||
|
|||||||
@@ -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
@@ -11,7 +11,8 @@
|
|||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "npx tsx prisma/seed.ts",
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"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": {
|
"prisma": {
|
||||||
"seed": "npx tsx prisma/seed.ts"
|
"seed": "npx tsx prisma/seed.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<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);
|
||||||
|
});
|
||||||
@@ -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