From 38d38cf992e385f179990e93eea0f91107f008a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 18:52:49 +0000 Subject: [PATCH] fix(whatsapp): el panel no marca como enviado lo que Meta rechaza MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La respuesta del panel no revisaba la ventana de 24 h y WhatsApp se tragaba el 131047, así que el mensaje quedaba como del dueño aunque no saliera. Ahora esa ruta reutiliza la ventana de segments, manda en modo strict y guarda el wamid. Los webhooks de estado se persisten y el hilo muestra enviado, entregado, visto o el fallo. Co-authored-by: santmun --- src/admin/routes.ts | 32 +++++- src/admin/views/conversations.ts | 127 ++++++++++++++++++++-- src/channels/shared.ts | 14 ++- src/channels/whatsapp.ts | 143 ++++++++++++++++++++++--- src/db/deliveries.ts | 175 +++++++++++++++++++++++++++++++ src/db/schema.sql | 16 +++ src/index.ts | 17 ++- src/segments.ts | 56 +++++++++- test/admin/inbox.test.ts | 157 +++++++++++++++++++++++++++ test/channels/whatsapp.test.ts | 164 ++++++++++++++++++++++++++++- test/db/deliveries.test.ts | 90 ++++++++++++++++ test/segments-window.test.ts | 36 +++++++ 12 files changed, 997 insertions(+), 30 deletions(-) create mode 100644 src/db/deliveries.ts create mode 100644 test/db/deliveries.test.ts create mode 100644 test/segments-window.test.ts diff --git a/src/admin/routes.ts b/src/admin/routes.ts index 05219fb4..5f7d2ad9 100644 --- a/src/admin/routes.ts +++ b/src/admin/routes.ts @@ -32,6 +32,8 @@ import { renderSuggestionBox, } from "./views/conversations"; import { pickAdapter } from "../replies/sender"; +import { windowFor } from "../segments"; +import { linkOutboundWamid } from "../db/deliveries"; import { channelLabel } from "../channels/labels"; import type { ChannelId } from "../channels/shared"; import { renderInsights } from "./views/insights"; @@ -258,7 +260,7 @@ adminApp.get("/conversations/list-fragment", async (c) => ); adminApp.get("/conversations/thread/:id", async (c) => - c.html(await renderThreadLive(c.env, c.req.param("id"))), + c.html(await renderThreadLive(c.env, c.req.param("id"), { oobComposer: true })), ); // Old detail URLs (linked from Insights, notifications, etc.) → inbox selection. @@ -573,17 +575,34 @@ adminApp.post("/conversations/:id/reply", async (c) => { const conv = await convs.getById(id); if (!conv) return c.html(`✗ Conversación no encontrada.`); + // WhatsApp Cloud rechaza el texto libre pasadas 24 h del último mensaje del + // cliente (131047). La misma ventana que windowFor. Sin esta revisión el + // panel guardaba el mensaje como enviado aunque Meta lo hubiera rechazado. + if (conv.channel === "whatsapp") { + const win = await windowFor(db, id); + if (!win.open) { + return c.html( + `✗ No se envió: la ventana de 24 h está cerrada. Este cliente no escribe hace más de 24 horas y WhatsApp no entrega el mensaje.`, + ); + } + } + + let providerMessageId: string | undefined; try { const adapter = pickAdapter(conv.channel as ChannelId); - await adapter.sendReply( + const sent = await adapter.sendReply( { channel: conv.channel as ChannelId, channelUserId: conv.channel_user_id, chunks: [text], interChunkDelayMs: 0, + strict: true, }, c.env, ); + if (sent && typeof sent === "object" && sent.providerMessageId) { + providerMessageId = sent.providerMessageId; + } } catch (e) { // Nothing persisted on failure: the customer never got the message. const msg = e instanceof Error ? e.message : String(e); @@ -591,7 +610,14 @@ adminApp.post("/conversations/:id/reply", async (c) => { } const msgs = new MessagesRepo(db); - await msgs.append(id, "owner", text); + const messageId = await msgs.append(id, "owner", text); + if (conv.channel === "whatsapp" && providerMessageId) { + try { + await linkOutboundWamid(db, { wamid: providerMessageId, messageId, conversationId: id }); + } catch (err) { + console.error("link wamid:", err instanceof Error ? err.message : err); + } + } await convs.touchLastMessage(id); await convs.setPausedUntil(id, Date.now() + TAKEOVER_MS); diff --git a/src/admin/views/conversations.ts b/src/admin/views/conversations.ts index ead74d5d..08227da5 100644 --- a/src/admin/views/conversations.ts +++ b/src/admin/views/conversations.ts @@ -19,6 +19,8 @@ import { costOfUsage, type ModelId } from "../../pricing"; import { channelLabel } from "../../channels/labels"; import { layout } from "./layout"; import { fmtDateTime } from "../format"; +import { WINDOW_MS, serviceWindowFrom, windowFor, windowHoursLabel, type ServiceWindow } from "../../segments"; +import { deliveriesForConversation, deliveryCaption } from "../../db/deliveries"; /** Tiempo relativo corto en español (ej. "hace 5 min", "hace 2 h", "hace 3 d"). */ function ago(ms: number | null | undefined): string { @@ -152,6 +154,23 @@ export async function renderInboxList(env: Env, p: InboxParams): Promise conds.push( "EXISTS (SELECT 1 FROM conversation_insights i WHERE i.conversation_id = c.id AND i.sentiment = 'positive')", ); + } else if (p.filter === "ventana") { + // WhatsApp dentro de las 24 h desde el último mensaje del cliente. + conds.push( + `c.channel = 'whatsapp' AND EXISTS ( + SELECT 1 FROM messages m + WHERE m.conversation_id = c.id AND m.role = 'user' AND m.created_at > ? + )`, + ); + params.push(now - WINDOW_MS); + } else if (p.filter === "cerrada") { + conds.push( + `c.channel = 'whatsapp' AND NOT EXISTS ( + SELECT 1 FROM messages m + WHERE m.conversation_id = c.id AND m.role = 'user' AND m.created_at > ? + )`, + ); + params.push(now - WINDOW_MS); } const whereSql = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; @@ -161,7 +180,8 @@ export async function renderInboxList(env: Env, p: InboxParams): Promise (SELECT COUNT(*) FROM leads l WHERE l.conversation_id = c.id) as lead_count, (SELECT status FROM leads l WHERE l.conversation_id = c.id ORDER BY created_at DESC LIMIT 1) as lead_status, (SELECT COUNT(*) FROM tickets t WHERE t.conversation_id = c.id AND t.status != 'resolved') as open_tickets, - (SELECT sentiment FROM conversation_insights i WHERE i.conversation_id = c.id) as ai_sentiment + (SELECT sentiment FROM conversation_insights i WHERE i.conversation_id = c.id) as ai_sentiment, + (SELECT MAX(created_at) FROM messages WHERE conversation_id = c.id AND role = 'user') as last_user_at FROM conversations c ${whereSql} ORDER BY c.last_message_at DESC LIMIT 50`, @@ -182,6 +202,13 @@ export async function renderInboxList(env: Env, p: InboxParams): Promise const s = SENTIMENT_BADGE[r.ai_sentiment as string]; badges.push(`${s.txt}`); } + if (r.channel === "whatsapp") { + const label = windowHoursLabel(r.last_user_at, now); + const open = serviceWindowFrom(r.last_user_at, now).open; + badges.push( + `${escapeHtml(label)}`, + ); + } const selected = r.id === p.selectedId; const name = escapeHtml(r.display_name ?? r.channel_user_id ?? "—"); const preview = escapeHtml((r.last_msg ?? "").replace(/\s+/g, " ").slice(0, 60)); @@ -249,7 +276,11 @@ function bloqueContacto(canal: string | null | undefined, idCanal: string): stri `; } -export async function renderThreadLive(env: Env, convId: string): Promise { +export async function renderThreadLive( + env: Env, + convId: string, + opts?: { oobComposer?: boolean }, +): Promise { const db = new Db(env.DB); const conv = await db.first("SELECT * FROM conversations WHERE id = ?", [convId]); if (!conv) return `
Conversación no encontrada.
`; @@ -259,8 +290,10 @@ export async function renderThreadLive(env: Env, convId: string): Promise now; const openTicket = (await db.first<{ n: number }>( @@ -319,6 +352,7 @@ export async function renderThreadLive(env: Env, convId: string): Promise 0 ? `🔔 ticket abierto` : ""} + ${waWindow ? windowPill(waWindow) : ""} ${controls} `; @@ -354,9 +388,14 @@ export async function renderThreadLive(env: Env, convId: string): Promise${escapeHtml(caption.text)}` + : ""; const bubbleBg = isOwner ? "background:rgba(245,166,35,.1);border:1px solid rgba(245,166,35,.4)" : "background:var(--accent-soft);border:1px solid var(--linelit)"; @@ -364,29 +403,68 @@ export async function renderThreadLive(env: Env, convId: string): Promise ${chips}
${escapeHtml(m.content)}
- ${meta} + ${meta}${receiptHtml} `; }) .join(""); + // El hilo se refresca cada 5 s. El cuadro de respuesta no: si lo reemplazamos + // se borra lo que la persona está escribiendo. Solo empujamos el aviso de + // ventana (y el candado que tapa el cuadro cuando ya cerró). + const oob = opts?.oobComposer && waWindow ? composerOob(waWindow, convId) : ""; + return ` ${header}
${bubbles || `
Sin mensajes.
`} +
${oob}`; +} + +function windowPill(win: ServiceWindow): string { + if (!win.open || win.closesAt == null) { + return `🔒 Ventana cerrada`; + } + const until = fmtDateTime(win.closesAt, { hour: "2-digit", minute: "2-digit" }); + return `✍ hasta ${escapeHtml(until)}`; +} + +function windowNote(win: ServiceWindow): string { + if (!win.open || win.closesAt == null) return ""; + const until = fmtDateTime(win.closesAt, { hour: "2-digit", minute: "2-digit" }); + return `Puedes escribirle hasta ${escapeHtml(until)}`; +} + +function windowLock(): string { + return `
+
🔒 Ventana cerrada
+

