-
+ ${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);
+ });
+});