Compare commits

...

10 Commits

Author SHA1 Message Date
brobert 536de6b4f8 docs: agregar métricas de reacciones del bot en operaciones
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 595e679276 docs: actualizar documentación con nuevas vars REACTIONS_* y cambios
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 215f242a0d feat: precalentar métricas de reacciones y añadir tests E2E
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert f0ab277d38 feat: añadir handleWithOutcome y usar outcome en WebhookServer
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 5f8b494a7f fix: establece fromMe=false en reacciones y actualiza tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 89c837b34d test: añade tests unitarios para la reacción al completar
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert cddb454692 feat: encolar reacción al completar tarea dentro TTL y filtrado
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 1a93e7aee0 test: añade tests unitarios para ResponseQueue y task_origins
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert db8d22c04c feat: agrega migración v17 task_origins y soporte de reacciones en webhook y queue
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert f020c809ec docs: añade plan detallado de reacciones del bot de tareas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago

@ -20,6 +20,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
- Alias de identidad con normalización de IDs. - Alias de identidad con normalización de IDs.
- Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso. - Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso.
- Métricas listas para Prometheus en el endpoint /metrics. - Métricas listas para Prometheus en el endpoint /metrics.
- Acks por reacciones en WhatsApp: 🤖/⚠️ al procesar comandos y ✅ al completar tareas dentro de un TTL configurable; idempotencia y gating por grupo/alcance; requiere Evolution API sendReaction (key.fromMe=false).
- Rate limiting por usuario para evitar abuso. - Rate limiting por usuario para evitar abuso.
- Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.). - Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.).
@ -36,7 +37,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
1. Evolution API envía eventos al webhook de Taskbot. 1. Evolution API envía eventos al webhook de Taskbot.
2. El servidor normaliza el mensaje, aplica control de acceso por grupo y rate limit. 2. El servidor normaliza el mensaje, aplica control de acceso por grupo y rate limit.
3. Los servicios de dominio (tareas, recordatorios, alias, colas) operan sobre SQLite. 3. Los servicios de dominio (tareas, recordatorios, alias, colas) operan sobre SQLite.
4. Las respuestas se encolan y envían a través de Evolution API. 4. Las respuestas y reacciones se encolan y se envían a través de Evolution API.
5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento. 5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento.
6. Las métricas se exponen en /metrics (Prometheus o JSON). 6. Las métricas se exponen en /metrics (Prometheus o JSON).
7. Un proxy interno en Bun sirve web y bot bajo el mismo dominio: /webhook y /metrics → bot; el resto → web. Actualmente, la compresión HTTP está desactivada temporalmente (sin Content-Encoding). 7. Un proxy interno en Bun sirve web y bot bajo el mismo dominio: /webhook y /metrics → bot; el resto → web. Actualmente, la compresión HTTP está desactivada temporalmente (sin Content-Encoding).
@ -69,6 +70,10 @@ Variables clave:
- EVOLUTION_API_URL, EVOLUTION_API_INSTANCE, EVOLUTION_API_KEY. - EVOLUTION_API_URL, EVOLUTION_API_INSTANCE, EVOLUTION_API_KEY.
- ADMIN_USERS (lista de IDs/JIDs autorizados). - ADMIN_USERS (lista de IDs/JIDs autorizados).
- GROUP_GATING_MODE: off | discover | enforce. - GROUP_GATING_MODE: off | discover | enforce.
- REACTIONS_ENABLED: 'true'|'false' para activar reacciones (por defecto 'false').
- REACTIONS_SCOPE: 'groups'|'all' para limitar reacciones a grupos o permitir en DMs (por defecto 'groups').
- REACTIONS_TTL_DAYS: días para permitir la reacción ✅ tras completar (por defecto 14).
- RQ_REACTIONS_MAX_ATTEMPTS: reintentos máximos para jobs de reacción (si no se define, aplica el global).
- WHATSAPP_COMMUNITY_ID (para sincronización de grupos). - WHATSAPP_COMMUNITY_ID (para sincronización de grupos).
- TZ (por defecto Europe/Madrid). - TZ (por defecto Europe/Madrid).
- REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60). - REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60).
@ -105,6 +110,7 @@ Consulta:
- Etapa 2 (lectura de datos - MVP): completada. GET /api/me/tasks (orden por due_date asc con NULL al final, búsqueda con ESCAPE, filtros soonDays/dueBefore, paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app (Mis tareas, filtros/búsqueda/paginación) y /app/groups (bloque “sin responsable” con prefetch). - Etapa 2 (lectura de datos - MVP): completada. GET /api/me/tasks (orden por due_date asc con NULL al final, búsqueda con ESCAPE, filtros soonDays/dueBefore, paginación page/limit), GET /api/me/groups (contadores open/unassigned) y GET /api/groups/:id/tasks (unassignedFirst, onlyUnassigned, limit). UI: /app (Mis tareas, filtros/búsqueda/paginación) y /app/groups (bloque “sin responsable” con prefetch).
- Etapa 3 (preferencias): completada. GET/POST /api/me/preferences y página /app/preferences con cálculo de “próximo recordatorio” coherente con la TZ y semántica del bot. - Etapa 3 (preferencias): completada. GET/POST /api/me/preferences y página /app/preferences con cálculo de “próximo recordatorio” coherente con la TZ y semántica del bot.
- Edición de tareas en web: completada. Reclamar/soltar, editar fecha y descripción desde /app; completar tareas y mostrar “Completadas (24 h)”; reclamar desde /app/groups; lista "sin responsable" sin límite y fichas ordenadas por cantidad de "sin responsable" (con gating y validación). - Edición de tareas en web: completada. Reclamar/soltar, editar fecha y descripción desde /app; completar tareas y mostrar “Completadas (24 h)”; reclamar desde /app/groups; lista "sin responsable" sin límite y fichas ordenadas por cantidad de "sin responsable" (con gating y validación).
- Reacciones en WhatsApp: completadas. 🤖/⚠️ al procesar comandos y ✅ al completar dentro de TTL; idempotencia, gating por grupo (enforce) y alcance configurable (groups|all).
- Roadmap y contribuciones: pendientes de publicación. - Roadmap y contribuciones: pendientes de publicación.
## Enlaces ## Enlaces

@ -19,6 +19,10 @@ Variables de entorno (principales)
- TZ: zona horaria para recordatorios (default Europe/Madrid). - TZ: zona horaria para recordatorios (default Europe/Madrid).
- REMINDERS_GRACE_MINUTES: minutos de gracia tras la hora programada para enviar recordatorios atrasados (por defecto 60). - REMINDERS_GRACE_MINUTES: minutos de gracia tras la hora programada para enviar recordatorios atrasados (por defecto 60).
- GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover' - GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover'
- REACTIONS_ENABLED: 'true'|'false' para activar reacciones (por defecto 'false'). Ej.: REACTIONS_ENABLED='true'
- REACTIONS_SCOPE: 'groups'|'all' para limitar reacciones a grupos o permitir en DMs (por defecto 'groups'). Ej.: REACTIONS_SCOPE='groups'
- REACTIONS_TTL_DAYS: días para permitir la reacción ✅ al completar respecto al mensaje origen (por defecto 14). Ej.: REACTIONS_TTL_DAYS='14'
- RQ_REACTIONS_MAX_ATTEMPTS: reintentos máximos para jobs de reacción (si no se define, aplica el global). Ej.: RQ_REACTIONS_MAX_ATTEMPTS='3'
- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222' - ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222'
- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' - ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us'
- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' - NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true'
@ -111,6 +115,10 @@ Métricas de referencia
- commands_blocked_total (counter). - commands_blocked_total (counter).
- sync_skipped_group_total (counter). - sync_skipped_group_total (counter).
- admin_actions_total_allow, admin_actions_total_block (counters). - admin_actions_total_allow, admin_actions_total_block (counters).
- Reacciones del bot:
- reactions_enqueued_total{emoji=robot|warn|check|other}
- reactions_sent_total{emoji=...}
- reactions_failed_total{emoji=...}
- Añadir nuevas métricas usando Metrics.inc/set y documentarlas aquí. - Añadir nuevas métricas usando Metrics.inc/set y documentarlas aquí.
Buenas prácticas Buenas prácticas

@ -0,0 +1,229 @@
# Plan de reacciones del bot de tareas (WhatsApp)
Objetivo: añadir un “ack” visual de bajo ruido en grupos, usando reacciones a los mensajes con comandos `/t`. Alcance inicial:
- Reaccionar 1 sola vez por comando:
- Éxito (comando procesado): 🤖
- Error (uso inválido, permisos, no encontrada…): ⚠️
- Plus opcional sin mucha complejidad: si el comando creó una tarea y esta se completa dentro de un TTL (714 días), reaccionar con ✅ al mensaje origen del comando.
No se añaden mensajes al chat; solo reacciones. Por defecto solo en grupos. Todo detrás de “feature flags”.
---
## 1) UX y reglas
- Ámbito:
- Grupos permitidos (AllowedGroups) por defecto (REACTIONS_SCOPE=groups).
- No reaccionar en DMs salvo que se configure explícitamente (REACTIONS_SCOPE=all).
- Una reacción por comando (no usar “procesando”/“pensando”).
- No borrar/reemplazar reacciones anteriores; simplemente añadir la correspondiente (🤖/⚠️) y, si aplica, luego ✅.
- TTL para marcar ✅ tras completar: 14 días por defecto (configurable vía REACTIONS_TTL_DAYS).
Emojis:
- Éxito de procesamiento: 🤖
- Error: ⚠️
- Tarea completada (tardío): ✅
---
## 2) Flags/entorno
Añadir variables de entorno:
- REACTIONS_ENABLED=true|false (default: false)
- REACTIONS_TTL_DAYS=14 (configurable; sin clamp, por defecto 14)
- REACTIONS_SCOPE=groups|all (default: groups)
- (Opcional) RQ_REACTIONS_MAX_ATTEMPTS=3 para limitar reintentos de jobs de reacción
Se reutilizan:
- GROUP_GATING_MODE (off|discover|enforce)
- AllowedGroups.isAllowed para coherencia con el gating.
---
## 3) Persistencia: nueva tabla `task_origins` (migración v17)
Objetivo: vincular una tarea creada con el mensaje de WhatsApp que originó el comando para poder reaccionar con ✅ al completarse.
Esquema:
- task_id INTEGER PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE
- chat_id TEXT NOT NULL // JID completo del grupo (p. ej. 123@g.us)
- message_id TEXT NOT NULL // id del mensaje del comando
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
Índices:
- CREATE INDEX IF NOT EXISTS idx_task_origins_task ON task_origins(task_id);
- (Opcional) CREATE INDEX IF NOT EXISTS idx_task_origins_chat_msg ON task_origins(chat_id, message_id);
Notas:
- 1 fila por tarea (PK = task_id). Suficiente para nuestro caso.
- No toca esquemas existentes.
---
## 4) Cola: soporte de “jobs de reacción” en ResponseQueue
Formato del job (reutilizamos `response_queue`, sin cambiar esquema):
- recipient: usar `chatId` (JID) para cumplir NOT NULL.
- message: puede estar vacío (no se usa para reactions).
- metadata (JSON):
```
{
"kind": "reaction",
"emoji": "🤖" | "⚠️" | "✅",
"chatId": "<jid>",
"messageId": "<msg-id>"
}
```
Envío (Evolution API):
- POST {EVOLUTION_API_URL}/message/sendReaction/{instance}
- Headers: { apikey, Content-Type: application/json }
- Body:
```
{
"key": { "remoteJid": "<jid>", "fromMe": false, "id": "<msg-id>" },
"reaction": "<emoji>"
}
```
Reintentos:
- Backoff existente.
- Opcional: limitar reacciones con `RQ_REACTIONS_MAX_ATTEMPTS` (p. ej. 3). 4xx → fallo definitivo; 5xx/red → reintentos.
Idempotencia:
- Antes de insertar, consultar si ya existe (status IN queued|processing|sent) un job con metadata idéntica (mismo chatId, messageId, emoji) en las últimas 24h; si existe, no insertar otro.
- Mantener JSON canónico (mismas claves/orden) al construir metadata para hacer la comparación fiable o parsear JSON en la consulta.
---
## 5) Cambios por fichero (implementación por fases)
Fase 1 — Infra y reacción final por comando
- src/services/response-queue.ts
- Detectar `metadata.kind === 'reaction'`.
- Construir y enviar POST a `/message/sendReaction/{instance}` con el payload anterior.
- Opcional: `RQ_REACTIONS_MAX_ATTEMPTS` para jobs de reacción.
- src/server.ts (WebhookServer)
- Capturar `messageId = data.key.id`.
- Pasar `messageId` en el `CommandContext`.
- Tras ejecutar el comando, decidir emoji:
- Si REACTIONS_ENABLED=false → no hacer nada.
- Si REACTIONS_SCOPE=groups y no es grupo → no hacer nada.
- Si GROUP_GATING_MODE='enforce' y el grupo no está allowed → no hacer nada.
- Determinar outcome con `handleWithOutcome` en CommandService que devuelve `{ responses, ok: boolean, createdTaskIds?: number[] }` (implementado).
- Encolar job con emoji = ok ? 🤖 : ⚠️, `chatId=remoteJid`, `messageId`.
- Idempotencia: consulta previa antes de insertar.
- src/services/command.ts
- Ampliar `CommandContext` con `messageId: string`.
- En la rama `/t nueva`, tras crear la tarea:
- Si `isGroupId(context.groupId)` y `context.messageId`, insertar fila en `task_origins (task_id, chat_id, message_id)`.
- (Recomendado) Añadir `handleWithOutcome` para clasificar ok/error sin depender del texto.
- src/db/migrations/index.ts
- Añadir migración v17 con `task_origins` e índices.
Fase 2 — Reacción tardía (✅) al completar
- src/tasks/service.ts
- En `completeTask`, cuando `status === 'updated'`:
- Buscar `task_origins` por `taskId`.
- Si no existe, salir.
- Comprobar TTL: `now - created_at <= REACTIONS_TTL_DAYS`.
- Flags/política: `REACTIONS_ENABLED` y, si `REACTIONS_SCOPE=groups`, que `chat_id` termine en `@g.us`.
- (Opcional) En modo enforce, verificar AllowedGroups.isAllowed(chat_id).
- Encolar job `kind:'reaction', emoji:'✅', chatId, messageId`.
- Idempotencia: mismo check previo antes de insertar.
---
## 6) Flujo E2E (grupo permitido)
1) Usuario envía mensaje con `/t nueva …` en un grupo.
2) WebhookServer:
- Obtiene `remoteJid`, `messageId`.
- Construye `CommandContext` con `sender`, `groupId`, `message`, `mentions`, `messageId`.
3) CommandService:
- Procesa el comando.
- Si crea tarea: inserta fila en `task_origins`.
4) WebhookServer:
- Clasifica outcome (ok/err).
- Si aplica, encola una reacción (🤖 o ⚠️) usando ResponseQueue.
5) Más tarde, alguien completa la tarea:
- TaskService.completeTask → si dentro del TTL, encola ✅ apuntando al `messageId` original.
6) ResponseQueue:
- Consume jobs `kind:'reaction'` y llama a Evolution `/message/sendReaction`.
---
## 7) Idempotencia, límites y gating
- Idempotencia:
- No duplicar reacciones para el mismo (chatId, messageId, emoji) gracias a la consulta previa en `response_queue`.
- Completar varias veces → solo 1 job ✅ (misma idempotencia).
- Gating:
- Respetar `GROUP_GATING_MODE='enforce'`: no reaccionar en grupos no permitidos.
- No reaccionar en DMs salvo `REACTIONS_SCOPE=all`.
- Límites:
- RateLimiter de comandos ya limita frecuencia.
- Reintentos de reacciones limitados para evitar ruido prolongado.
---
## 8) Errores previstos y manejo
- Mensaje borrado / bot expulsado / permisos → error 4xx → marcar `failed` sin reintentos excesivos.
- Errores de red/5xx → reintentos con backoff hasta `RQ_REACTIONS_MAX_ATTEMPTS` (si definido) o los globales.
- Falta de `messageId` en el evento → omitir reacciones y `task_origins` (no romper el flujo).
---
## 9) Pruebas a añadir
Unitarias:
- Reacción final:
- Grupo allowed, `REACTIONS_ENABLED=true`, `/t nueva …` → se encola 🤖 (1 job con metadata.kind='reaction', emoji='🤖', chatId=grupo, messageId capturado).
- Comando inválido (p. ej. `/t x` sin IDs) → se encola ⚠️.
- DM con `REACTIONS_SCOPE=groups` → no se encola.
- `REACTIONS_ENABLED=false` → no se encola.
- task_origins:
- Tras `/t nueva` en grupo, existe `task_origins(task_id, chat_id, message_id)`.
- Completar → ✅:
- Dentro de TTL → se encola ✅ con el `messageId` de origen.
- Fuera de TTL → no se encola.
- Completar dos veces → solo 1 job ✅ (idempotencia).
- ResponseQueue:
- Jobs `kind:'reaction'` llaman a `/message/sendReaction…` (no a sendText).
- Manejo de 4xx/5xx conforme a política de reintentos.
Integración simulada:
- Flujo feliz: `/t nueva` → 🤖; `completeTask` → ✅.
- Error: comando desconocido o “Uso:” → ⚠️.
- Grupo bloqueado en enforce → no reacción.
---
## 10) Despliegue y configuración
- Añadir flags al entorno:
- `REACTIONS_ENABLED=false` (arranque en “off”).
- `REACTIONS_TTL_DAYS=14`.
- `REACTIONS_SCOPE=groups`.
- (Opcional) `RQ_REACTIONS_MAX_ATTEMPTS=3`.
- Aplicar migraciones (incluye v17: `task_origins`).
- Activar `REACTIONS_ENABLED` gradualmente y monitorizar efectos.
---
## 11) Consideraciones
- Notificaciones: algunos usuarios reciben notificación por reacciones; una sola por comando minimiza ruido.
- Privacidad: no se envían datos nuevos; solo reacciones en el mismo chat.
- Observabilidad: se puede añadir contadores de métricas (opcional):
- `reactions_enqueued_total{emoji=…}`, `reactions_sent_total`, `reactions_failed_total`.
---
## 12) Trabajos futuros (opcional)
- Debounce de “procesando” (⏳) >12s y reemplazo por 🤖.
- Opt-out por grupo (preferencia guardada en DB).
- Cambio de reacción previa (quitar ⚠️/🤖 y dejar solo ✅) — requiere leer/gestionar estado de reacciones y añade complejidad.
- Reaccionar a otros comandos (tomar/soltar) con emojis específicos.