WhatsApp no acepta un mensaje libre: este cliente no escribe hace más de 24 horas. Si lo mandas, no le llega.

`; } +/** + * El refresco del hilo cambia el cuadro sin borrar lo que se está escribiendo: + * el textarea y el aviso de envío llevan hx-preserve. Si la ventana cerró, el + * cuadro entero pasa a ser el candado. + */ +function composerOob(win: ServiceWindow, convId: string): string { + return `
${composerBody(win, convId)}
`; +} + // --- Composer (static per selection — NOT inside the polled fragment) --------- -function renderComposer(convId: string): string { +function composerBody(win: ServiceWindow | null, convId: string): string { + if (win && !win.open) return windowLock(); const id = encodeURIComponent(convId); + const note = win + ? `
${windowNote(win)}
` + : ""; return ` -
-
+ ${note} +
-
-
+
`; +} + +const COMPOSER_SLOT_STYLE = + "border-top:1px solid var(--line);background:var(--panel);padding:12px;display:flex;flex-direction:column;gap:8px"; + +async function renderComposer(env: Env, convId: string): Promise { + const db = new Db(env.DB); + const conv = await db.first<{ channel: string }>("SELECT channel FROM conversations WHERE id = ?", [convId]); + const waWindow = conv?.channel === "whatsapp" ? await windowFor(db, convId) : null; + return ` +
+ ${composerBody(waWindow, convId)}
`; } @@ -438,6 +528,23 @@ export async function renderInbox(env: Env, p: InboxParams): Promise { OR EXISTS (SELECT 1 FROM tickets t WHERE t.conversation_id = c.id AND t.status != 'resolved')`, [now], ))?.n ?? 0; + const cutoff = now - WINDOW_MS; + const winCounts = await db.first<{ abiertas: number; cerradas: number }>( + `SELECT + COALESCE(SUM(CASE WHEN last_user_at > ? THEN 1 ELSE 0 END), 0) AS abiertas, + COALESCE(SUM(CASE WHEN last_user_at IS NULL OR last_user_at <= ? THEN 1 ELSE 0 END), 0) AS cerradas + FROM ( + SELECT ( + SELECT MAX(m.created_at) FROM messages m + WHERE m.conversation_id = c.id AND m.role = 'user' + ) AS last_user_at + FROM conversations c + WHERE c.channel = 'whatsapp' + ) AS wa`, + [cutoff, cutoff], + ); + const nAbiertas = Number(winCounts?.abiertas ?? 0); + const nCerradas = Number(winCounts?.cerradas ?? 0); const filterPill = (href: string, label: string, active: boolean, color: string) => ` ${thread}
- ${renderComposer(p.selectedId)}`; + ${await renderComposer(env, p.selectedId)}`; } else { rightPane = `
@@ -476,6 +583,8 @@ export async function renderInbox(env: Env, p: InboxParams): Promise { ${filterPill(inboxUrl({ filter: "atencion", selectedId: p.selectedId }), `🔔 Atención · ${needAttention}`, p.filter === "atencion", "var(--bad)")} ${filterPill(inboxUrl({ filter: "molestos", selectedId: p.selectedId }), `😠 Molestos · ${nMolestos}`, p.filter === "molestos", "var(--bad)")} ${filterPill(inboxUrl({ filter: "contentos", selectedId: p.selectedId }), `🙂 Contentos · ${nContentos}`, p.filter === "contentos", "var(--ok)")} + ${filterPill(inboxUrl({ filter: "ventana", selectedId: p.selectedId }), `Puedo escribirle · ${nAbiertas}`, p.filter === "ventana", "var(--ok)")} + ${filterPill(inboxUrl({ filter: "cerrada", selectedId: p.selectedId }), `Ventana cerrada · ${nCerradas}`, p.filter === "cerrada", "var(--bad)")}
${p.filter ? `` : ""} diff --git a/src/channels/shared.ts b/src/channels/shared.ts index 08665db1..1b74eaa1 100644 --- a/src/channels/shared.ts +++ b/src/channels/shared.ts @@ -17,10 +17,22 @@ export interface OutgoingReply { channelUserId: string; chunks: string[]; interChunkDelayMs?: number; + /** + * Si es true, un rechazo del proveedor lanza en vez de solo loguearse. + * El panel lo prende para no guardar como enviado un mensaje que no salió. + * El bot y las campañas no lo prenden: un fallo ahí no debe tumbar el turno. + */ + strict?: boolean; +} + +/** Lo que el canal devuelve cuando el proveedor acepta el envío. */ +export interface SendResult { + /** Id del proveedor. En WhatsApp Cloud es el wamid. */ + providerMessageId?: string; } export interface ChannelAdapter { parseIncoming(request: Request, env: any): Promise; - sendReply(reply: OutgoingReply, env: any): Promise; + sendReply(reply: OutgoingReply, env: any): Promise; showTyping?(channelUserId: string, env: any): Promise; } diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index 7bc7f8a9..cde62920 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -3,7 +3,8 @@ // Mismo ecosistema Graph que Meta (Messenger/IG), pero el formato del webhook y // del envío son DISTINTOS: // • Entrante: object "whatsapp_business_account" → entry[].changes[].value.messages[] -// (los recibos de entrega/lectura vienen en value.statuses[] y se ignoran). +// Los recibos (sent/delivered/read/failed) vienen en value.statuses[] y los +// devuelve parseWhatsAppStatuses — no son mensajes del cliente. // • Envío: POST graph.facebook.com//messages con el token del // system user/WABA y { messaging_product:"whatsapp", to, type:"text", text }. // @@ -11,7 +12,7 @@ // (Bearer) → url, y GET url (Bearer) → bytes. Para reusar transcribe/vision sin // tocarlas, lo servimos por un proxy FIRMADO (/webhooks/whatsapp/media/:id): la // URL es pública pero con HMAC + expiración, y el token queda del lado del server. -import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared"; +import type { ChannelAdapter, IncomingMessage, OutgoingReply, SendResult } from "./shared"; import type { Env } from "../env"; import { egressFetch } from "../http/egress"; @@ -28,6 +29,21 @@ interface WaMessage { audio?: { id?: string; voice?: boolean; mime_type?: string }; } +interface WaStatusError { + code?: number; + title?: string; + message?: string; + error_data?: { details?: string }; +} + +interface WaStatus { + id?: string; + status?: string; + timestamp?: string | number; + recipient_id?: string; + errors?: WaStatusError[]; +} + interface WaChange { field?: string; value?: { @@ -35,7 +51,7 @@ interface WaChange { metadata?: { phone_number_id?: string; display_phone_number?: string }; contacts?: { profile?: { name?: string }; wa_id?: string }[]; messages?: WaMessage[]; - statuses?: unknown[]; + statuses?: WaStatus[]; }; } @@ -80,9 +96,10 @@ async function signedMediaUrl(mediaId: string, env: Env, origin: string): Promis /** * Convierte un webhook de WhatsApp Cloud en 0..N mensajes entrantes. Un POST - * puede traer varias entradas y varios mensajes; los `statuses` (recibos) y los - * tipos no soportados (ubicación, sticker, etc.) se ignoran. `origin` es la base - * pública del worker (para firmar las URLs de media entrante). + * puede traer varias entradas y varios mensajes. Los `statuses` (recibos) no + * son mensajes: se leen con parseWhatsAppStatuses. Los tipos no soportados + * (ubicación, sticker, etc.) se ignoran. `origin` es la base pública del worker + * (para firmar las URLs de media entrante). */ export async function parseWhatsAppEvents( body: WaWebhookBody, @@ -94,7 +111,7 @@ export async function parseWhatsAppEvents( for (const change of entry.changes ?? []) { if (change.field && change.field !== "messages") continue; const value = change.value; - if (!value?.messages?.length) continue; // statuses-only u otros → ignora + if (!value?.messages?.length) continue; // solo recibos, o sin mensajes del cliente const nameByWaId = new Map(); for (const c of value.contacts ?? []) { if (c.wa_id && c.profile?.name) nameByWaId.set(c.wa_id, c.profile.name); @@ -136,6 +153,100 @@ export async function parseWhatsAppEvents( return out; } +export interface WhatsAppStatusEvent { + wamid: string; + status: string; + /** Unix en milisegundos. */ + timestamp: number; + recipientId?: string; + errorCode?: number; + errorTitle?: string; +} + +function unixToMs(ts: string | number | undefined, fallback: number): number { + const n = Number(ts); + if (!Number.isFinite(n) || n <= 0) return fallback; + return n < 1e12 ? n * 1000 : n; +} + +/** + * Recibos de entrega de un webhook de WhatsApp Cloud. + * Meta manda sent, delivered, read y failed (con errors[].code, p.ej. 131047) + * en value.statuses[], a veces solos y a veces junto a messages[]. + */ +export function parseWhatsAppStatuses(body: WaWebhookBody, now = Date.now()): WhatsAppStatusEvent[] { + const out: WhatsAppStatusEvent[] = []; + for (const entry of body.entry ?? []) { + for (const change of entry.changes ?? []) { + if (change.field && change.field !== "messages") continue; + const statuses = change.value?.statuses; + if (!Array.isArray(statuses)) continue; + for (const s of statuses) { + const wamid = s?.id ? String(s.id) : ""; + const status = s?.status ? String(s.status).toLowerCase() : ""; + if (!wamid || !status) continue; + const err = Array.isArray(s.errors) ? s.errors[0] : undefined; + out.push({ + wamid, + status, + timestamp: unixToMs(s.timestamp, now), + recipientId: s.recipient_id ? String(s.recipient_id) : undefined, + errorCode: typeof err?.code === "number" ? err.code : undefined, + errorTitle: err?.title || err?.message || undefined, + }); + } + } + } + return out; +} + +interface GraphErrorBody { + error?: { code?: number; message?: string; error_data?: { details?: string } }; +} + +interface GraphSendBody { + messages?: { id?: string }[]; +} + +/** Error de envío de WhatsApp Cloud. `code` es el de Meta (131047 = ventana cerrada). */ +export class WhatsAppSendError extends Error { + readonly httpStatus: number; + readonly code?: number; + constructor(message: string, httpStatus: number, code?: number) { + super(message); + this.name = "WhatsAppSendError"; + this.httpStatus = httpStatus; + this.code = code; + } +} + +function describeWaFailure(httpStatus: number, raw: string): { message: string; code?: number } { + let code: number | undefined; + let details = raw.slice(0, 300); + try { + const parsed = JSON.parse(raw) as GraphErrorBody; + code = parsed.error?.code; + details = parsed.error?.error_data?.details || parsed.error?.message || details; + } catch { + /* el cuerpo no es JSON */ + } + if (code === 131047) { + return { code, message: "Fuera de la ventana de 24 h: WhatsApp rechazó el mensaje (131047)." }; + } + const suffix = details ? `: ${details}` : ""; + return { code, message: `WhatsApp rechazó el mensaje${code ? ` (${code})` : ""}${suffix}` }; +} + +function readWamid(raw: string): string | undefined { + try { + const parsed = JSON.parse(raw) as GraphSendBody; + const id = parsed.messages?.[0]?.id; + return id ? String(id) : undefined; + } catch { + return undefined; + } +} + /** * Sirve el media entrante de WhatsApp Cloud: valida la firma HMAC + expiración, * resuelve el media_id contra Graph (Bearer) y devuelve los bytes. Público pero @@ -183,13 +294,14 @@ export const whatsappAdapter: ChannelAdapter = { return first; }, - async sendReply(reply: OutgoingReply, env: Env): Promise { + async sendReply(reply: OutgoingReply, env: Env): Promise { const phoneId = env.WHATSAPP_PHONE_NUMBER_ID; const token = env.WHATSAPP_ACCESS_TOKEN; if (!phoneId || !token) { throw new Error("WhatsApp Cloud: falta WHATSAPP_PHONE_NUMBER_ID o WHATSAPP_ACCESS_TOKEN."); } const url = `https://graph.facebook.com/${GRAPH_VERSION}/${phoneId}/messages`; + let providerMessageId: string | undefined; for (let i = 0; i < reply.chunks.length; i++) { const delay = i === 0 ? 0 : reply.interChunkDelayMs ?? 1000; if (delay > 0) await new Promise((r) => setTimeout(r, delay)); @@ -204,12 +316,19 @@ export const whatsappAdapter: ChannelAdapter = { text: { preview_url: false, body: reply.chunks[i] }, }), }); - // Fuera de la ventana de 24h Meta rechaza texto libre (pide plantilla HSM): - // no lo tragues, logéalo con el cuerpo para ver el motivo exacto. + const raw = await res.text().catch(() => ""); + // Fuera de la ventana de 24h Meta rechaza texto libre (131047). + // Sin strict solo se loguea (el bot no debe tumbar el turno). Con strict + // —el panel— se lanza para no guardar el mensaje como enviado. if (!res.ok) { - const errBody = await res.text().catch(() => ""); - console.error(`whatsapp sendReply ${res.status}: ${errBody}`); + const failure = describeWaFailure(res.status, raw); + if (reply.strict) throw new WhatsAppSendError(failure.message, res.status, failure.code); + console.error(`whatsapp sendReply ${res.status}: ${raw}`); + continue; } + const id = readWamid(raw); + if (id && !providerMessageId) providerMessageId = id; } + return providerMessageId ? { providerMessageId } : {}; }, }; diff --git a/src/db/deliveries.ts b/src/db/deliveries.ts new file mode 100644 index 00000000..40c70432 --- /dev/null +++ b/src/db/deliveries.ts @@ -0,0 +1,175 @@ +import { Db } from "./client"; + +/** Código de Meta: texto libre fuera de la ventana de 24 h. */ +export const WA_WINDOW_CLOSED = 131047; + +const RANK: Record = { + sent: 1, + delivered: 2, + read: 3, + failed: 4, +}; + +export function deliveryRank(status: string): number { + return RANK[status] ?? 0; +} + +export interface DeliveryRow { + wamid: string; + message_id: string | null; + conversation_id: string | null; + status: string; + error_code: number | null; + error_title: string | null; + status_at: number; + updated_at: number; +} + +export interface DeliveryUpsert { + wamid: string; + messageId?: string | null; + conversationId?: string | null; + status: string; + errorCode?: number | null; + errorTitle?: string | null; + statusAt: number; +} + +/** + * Inserta o actualiza un recibo. Un estado más nuevo no pisa uno más avanzado + * (read no vuelve a sent). failed gana sobre el resto. El message_id que + * llega después (el envío guardó el wamid) no borra el estado que ya trajo el webhook. + */ +export async function upsertDelivery(db: Db, row: DeliveryUpsert): Promise { + const existing = await db.first( + "SELECT * FROM message_deliveries WHERE wamid = ?", + [row.wamid], + ); + const now = Date.now(); + if (!existing) { + await db.run( + `INSERT INTO message_deliveries ( + wamid, message_id, conversation_id, status, error_code, error_title, status_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.wamid, + row.messageId ?? null, + row.conversationId ?? null, + row.status, + row.status === "failed" ? (row.errorCode ?? null) : null, + row.status === "failed" ? (row.errorTitle ?? null) : null, + row.statusAt, + now, + ], + ); + return; + } + + const incoming = deliveryRank(row.status); + const current = deliveryRank(existing.status); + const nextStatus = incoming >= current ? row.status : existing.status; + const nextStatusAt = incoming >= current ? row.statusAt : existing.status_at; + let errorCode: number | null = null; + let errorTitle: string | null = null; + if (nextStatus === "failed") { + if (row.status === "failed") { + errorCode = row.errorCode ?? existing.error_code; + errorTitle = row.errorTitle ?? existing.error_title; + } else { + errorCode = existing.error_code; + errorTitle = existing.error_title; + } + } + + await db.run( + `UPDATE message_deliveries SET + message_id = COALESCE(message_id, ?), + conversation_id = COALESCE(conversation_id, ?), + status = ?, + error_code = ?, + error_title = ?, + status_at = ?, + updated_at = ? + WHERE wamid = ?`, + [ + row.messageId ?? null, + row.conversationId ?? null, + nextStatus, + errorCode, + errorTitle, + nextStatusAt, + now, + row.wamid, + ], + ); +} + +export interface WhatsAppStatusUpdate { + wamid: string; + status: string; + timestamp: number; + errorCode?: number; + errorTitle?: string; +} + +/** Persiste los recibos que mandó Meta. El match es por wamid. */ +export async function applyWhatsAppStatuses(db: Db, events: WhatsAppStatusUpdate[]): Promise { + for (const ev of events) { + await upsertDelivery(db, { + wamid: ev.wamid, + status: ev.status, + errorCode: ev.errorCode ?? null, + errorTitle: ev.errorTitle ?? null, + statusAt: ev.timestamp, + }); + } +} + +/** El panel acaba de enviar: deja el wamid ligado al mensaje, sin bajar un recibo que ya llegó. */ +export async function linkOutboundWamid( + db: Db, + input: { wamid: string; messageId: string; conversationId: string; statusAt?: number }, +): Promise { + await upsertDelivery(db, { + wamid: input.wamid, + messageId: input.messageId, + conversationId: input.conversationId, + status: "sent", + statusAt: input.statusAt ?? Date.now(), + }); +} + +/** El recibo más avanzado de cada mensaje de la conversación. */ +export async function deliveriesForConversation( + db: Db, + conversationId: string, +): Promise> { + const rows = await db.all( + "SELECT * FROM message_deliveries WHERE conversation_id = ?", + [conversationId], + ); + const map = new Map(); + for (const row of rows) { + if (!row.message_id) continue; + const prev = map.get(row.message_id); + if (!prev || deliveryRank(row.status) >= deliveryRank(prev.status)) map.set(row.message_id, row); + } + return map; +} + +export function deliveryCaption(row: { + status: string; + error_code: number | null; + error_title: string | null; +}): { text: string; color: string } { + if (row.status === "failed") { + if (row.error_code === WA_WINDOW_CLOSED) { + return { text: "No le llegó: fuera de la ventana de 24 h", color: "var(--bad)" }; + } + const extra = row.error_title ? `: ${row.error_title}` : ""; + return { text: `No le llegó${extra}`, color: "var(--bad)" }; + } + if (row.status === "read") return { text: "✓✓ Visto", color: "var(--info)" }; + if (row.status === "delivered") return { text: "✓✓ Entregado", color: "var(--ok)" }; + return { text: "✓ Enviado", color: "var(--dim)" }; +} diff --git a/src/db/schema.sql b/src/db/schema.sql index f50800aa..a8086305 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -203,3 +203,19 @@ CREATE TABLE IF NOT EXISTS template_sends ( UNIQUE (campaign_key, conversation_id) ); CREATE INDEX IF NOT EXISTS idx_template_sends_time ON template_sends(sent_at); + +-- Recibos de WhatsApp Cloud (sent / delivered / read / failed). +-- Se ligan al mensaje saliente por el wamid que Graph devuelve al enviar. +-- Un webhook que llega antes del insert deja message_id vacío y el envío lo completa. +CREATE TABLE IF NOT EXISTS message_deliveries ( + wamid TEXT PRIMARY KEY, + message_id TEXT, + conversation_id TEXT, + status TEXT NOT NULL, + error_code INTEGER, + error_title TEXT, + status_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_deliveries_message ON message_deliveries(message_id); +CREATE INDEX IF NOT EXISTS idx_deliveries_conv ON message_deliveries(conversation_id); diff --git a/src/index.ts b/src/index.ts index 45c61d68..44cde1fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,8 @@ import { telegramAdapter } from "./channels/telegram"; import { manychatAdapter } from "./channels/manychat"; import { twilioAdapter } from "./channels/twilio"; import { parseMetaEvents, verifyMetaSignature } from "./channels/meta"; -import { parseWhatsAppEvents, serveWhatsAppMedia } from "./channels/whatsapp"; +import { parseWhatsAppEvents, parseWhatsAppStatuses, serveWhatsAppMedia } from "./channels/whatsapp"; +import { applyWhatsAppStatuses } from "./db/deliveries"; import { adminApp } from "./admin/routes"; import { purgeOldMessages } from "./crons/purgeOldMessages"; import { DAILY_CRON, isNightlyTick } from "./crons/schedule"; @@ -165,9 +166,21 @@ app.post("/webhooks/whatsapp", async (c) => { return c.text("bad json", 400); } const origin = c.env.DASHBOARD_BASE_URL || new URL(c.req.url).origin; - for (const msg of await parseWhatsAppEvents(body as any, c.env, origin)) { + const waBody = body as any; + for (const msg of await parseWhatsAppEvents(waBody, c.env, origin)) { await getAgentStub(c.env, `${msg.channel}:${msg.channelUserId}`).ingest(msg); } + // Recibos (sent/delivered/read/failed). No son mensajes: se guardan por wamid. + // Si la tabla aún no existe, no tumbamos el webhook — Meta reintentaría y + // el mensaje del cliente se procesaría dos veces. + const statuses = parseWhatsAppStatuses(waBody); + if (statuses.length) { + try { + await applyWhatsAppStatuses(new Db(c.env.DB), statuses); + } catch (err) { + console.error("whatsapp statuses:", err instanceof Error ? err.message : err); + } + } return c.text("EVENT_RECEIVED", 200); }); diff --git a/src/segments.ts b/src/segments.ts index 9208e522..89452e61 100644 --- a/src/segments.ts +++ b/src/segments.ts @@ -129,10 +129,64 @@ export async function segmentMembers( ); return rows.map((r) => ({ ...r, - inWindow: now - r.lastUserAt < WINDOW_SAFE_MS, + // Las campañas usan el margen de 23 h (WINDOW_SAFE_MS), no las 24 h exactas. + inWindow: serviceWindowFrom(r.lastUserAt, now, WINDOW_SAFE_MS).open, })); } +export interface ServiceWindow { + /** true si todavía se puede mandar texto libre. */ + open: boolean; + /** Último mensaje del cliente, o null si nunca escribió. */ + lastUserAt: number | null; + /** Momento en que cierra la ventana (lastUserAt + windowMs). */ + closesAt: number | null; +} + +/** + * Ventana de servicio a partir del último mensaje del cliente. + * `windowMs` por defecto es las 24 h de Meta. Las campañas pasan + * WINDOW_SAFE_MS (23 h) para no rozar el cierre. + */ +export function serviceWindowFrom( + lastUserAt: number | null | undefined, + now = Date.now(), + windowMs = WINDOW_MS, +): ServiceWindow { + if (lastUserAt == null || !Number.isFinite(Number(lastUserAt))) { + return { open: false, lastUserAt: null, closesAt: null }; + } + const last = Number(lastUserAt); + const closesAt = last + windowMs; + return { open: now < closesAt, lastUserAt: last, closesAt }; +} + +/** Etiqueta corta para la lista: "✍ 5 h", "✍ 40 min" o "🔒". */ +export function windowHoursLabel(lastUserAt: number | null | undefined, now = Date.now()): string { + const win = serviceWindowFrom(lastUserAt, now); + if (!win.open || win.closesAt == null) return "🔒"; + const ms = win.closesAt - now; + if (ms < 3_600_000) { + const min = Math.max(1, Math.ceil(ms / 60_000)); + return `✍ ${min} min`; + } + return `✍ ${Math.floor(ms / 3_600_000)} h`; +} + +/** + * Ventana de 24 h de una conversación, medida desde su último mensaje + * con role=user. La misma regla que usa el inbox del panel. + */ +export async function windowFor(db: Db, conversationId: string, now = Date.now()): Promise { + const row = await db.first<{ lastUserAt: number | null }>( + `SELECT MAX(created_at) AS lastUserAt + FROM messages + WHERE conversation_id = ? AND role = 'user'`, + [conversationId], + ); + return serviceWindowFrom(row?.lastUserAt ?? null, now); +} + export interface SegmentCount { id: string; label: string; diff --git a/test/admin/inbox.test.ts b/test/admin/inbox.test.ts index a4313325..58dfc69d 100644 --- a/test/admin/inbox.test.ts +++ b/test/admin/inbox.test.ts @@ -125,6 +125,7 @@ describe("inbox — owner reply (takeover)", () => { const [payload] = sendReplyMock.mock.calls[0]; expect(payload.channelUserId).toBe("u3"); expect(payload.chunks).toEqual(["Hola, soy Ana 👋"]); + expect(payload.strict).toBe(true); // Persisted as owner + bot paused (takeover). const history = await msgs.lastN(conv.id, 10); @@ -165,6 +166,162 @@ describe("inbox — owner reply (takeover)", () => { }); }); +describe("inbox — ventana de 24 h en WhatsApp", () => { + it("no manda ni guarda el mensaje si la ventana está cerrada", async () => { + const conv = await convs.getOrCreate("whatsapp", "5215500002222", "Cerrada"); + await msgs.append(conv.id, "user", "hola hace días", { createdAt: Date.now() - 26 * 3_600_000 }); + + const res = await adminApp.request( + `/conversations/${encodeURIComponent(conv.id)}/reply`, + { method: "POST", headers: FORM, body: new URLSearchParams({ text: "seguimiento" }) }, + env, + ); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Sent")).toBeNull(); + expect(await res.text()).toContain("ventana de 24 h está cerrada"); + expect(sendReplyMock).not.toHaveBeenCalled(); + + const history = await msgs.lastN(conv.id, 10); + expect(history.every((m) => m.role !== "owner")).toBe(true); + expect(await convs.isPaused(conv.id)).toBe(false); + }); + + it("un rechazo del canal no queda guardado como enviado", async () => { + const conv = await convs.getOrCreate("whatsapp", "5215500003333", "Rechazo"); + await msgs.append(conv.id, "user", "acabo de escribir"); + sendReplyMock.mockRejectedValue(new Error("Fuera de la ventana de 24 h: WhatsApp rechazó el mensaje (131047).")); + + const res = await adminApp.request( + `/conversations/${encodeURIComponent(conv.id)}/reply`, + { method: "POST", headers: FORM, body: new URLSearchParams({ text: "no debe quedar" }) }, + env, + ); + + expect(await res.text()).toContain("No se pudo enviar"); + expect(res.headers.get("X-Sent")).toBeNull(); + const history = await msgs.lastN(conv.id, 10); + expect(history.every((m) => m.content !== "no debe quedar")).toBe(true); + const n = await db.first<{ n: number }>("SELECT COUNT(*) as n FROM message_deliveries"); + expect(n?.n).toBe(0); + }); + + it("con la ventana abierta manda en strict y guarda el wamid", async () => { + const conv = await convs.getOrCreate("whatsapp", "5215500004444", "Abierta"); + await msgs.append(conv.id, "user", "hola", { createdAt: Date.now() - 3_600_000 }); + sendReplyMock.mockResolvedValue({ providerMessageId: "wamid.panel.1" }); + + const res = await adminApp.request( + `/conversations/${encodeURIComponent(conv.id)}/reply`, + { method: "POST", headers: FORM, body: new URLSearchParams({ text: "te escribo yo" }) }, + env, + ); + + expect(res.headers.get("X-Sent")).toBe("1"); + const [payload] = sendReplyMock.mock.calls[0]; + expect(payload.strict).toBe(true); + expect(payload.channel).toBe("whatsapp"); + + const history = await msgs.lastN(conv.id, 10); + const owner = history[history.length - 1]; + expect(owner.role).toBe("owner"); + const row = await db.first<{ message_id: string; status: string; conversation_id: string }>( + "SELECT message_id, status, conversation_id FROM message_deliveries WHERE wamid = ?", + ["wamid.panel.1"], + ); + expect(row?.message_id).toBe(owner.id); + expect(row?.status).toBe("sent"); + expect(row?.conversation_id).toBe(conv.id); + expect(await res.text()).toContain("✓ Enviado"); + }); + + it("Telegram sigue pudiendo responder aunque el cliente no escriba hace días", async () => { + const conv = await convs.getOrCreate("telegram", "viejo", "Viejo"); + await msgs.append(conv.id, "user", "hola", { createdAt: Date.now() - 10 * 24 * 3_600_000 }); + + const res = await adminApp.request( + `/conversations/${encodeURIComponent(conv.id)}/reply`, + { method: "POST", headers: FORM, body: new URLSearchParams({ text: "sigo aquí" }) }, + env, + ); + + expect(res.headers.get("X-Sent")).toBe("1"); + expect(sendReplyMock).toHaveBeenCalledTimes(1); + const history = await msgs.lastN(conv.id, 5); + expect(history[history.length - 1].content).toBe("sigo aquí"); + }); + + it("el panel muestra el candado, las horas y el estado de entrega", async () => { + const closed = await convs.getOrCreate("whatsapp", "5215500005555", "Sin ventana"); + await msgs.append(closed.id, "user", "hace una semana", { createdAt: Date.now() - 7 * 24 * 3_600_000 }); + const ownerId = await msgs.append(closed.id, "owner", "esto no llegó"); + await db.run( + `INSERT INTO message_deliveries + (wamid, message_id, conversation_id, status, error_code, error_title, status_at, updated_at) + VALUES (?, ?, ?, 'failed', 131047, 'Re-engagement message', ?, ?)`, + ["wamid.closed", ownerId, closed.id, Date.now(), Date.now()], + ); + + const open = await convs.getOrCreate("whatsapp", "5215500006666", "Con ventana"); + // Un minuto de margen: si el render tarda, el piso de horas no baja de 19. + await msgs.append(open.id, "user", "hoy", { createdAt: Date.now() - 5 * 3_600_000 + 60_000 }); + const seenId = await msgs.append(open.id, "owner", "ya lo vi"); + await db.run( + `INSERT INTO message_deliveries + (wamid, message_id, conversation_id, status, error_code, error_title, status_at, updated_at) + VALUES (?, ?, ?, 'read', NULL, NULL, ?, ?)`, + ["wamid.seen", seenId, open.id, Date.now(), Date.now()], + ); + + const page = await adminApp.request("/conversations", { headers: AUTH }, env); + const html = await page.text(); + expect(html).toContain("Puedo escribirle · 1"); + expect(html).toContain("Ventana cerrada · 1"); + expect(html).toContain("✍ 19 h"); + expect(html).toContain("🔒"); + + const closedPage = await adminApp.request( + `/conversations?c=${encodeURIComponent(closed.id)}`, + { headers: AUTH }, + env, + ); + const closedHtml = await closedPage.text(); + expect(closedHtml).toContain("🔒 Ventana cerrada"); + expect(closedHtml).toContain("no acepta un mensaje libre"); + expect(closedHtml).toContain("No le llegó: fuera de la ventana de 24 h"); + expect(closedHtml).not.toContain("Responde como humano"); + + const openPage = await adminApp.request( + `/conversations?c=${encodeURIComponent(open.id)}`, + { headers: AUTH }, + env, + ); + const openHtml = await openPage.text(); + expect(openHtml).toContain("Puedes escribirle hasta"); + expect(openHtml).toContain("Responde como humano"); + expect(openHtml).toContain("✓✓ Visto"); + expect(openHtml).not.toContain("no acepta un mensaje libre"); + + const onlyClosed = await adminApp.request( + "/conversations?f=cerrada", + { headers: AUTH }, + env, + ); + const list = await onlyClosed.text(); + expect(list).toContain("Sin ventana"); + expect(list).not.toContain("Con ventana"); + + const thread = await adminApp.request( + `/conversations/thread/${encodeURIComponent(closed.id)}`, + { headers: AUTH }, + env, + ); + const threadHtml = await thread.text(); + expect(threadHtml).toContain('id="composer-gate"'); + expect(threadHtml).toContain("hx-swap-oob"); + }); +}); + describe("inbox — pause / resume", () => { it("pause route pauses the bot and returns the paused thread fragment", async () => { const conv = await convs.getOrCreate("telegram", "u6", "Pedro"); diff --git a/test/channels/whatsapp.test.ts b/test/channels/whatsapp.test.ts index e7e8d97c..092d2d50 100644 --- a/test/channels/whatsapp.test.ts +++ b/test/channels/whatsapp.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { parseWhatsAppEvents, whatsappAdapter } from "../../src/channels/whatsapp"; +import { + parseWhatsAppEvents, + parseWhatsAppStatuses, + WhatsAppSendError, + whatsappAdapter, +} from "../../src/channels/whatsapp"; const ORIGIN = "https://bot.example.workers.dev"; const env = { WHATSAPP_APP_SECRET: "s3cr3t" } as any; @@ -65,7 +70,7 @@ describe("parseWhatsAppEvents", () => { expect(out[0].text).toBeUndefined(); }); - it("ignora los recibos de entrega/lectura (statuses)", async () => { + it("no convierte los recibos en mensajes del cliente", async () => { const b = { object: "whatsapp_business_account", entry: [{ id: "WABA", changes: [{ field: "messages", value: { statuses: [{ id: "wamid.x", status: "delivered" }] } }] }], @@ -85,6 +90,102 @@ describe("parseWhatsAppEvents", () => { }); }); +describe("parseWhatsAppStatuses", () => { + const now = 1_800_000_000_000; + + it("lee sent, delivered, read y failed con su código", () => { + const body = { + object: "whatsapp_business_account", + entry: [ + { + id: "WABA", + changes: [ + { + field: "messages", + value: { + statuses: [ + { id: "wamid.sent", status: "sent", timestamp: "1710000000", recipient_id: "5215511111111" }, + { id: "wamid.read", status: "read", timestamp: "1710000300", recipient_id: "5215511111111" }, + ], + }, + }, + { + field: "messages", + value: { + statuses: [ + { + id: "wamid.fail", + status: "failed", + timestamp: "1710000400", + recipient_id: "5215522222222", + errors: [ + { + code: 131047, + title: "Re-engagement message", + message: "Re-engagement message", + error_data: { details: "More than 24 hours have passed" }, + }, + ], + }, + ], + }, + }, + ], + }, + ], + }; + const out = parseWhatsAppStatuses(body as any, now); + expect(out).toHaveLength(3); + expect(out[0]).toMatchObject({ + wamid: "wamid.sent", + status: "sent", + timestamp: 1710000000 * 1000, + recipientId: "5215511111111", + }); + expect(out[1].status).toBe("read"); + expect(out[2]).toMatchObject({ + wamid: "wamid.fail", + status: "failed", + errorCode: 131047, + errorTitle: "Re-engagement message", + }); + }); + + it("lee los recibos aunque el mismo change traiga un mensaje", async () => { + const body = bodyFn([ + { from: "5215512345678", id: "wamid.in", type: "text", text: { body: "hola" } }, + ], { + statuses: [{ id: "wamid.out", status: "delivered", timestamp: "1710000001", recipient_id: "5215512345678" }], + }); + const messages = await parseWhatsAppEvents(body as any, env, ORIGIN); + const statuses = parseWhatsAppStatuses(body as any, now); + expect(messages).toHaveLength(1); + expect(messages[0].text).toBe("hola"); + expect(statuses).toEqual([ + expect.objectContaining({ wamid: "wamid.out", status: "delivered", timestamp: 1710000001 * 1000 }), + ]); + }); + + it("ignora changes que no son de messages y statuses sin id", () => { + const body = { + entry: [ + { + changes: [ + { field: "account_update", value: { statuses: [{ id: "wamid.no", status: "sent" }] } }, + { field: "messages", value: { statuses: [{ status: "sent" }, { id: "wamid.ok", status: "delivered", timestamp: "10" }] } }, + ], + }, + ], + }; + const out = parseWhatsAppStatuses(body as any, now); + expect(out.map((s) => s.wamid)).toEqual(["wamid.ok"]); + }); +}); + +function bodyFn(messages: any[], extra: any = {}) { + return body(messages, extra); +} + describe("whatsappAdapter.sendReply", () => { afterEach(() => vi.restoreAllMocks()); @@ -109,6 +210,65 @@ describe("whatsappAdapter.sendReply", () => { expect(payload.text.body).toBe("hola"); }); + it("en modo strict lanza con el código 131047 y no traga el rechazo", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + error: { + code: 131047, + message: "Re-engagement message", + error_data: { details: "More than 24 hours have passed since the customer last replied" }, + }, + }), + { status: 400 }, + ), + ), + ); + const err = await whatsappAdapter + .sendReply( + { channel: "whatsapp", channelUserId: "52155", chunks: ["seguimiento"], strict: true }, + { WHATSAPP_PHONE_NUMBER_ID: "PHONE_ID", WHATSAPP_ACCESS_TOKEN: "TOKEN" } as any, + ) + .then( + () => null, + (e) => e, + ); + expect(err).toBeInstanceOf(WhatsAppSendError); + expect(err.code).toBe(131047); + expect(err.message).toMatch(/ventana de 24 h/); + }); + + it("sin strict un rechazo de Meta no lanza (el bot sigue el turno)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ error: { code: 131047, message: "Re-engagement message" } }), { status: 400 }), + ), + ); + await expect( + whatsappAdapter.sendReply( + { channel: "whatsapp", channelUserId: "52155", chunks: ["hola"] }, + { WHATSAPP_PHONE_NUMBER_ID: "PHONE_ID", WHATSAPP_ACCESS_TOKEN: "TOKEN" } as any, + ), + ).resolves.toEqual({}); + }); + + it("devuelve el wamid que respondió Graph", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response(JSON.stringify({ messages: [{ id: "wamid.HBgLNTIx" }] }), { status: 200 }), + ), + ); + const sent = await whatsappAdapter.sendReply( + { channel: "whatsapp", channelUserId: "52155", chunks: ["hola"], strict: true }, + { WHATSAPP_PHONE_NUMBER_ID: "PHONE_ID", WHATSAPP_ACCESS_TOKEN: "TOKEN" } as any, + ); + expect(sent && sent.providerMessageId).toBe("wamid.HBgLNTIx"); + }); + it("lanza si falta configuración", async () => { await expect( whatsappAdapter.sendReply({ channel: "whatsapp", channelUserId: "x", chunks: ["hi"] }, {} as any), diff --git a/test/db/deliveries.test.ts b/test/db/deliveries.test.ts new file mode 100644 index 00000000..5fb4d942 --- /dev/null +++ b/test/db/deliveries.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestMiniflare } from "../helpers/miniflareSetup"; +import { Db } from "../../src/db/client"; +import { ConversationsRepo } from "../../src/db/conversations"; +import { MessagesRepo } from "../../src/db/messages"; +import { + applyWhatsAppStatuses, + deliveriesForConversation, + deliveryCaption, + linkOutboundWamid, +} from "../../src/db/deliveries"; +import { windowFor } from "../../src/segments"; + +let db: Db; +let convId: string; +let messageId: string; + +beforeEach(async () => { + const mf = await createTestMiniflare(); + const d1 = await mf.getD1Database("DB"); + db = new Db(d1 as any); + const conv = await new ConversationsRepo(db).getOrCreate("whatsapp", "5215500001111", "Ana"); + convId = conv.id; + messageId = await new MessagesRepo(db).append(convId, "owner", "seguimiento"); +}); + +describe("recibos de WhatsApp", () => { + it("liga el wamid del envío y el webhook lo sube a leído", async () => { + await linkOutboundWamid(db, { wamid: "wamid.1", messageId, conversationId: convId, statusAt: 1000 }); + await applyWhatsAppStatuses(db, [ + { wamid: "wamid.1", status: "delivered", timestamp: 2000 }, + { wamid: "wamid.1", status: "read", timestamp: 3000 }, + ]); + // Un sent atrasado no baja el visto. + await applyWhatsAppStatuses(db, [{ wamid: "wamid.1", status: "sent", timestamp: 1500 }]); + + const row = await db.first<{ status: string; message_id: string }>( + "SELECT status, message_id FROM message_deliveries WHERE wamid = ?", + ["wamid.1"], + ); + expect(row?.status).toBe("read"); + expect(row?.message_id).toBe(messageId); + + const map = await deliveriesForConversation(db, convId); + expect(deliveryCaption(map.get(messageId)!).text).toBe("✓✓ Visto"); + }); + + it("si el webhook llega antes del insert, el envío no pisa el fallo", async () => { + await applyWhatsAppStatuses(db, [ + { + wamid: "wamid.fail", + status: "failed", + timestamp: 5000, + errorCode: 131047, + errorTitle: "Re-engagement message", + }, + ]); + await linkOutboundWamid(db, { + wamid: "wamid.fail", + messageId, + conversationId: convId, + statusAt: 4000, + }); + + const row = await db.first<{ status: string; error_code: number; message_id: string; conversation_id: string }>( + "SELECT status, error_code, message_id, conversation_id FROM message_deliveries WHERE wamid = ?", + ["wamid.fail"], + ); + expect(row?.status).toBe("failed"); + expect(row?.error_code).toBe(131047); + expect(row?.message_id).toBe(messageId); + expect(row?.conversation_id).toBe(convId); + expect(deliveryCaption({ status: "failed", error_code: 131047, error_title: "Re-engagement message" }).text).toBe( + "No le llegó: fuera de la ventana de 24 h", + ); + }); + + it("windowFor usa el último mensaje del cliente", async () => { + const msgs = new MessagesRepo(db); + const now = Date.now(); + await msgs.append(convId, "user", "hola", { createdAt: now - 2 * 3_600_000 }); + await msgs.append(convId, "assistant", "qué tal", { createdAt: now - 60_000 }); + const win = await windowFor(db, convId, now); + expect(win.open).toBe(true); + expect(win.lastUserAt).toBe(now - 2 * 3_600_000); + + const stale = await windowFor(db, convId, now + 30 * 3_600_000); + expect(stale.open).toBe(false); + }); +}); diff --git a/test/segments-window.test.ts b/test/segments-window.test.ts new file mode 100644 index 00000000..4984be13 --- /dev/null +++ b/test/segments-window.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { WINDOW_MS, WINDOW_SAFE_MS, serviceWindowFrom, windowHoursLabel } from "../src/segments"; + +const NOW = 1_800_000_000_000; + +describe("serviceWindowFrom", () => { + it("está abierta si el cliente escribió hace menos de 24 h", () => { + const last = NOW - 5 * 3_600_000; + const win = serviceWindowFrom(last, NOW); + expect(win.open).toBe(true); + expect(win.closesAt).toBe(last + WINDOW_MS); + expect(windowHoursLabel(last, NOW)).toBe("✍ 19 h"); + }); + + it("cierra exactamente a las 24 h", () => { + const last = NOW - WINDOW_MS; + expect(serviceWindowFrom(last, NOW).open).toBe(false); + expect(windowHoursLabel(last, NOW)).toBe("🔒"); + }); + + it("sin mensaje del cliente la ventana está cerrada", () => { + expect(serviceWindowFrom(null, NOW).open).toBe(false); + expect(serviceWindowFrom(undefined, NOW).closesAt).toBeNull(); + }); + + it("bajo una hora muestra minutos", () => { + const last = NOW - (WINDOW_MS - 30 * 60_000); + expect(windowHoursLabel(last, NOW)).toBe("✍ 30 min"); + }); + + it("las campañas pueden pedir el margen de 23 h sin duplicar la cuenta", () => { + const last = NOW - 23 * 3_600_000; + expect(serviceWindowFrom(last, NOW).open).toBe(true); + expect(serviceWindowFrom(last, NOW, WINDOW_SAFE_MS).open).toBe(false); + }); +});