@ -453,5 +453,24 @@ export const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`);
} catch {} } catch {}
} }
},
{
version: 17,
name: 'task-origins',
checksum: 'v17-task-origins-2025-10-20',
up: (db: Database) => {
db.exec(`PRAGMA foreign_keys = ON;`);
db.exec(`
CREATE TABLE IF NOT EXISTS task_origins (
task_id INTEGER PRIMARY KEY,
chat_id TEXT NOT NULL,
message_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_origins_task ON task_origins (task_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_origins_chat_msg ON task_origins (chat_id, message_id);`);
}
} }
]; ];

@ -477,17 +477,52 @@ export class WebhookServer {
(TaskService as any).dbInstance = WebhookServer.dbInstance; (TaskService as any).dbInstance = WebhookServer.dbInstance;
// Delegar el manejo del comando // Delegar el manejo del comando
const responses = await CommandService.handle({ const messageId = typeof data?.key?.id === 'string' ? data.key.id : null;
const outcome = await CommandService.handleWithOutcome({
sender: normalizedSenderId, sender: normalizedSenderId,
groupId: data.key.remoteJid, groupId: data.key.remoteJid,
message: messageText, message: messageText,
mentions mentions,
messageId: messageId || undefined
}); });
const responses = outcome.responses;
// Encolar respuestas si las hay // Encolar respuestas si las hay
if (responses.length > 0) { if (responses.length > 0) {
await ResponseQueue.add(responses); await ResponseQueue.add(responses);
} }
// Reaccionar al mensaje del comando con outcome explícito
try {
const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(reactionsEnabled);
if (!enabled) return;
if (!messageId) return;
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
const isGroup = isGroupId(data.key.remoteJid);
if (scope !== 'all' && !isGroup) return;
// Respetar gating 'enforce'
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce' && isGroup) {
try {
if (!AllowedGroups.isAllowed(data.key.remoteJid)) {
return;
}
} catch {}
}
const emoji = outcome.ok ? '🤖' : '⚠️';
await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji);
} catch (e) {
// No romper el flujo por errores de reacción
if (process.env.NODE_ENV !== 'test') {
console.warn('⚠️ Reaction enqueue failed:', e);
}
}
} }
} }
@ -530,6 +565,13 @@ export class WebhookServer {
Metrics.inc('onboarding_prompts_sent_total', 0); Metrics.inc('onboarding_prompts_sent_total', 0);
Metrics.inc('onboarding_prompts_skipped_total', 0); Metrics.inc('onboarding_prompts_skipped_total', 0);
Metrics.inc('onboarding_assign_failures_total', 0); Metrics.inc('onboarding_assign_failures_total', 0);
// Precalentar métricas de reacciones por emoji
for (const emoji of ['robot', 'warn', 'check', 'other']) {
Metrics.inc('reactions_enqueued_total', 0, { emoji });
Metrics.inc('reactions_sent_total', 0, { emoji });
Metrics.inc('reactions_failed_total', 0, { emoji });
}
} catch {} } catch {}
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {

@ -17,6 +17,7 @@ type CommandContext = {
groupId: string; // full JID (e.g., xxx@g.us) groupId: string; // full JID (e.g., xxx@g.us)
message: string; // raw message text message: string; // raw message text
mentions: string[]; // array of raw JIDs mentioned mentions: string[]; // array of raw JIDs mentioned
messageId?: string; // id del mensaje origen (para task_origins y reacciones)
}; };
export type CommandResponse = { export type CommandResponse = {
@ -25,6 +26,12 @@ export type CommandResponse = {
mentions?: string[]; // full JIDs to mention in the outgoing message mentions?: string[]; // full JIDs to mention in the outgoing message
}; };
export type CommandOutcome = {
responses: CommandResponse[];
ok: boolean;
createdTaskIds?: number[];
};
export class CommandService { export class CommandService {
static dbInstance: Database = db; static dbInstance: Database = db;
@ -1206,6 +1213,16 @@ export class CommandService {
})) }))
); );
// Registrar origen del comando para esta tarea (Fase 1)
try {
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
this.dbInstance.prepare(`
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
VALUES (?, ?, ?)
`).run(taskId, groupIdToUse, context.messageId);
}
} catch {}
// Recuperar la tarea creada para obtener display_code asignado // Recuperar la tarea creada para obtener display_code asignado
const createdTask = TaskService.getTaskById(taskId); const createdTask = TaskService.getTaskById(taskId);
@ -1295,12 +1312,17 @@ export class CommandService {
} }
static async handle(context: CommandContext): Promise<CommandResponse[]> { static async handle(context: CommandContext): Promise<CommandResponse[]> {
const outcome = await this.handleWithOutcome(context);
return outcome.responses;
}
static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
const msg = (context.message || '').trim(); const msg = (context.message || '').trim();
if (!/^\/(tarea|t)\b/i.test(msg)) { if (!/^\/(tarea|t)\b/i.test(msg)) {
return []; return { responses: [], ok: true };
} }
// Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente // Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
if (isGroupId(context.groupId)) { if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
@ -1308,7 +1330,7 @@ export class CommandService {
try { try {
if (!AllowedGroups.isAllowed(context.groupId)) { if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {} try { Metrics.inc('commands_blocked_total'); } catch {}
return []; return { responses: [], ok: true };
} }
} catch { } catch {
// Si falla el check, ser permisivos // Si falla el check, ser permisivos
@ -1317,12 +1339,79 @@ export class CommandService {
} }
try { try {
return await this.processTareaCommand(context); const responses = await this.processTareaCommand(context);
// Clasificación explícita del outcome (evita lógica en server)
const tokens = msg.split(/\s+/);
const rawAction = (tokens[1] || '').toLowerCase();
const ACTION_ALIASES: Record<string, string> = {
'n': 'nueva',
'nueva': 'nueva',
'crear': 'nueva',
'+': 'nueva',
'ver': 'ver',
'mostrar': 'ver',
'listar': 'ver',
'ls': 'ver',
'x': 'completar',
'hecho': 'completar',
'completar': 'completar',
'done': 'completar',
'tomar': 'tomar',
'claim': 'tomar',
'asumir': 'tomar',
'asumo': 'tomar',
'soltar': 'soltar',
'unassign': 'soltar',
'dejar': 'soltar',
'liberar': 'soltar',
'renunciar': 'soltar',
'ayuda': 'ayuda',
'help': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar',
'web': 'web'
};
const action = ACTION_ALIASES[rawAction] || rawAction;
// Casos explícitos considerados éxito
if (!action || action === 'ayuda' || action === 'web') {
return { responses, ok: true };
}
const lowerMsgs = (responses || []).map(r => String(r?.message || '').toLowerCase());
const isOkException = (m: string) =>
m.includes('ya estaba completada') ||
m.includes('ya la tenías') ||
m.includes('no la tenías');
const isErrorMsg = (m: string) =>
m.startsWith(' uso:'.toLowerCase()) ||
m.includes('uso:') ||
m.includes('no puedes') ||
m.includes('no permitido') ||
m.includes('no encontrada') ||
m.includes('comando no reconocido');
let hasError = false;
for (const m of lowerMsgs) {
if (isErrorMsg(m) && !isOkException(m)) {
hasError = true;
break;
}
}
return { responses, ok: !hasError };
} catch (error) { } catch (error) {
return [{ return {
responses: [{
recipient: context.sender, recipient: context.sender,
message: 'Error processing command' message: 'Error processing command'
}]; }],
ok: false
};
} }
} }
} }

@ -42,6 +42,7 @@ export const ResponseQueue = {
MAX_ATTEMPTS: process.env.RQ_MAX_ATTEMPTS ? Number(process.env.RQ_MAX_ATTEMPTS) : 6, MAX_ATTEMPTS: process.env.RQ_MAX_ATTEMPTS ? Number(process.env.RQ_MAX_ATTEMPTS) : 6,
BASE_BACKOFF_MS: process.env.RQ_BASE_BACKOFF_MS ? Number(process.env.RQ_BASE_BACKOFF_MS) : 5000, BASE_BACKOFF_MS: process.env.RQ_BASE_BACKOFF_MS ? Number(process.env.RQ_BASE_BACKOFF_MS) : 5000,
MAX_BACKOFF_MS: process.env.RQ_MAX_BACKOFF_MS ? Number(process.env.RQ_MAX_BACKOFF_MS) : 3600000, MAX_BACKOFF_MS: process.env.RQ_MAX_BACKOFF_MS ? Number(process.env.RQ_MAX_BACKOFF_MS) : 3600000,
REACTIONS_MAX_ATTEMPTS: process.env.RQ_REACTIONS_MAX_ATTEMPTS ? Number(process.env.RQ_REACTIONS_MAX_ATTEMPTS) : null,
// Limpieza/retención (configurable por entorno) // Limpieza/retención (configurable por entorno)
CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false', CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false',
@ -106,6 +107,44 @@ export const ResponseQueue = {
} }
}, },
// Encolar una reacción con idempotencia (24h) usando metadata canónica
async enqueueReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
try {
if (!chatId || !messageId || !emoji) return;
// Construir JSON canónico
const metaObj = { kind: 'reaction', emoji, chatId, messageId };
const metadata = JSON.stringify(metaObj);
const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other'));
// Ventana de 24h
const cutoff = this.futureIso(-24 * 60 * 60 * 1000);
// Idempotencia: existe job igual reciente en estados activos?
const exists = this.dbInstance.prepare(`
SELECT 1
FROM response_queue
WHERE metadata = ?
AND status IN ('queued','processing','sent')
AND (updated_at > ? OR created_at > ?)
LIMIT 1
`).get(metadata, cutoff, cutoff) as any;
if (exists) {
return;
}
this.dbInstance.prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?)
`).run(chatId, '', metadata, this.nowIso());
try { Metrics.inc('reactions_enqueued_total', 1, { emoji: emojiLabel }); } catch {}
} catch (err) {
console.error('Failed to enqueue reaction:', err);
throw err;
}
},
getHeaders(): HeadersInit { getHeaders(): HeadersInit {
return { return {
apikey: process.env.EVOLUTION_API_KEY || '', apikey: process.env.EVOLUTION_API_KEY || '',
@ -122,6 +161,46 @@ export const ResponseQueue = {
return { ok: false, error: msg }; return { ok: false, error: msg };
} }
// Detectar jobs de reacción
let meta: any = null;
try { meta = item.metadata ? JSON.parse(item.metadata) : null; } catch {}
if (meta && meta.kind === 'reaction') {
const reactionUrl = `${baseUrl}/message/sendReaction/${instance}`;
const chatId = String(meta.chatId || '');
const messageId = String(meta.messageId || '');
const emoji = String(meta.emoji || '');
const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other'));
if (!chatId || !messageId || !emoji) {
return { ok: false, error: 'invalid_reaction_metadata' };
}
const payload = {
key: { remoteJid: chatId, fromMe: false, id: messageId },
reaction: emoji
};
try {
const response = await fetch(reactionUrl, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
const errTxt = body?.slice(0, 200) || `HTTP ${response.status}`;
console.warn('Send reaction failed:', { status: response.status, body: errTxt });
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: false, status: response.status, error: errTxt };
}
console.log(`✅ Sent reaction with payload: ${JSON.stringify(payload)}`);
try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: true, status: response.status };
} catch (err) {
const errMsg = (err instanceof Error ? err.message : String(err));
console.error('Network error sending reaction:', errMsg);
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
return { ok: false, error: errMsg };
}
}
// Endpoint típico de Evolution API para texto simple // Endpoint típico de Evolution API para texto simple
const url = `${baseUrl}/message/sendText/${instance}`; const url = `${baseUrl}/message/sendText/${instance}`;
@ -294,8 +373,13 @@ export const ResponseQueue = {
continue; continue;
} }
// 5xx o error de red: reintento con backoff si no superó el máximo // 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones)
if (attemptsNow >= this.MAX_ATTEMPTS) { let metaForMax: any = null;
try { metaForMax = item.metadata ? JSON.parse(String(item.metadata)) : null; } catch {}
const isReactionJob = !!(metaForMax && metaForMax.kind === 'reaction');
const effectiveMax = isReactionJob && this.REACTIONS_MAX_ATTEMPTS ? this.REACTIONS_MAX_ATTEMPTS : this.MAX_ATTEMPTS;
if (attemptsNow >= effectiveMax) {
this.markFailed(item.id, errMsg, status, attemptsNow); this.markFailed(item.id, errMsg, status, attemptsNow);
continue; continue;
} }

@ -2,6 +2,8 @@ import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { db, ensureUserExists } from '../db';
import { AllowedGroups } from '../services/allowed-groups'; import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp'; import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue';
import { Metrics } from '../services/metrics';
type CreateTaskInput = { type CreateTaskInput = {
description: string; description: string;
@ -278,6 +280,52 @@ export class TaskService {
`) `)
.run(ensured, taskId); .run(ensured, taskId);
// Fase 2: reacción ✅ al completar dentro del TTL y con gating
try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(rxEnabled);
if (enabled) {
const origin = this.dbInstance.prepare(`
SELECT chat_id, message_id, created_at
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
if (origin && origin.chat_id && origin.message_id) {
const chatId = String(origin.chat_id);
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
if (scope === 'all' || isGroupId(chatId)) {
// TTL desde REACTIONS_TTL_DAYS (usar tal cual; default 14 si inválido)
const ttlDaysEnv = Number(process.env.REACTIONS_TTL_DAYS);
const ttlDays = Number.isFinite(ttlDaysEnv) && ttlDaysEnv > 0 ? ttlDaysEnv : 14;
const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000;
const createdRaw = String(origin.created_at || '');
const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z');
const createdMs = Date.parse(createdIso);
const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= maxAgeMs) : false;
if (withinTtl) {
// Gating 'enforce' para grupos
let allowed = true;
if (isGroupId(chatId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; }
}
}
if (allowed) {
// Encolar reacción ✅ con idempotencia; no bloquear si falla
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅')
.catch(() => {});
}
}
}
}
}
} catch {}
return { return {
status: 'updated', status: 'updated',
task: { task: {

@ -0,0 +1,131 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { WebhookServer } from '../../../src/server';
import { ResponseQueue } from '../../../src/services/response-queue';
import { AllowedGroups } from '../../../src/services/allowed-groups';
import { GroupSyncService } from '../../../src/services/group-sync';
function makePayload(event: string, data: any) {
return {
event,
instance: 'test-instance',
data
};
}
async function postWebhook(payload: any) {
const req = new Request('http://localhost/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
return await WebhookServer.handleRequest(req);
}
describe('WebhookServer E2E - reacciones por comando', () => {
let memdb: Database;
const envBackup = { ...process.env };
beforeAll(() => {
memdb = new Database(':memory:');
initializeDatabase(memdb);
(WebhookServer as any).dbInstance = memdb;
(ResponseQueue as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
(GroupSyncService as any).dbInstance = memdb;
});
afterAll(() => {
process.env = envBackup;
try { memdb.close(); } catch {}
});
beforeEach(() => {
process.env = {
...envBackup,
NODE_ENV: 'test',
REACTIONS_ENABLED: 'true',
REACTIONS_SCOPE: 'groups',
GROUP_GATING_MODE: 'enforce',
CHATBOT_PHONE_NUMBER: '999'
};
memdb.exec(`
DELETE FROM response_queue;
DELETE FROM task_assignments;
DELETE FROM tasks;
DELETE FROM users;
DELETE FROM groups;
DELETE FROM allowed_groups;
`);
GroupSyncService.activeGroupsCache?.clear?.();
});
it('encola 🤖 en grupo allowed y activo tras /t n', async () => {
const groupId = 'g1@g.us';
// Sembrar grupo activo y allowed
memdb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
VALUES ('${groupId}', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
`);
GroupSyncService.activeGroupsCache.set(groupId, 'G1');
AllowedGroups.setStatus(groupId, 'allowed');
const payload = makePayload('MESSAGES_UPSERT', {
key: { remoteJid: groupId, id: 'MSG-OK-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
message: { conversation: '/t n prueba e2e' }
});
const res = await postWebhook(payload);
expect(res.status).toBe(200);
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
const meta = JSON.parse(String(row.metadata));
expect(meta.kind).toBe('reaction');
expect(meta.emoji).toBe('🤖');
expect(meta.chatId).toBe(groupId);
expect(meta.messageId).toBe('MSG-OK-1');
});
it('no encola reacción en DM cuando REACTIONS_SCOPE=groups', async () => {
const dmJid = '600111222@s.whatsapp.net';
const payload = makePayload('MESSAGES_UPSERT', {
key: { remoteJid: dmJid, id: 'MSG-DM-1', fromMe: false },
message: { conversation: '/t n en DM no reacciona' }
});
const res = await postWebhook(payload);
expect(res.status).toBe(200);
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%'`).get() as any;
expect(Number(cnt.c)).toBe(0);
});
it('encola ⚠️ en grupo allowed y activo para comando inválido (/t x sin IDs)', async () => {
const groupId = 'g2@g.us';
memdb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
VALUES ('${groupId}', 'comm-1', 'G2', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
`);
GroupSyncService.activeGroupsCache.set(groupId, 'G2');
AllowedGroups.setStatus(groupId, 'allowed');
const payload = makePayload('MESSAGES_UPSERT', {
key: { remoteJid: groupId, id: 'MSG-ERR-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
message: { conversation: '/t x' }
});
const res = await postWebhook(payload);
expect(res.status).toBe(200);
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
const meta = JSON.parse(String(row.metadata));
expect(meta.kind).toBe('reaction');
expect(meta.emoji).toBe('⚠️');
expect(meta.chatId).toBe(groupId);
expect(meta.messageId).toBe('MSG-ERR-1');
});
});

@ -0,0 +1,55 @@
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service';
import { CommandService } from '../../../src/services/command';
import { GroupSyncService } from '../../../src/services/group-sync';
describe('CommandService - inserta task_origins al crear en grupo con messageId', () => {
let memdb: Database;
beforeAll(() => {
memdb = new Database(':memory:');
initializeDatabase(memdb);
(TaskService as any).dbInstance = memdb;
(CommandService as any).dbInstance = memdb;
// Sembrar grupo activo y cache
memdb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
VALUES ('g1@g.us', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
`);
try { (GroupSyncService as any).dbInstance = memdb; } catch {}
GroupSyncService.activeGroupsCache?.clear?.();
GroupSyncService.activeGroupsCache?.set?.('g1@g.us', 'G1');
});
beforeEach(() => {
process.env.NODE_ENV = 'test';
memdb.exec('DELETE FROM task_assignments; DELETE FROM tasks; DELETE FROM task_origins;');
});
it('crea tarea en grupo y registra (task_id, chat_id, message_id)', async () => {
const sender = '600111222';
const res = await CommandService.handle({
sender,
groupId: 'g1@g.us',
message: '/t n pruebas origen 2099-01-05',
mentions: [],
messageId: 'MSG-ORIG-1'
});
expect(res.length).toBeGreaterThan(0);
const t = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
expect(t).toBeTruthy();
const row = memdb.prepare(`
SELECT task_id, chat_id, message_id FROM task_origins WHERE task_id = ?
`).get(Number(t.id)) as any;
expect(row).toBeTruthy();
expect(Number(row.task_id)).toBe(Number(t.id));
expect(String(row.chat_id)).toBe('g1@g.us');
expect(String(row.message_id)).toBe('MSG-ORIG-1');
});
});

@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue';
const ORIGINAL_FETCH = globalThis.fetch;
const envBackup = { ...process.env };
describe('ResponseQueue - jobs de reacción (enqueue + sendOne)', () => {
let memdb: Database;
let captured: { url?: string; payload?: any } = {};
beforeEach(() => {
process.env = {
...envBackup,
NODE_ENV: 'test',
EVOLUTION_API_URL: 'http://evolution.test',
EVOLUTION_API_INSTANCE: 'instance-1',
EVOLUTION_API_KEY: 'apikey',
RQ_REACTIONS_MAX_ATTEMPTS: '3',
};
memdb = new Database(':memory:');
memdb.exec('PRAGMA foreign_keys = ON;');
initializeDatabase(memdb);
(ResponseQueue as any).dbInstance = memdb;
globalThis.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
captured.url = String(url);
try {
captured.payload = init?.body ? JSON.parse(String(init.body)) : null;
} catch {
captured.payload = null;
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
memdb.exec('DELETE FROM response_queue');
captured = {};
});
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
process.env = envBackup;
try { memdb.close(); } catch {}
});
it('enqueueReaction aplica idempotencia por (chatId, messageId, emoji) en ventana 24h', async () => {
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖');
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖'); // duplicado → ignorar
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c)).toBe(1);
// Mismo chat y mensaje, emoji distinto → debe insertar
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '⚠️');
const cnt2 = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt2.c)).toBe(2);
});
it('sendOne con metadata.kind === "reaction" usa /message/sendReaction y payload esperado', async () => {
const item = {
id: 42,
recipient: '123@g.us',
message: '', // no se usa para reaction
attempts: 0,
metadata: JSON.stringify({ kind: 'reaction', emoji: '🤖', chatId: '123@g.us', messageId: 'MSG-99' }),
};
const res = await ResponseQueue.sendOne(item as any);
expect(res.ok).toBe(true);
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
expect(captured.payload).toBeDefined();
expect(captured.payload.reaction).toBe('🤖');
expect(captured.payload.key).toEqual({ remoteJid: '123@g.us', fromMe: false, id: 'MSG-99' });
});
});

@ -0,0 +1,158 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service';
import { ResponseQueue } from '../../../src/services/response-queue';
import { AllowedGroups } from '../../../src/services/allowed-groups';
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
describe('TaskService - reacción ✅ al completar (Fase 2)', () => {
let memdb: Database;
let envBackup: Record<string, string | undefined>;
beforeAll(() => {
envBackup = { ...process.env };
memdb = new Database(':memory:');
initializeDatabase(memdb);
(TaskService as any).dbInstance = memdb;
(ResponseQueue as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
});
afterAll(() => {
process.env = envBackup;
try { memdb.close(); } catch {}
});
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.REACTIONS_ENABLED = 'true';
process.env.REACTIONS_SCOPE = 'groups';
process.env.REACTIONS_TTL_DAYS = '14';
process.env.GROUP_GATING_MODE = 'enforce';
memdb.exec(`
DELETE FROM response_queue;
DELETE FROM task_assignments;
DELETE FROM tasks;
DELETE FROM users;
DELETE FROM task_origins;
DELETE FROM allowed_groups;
`);
});
it('enqueuea ✅ al completar una tarea con task_origins dentro de TTL y grupo allowed', async () => {
const groupId = 'grp-1@g.us';
AllowedGroups.setStatus(groupId, 'allowed');
const taskId = TaskService.createTask({
description: 'Prueba ✅',
due_date: null,
group_id: groupId,
created_by: '600111222'
});
// Origen reciente (dentro de TTL)
const msgId = 'MSG-OK-1';
memdb.prepare(`
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
const res = TaskService.completeTask(taskId, '600111222');
expect(res.status).toBe('updated');
const row = memdb.prepare(`SELECT id, recipient, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
expect(String(row.recipient)).toBe(groupId);
const meta = JSON.parse(String(row.metadata || '{}'));
expect(meta.kind).toBe('reaction');
expect(meta.emoji).toBe('✅');
expect(meta.chatId).toBe(groupId);
expect(meta.messageId).toBe(msgId);
});
it('no encola ✅ si el origen está fuera de TTL', async () => {
const groupId = 'grp-2@g.us';
AllowedGroups.setStatus(groupId, 'allowed');
// TTL 7 días para forzar expiración
process.env.REACTIONS_TTL_DAYS = '7';
const taskId = TaskService.createTask({
description: 'Fuera TTL',
due_date: null,
group_id: groupId,
created_by: '600111222'
});
const msgId = 'MSG-OLD-1';
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 días atrás
memdb.prepare(`
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, groupId, msgId, toIsoSql(old));
const res = TaskService.completeTask(taskId, '600111222');
expect(res.status).toBe('updated');
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c)).toBe(0);
});
it('idempotencia: completar dos veces encola solo un ✅', async () => {
const groupId = 'grp-3@g.us';
AllowedGroups.setStatus(groupId, 'allowed');
const taskId = TaskService.createTask({
description: 'Idempotencia ✅',
due_date: null,
group_id: groupId,
created_by: '600111222'
});
const msgId = 'MSG-IDEMP-1';
memdb.prepare(`
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
const r1 = TaskService.completeTask(taskId, '600111222');
const r2 = TaskService.completeTask(taskId, '600111222');
expect(r1.status === 'updated' || r1.status === 'already').toBe(true);
expect(r2.status === 'updated' || r2.status === 'already').toBe(true);
const rows = memdb.query(`SELECT metadata FROM response_queue`).all() as any[];
expect(rows.length).toBe(1);
const meta = JSON.parse(String(rows[0].metadata || '{}'));
expect(meta.emoji).toBe('✅');
});
it('enforce: grupo no allowed → no encola ✅', async () => {
const groupId = 'grp-4@g.us';
// Estado por defecto 'pending' (no allowed)
const taskId = TaskService.createTask({
description: 'No allowed',
due_date: null,
group_id: groupId,
created_by: '600111222'
});
const msgId = 'MSG-NO-ALLOW-1';
memdb.prepare(`
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
const res = TaskService.completeTask(taskId, '600111222');
expect(res.status === 'updated' || res.status === 'already').toBe(true);
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c)).toBe(0);
});
});
Loading…
Cancel
Save