Compare commits

..

No commits in common. '536de6b4f86842ad2308d63a2b291882c1cbf848' and '2450c8806abf8f21cc0f9d51bba7f3f4fd08f5df' have entirely different histories.

@ -20,7 +20,6 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
- 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.
- 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.
- Persistencia simple con SQLite, migraciones automáticas y PRAGMAs seguros (WAL, FK, etc.).
@ -37,7 +36,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
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.
3. Los servicios de dominio (tareas, recordatorios, alias, colas) operan sobre SQLite.
4. Las respuestas y reacciones se encolan y se envían a través de Evolution API.
4. Las respuestas se encolan y envían a través de Evolution API.
5. Schedulers ejecutan sincronización de grupos/miembros, recordatorios y tareas de mantenimiento.
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).
@ -70,10 +69,6 @@ Variables clave:
- EVOLUTION_API_URL, EVOLUTION_API_INSTANCE, EVOLUTION_API_KEY.
- ADMIN_USERS (lista de IDs/JIDs autorizados).
- 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).
- TZ (por defecto Europe/Madrid).
- REMINDERS_GRACE_MINUTES (ventana de gracia tras la hora; por defecto 60).
@ -110,7 +105,6 @@ 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 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).
- 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.
## Enlaces

@ -19,10 +19,6 @@ Variables de entorno (principales)
- 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).
- 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'
- 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'
@ -115,10 +111,6 @@ Métricas de referencia
- commands_blocked_total (counter).
- sync_skipped_group_total (counter).
- 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í.
Buenas prácticas

@ -1,229 +0,0 @@
# 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,24 +453,5 @@ export const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_groups_is_community ON groups (is_community);`);
} 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,52 +477,17 @@ export class WebhookServer {
(TaskService as any).dbInstance = WebhookServer.dbInstance;
// Delegar el manejo del comando
const messageId = typeof data?.key?.id === 'string' ? data.key.id : null;
const outcome = await CommandService.handleWithOutcome({
const responses = await CommandService.handle({
sender: normalizedSenderId,
groupId: data.key.remoteJid,
message: messageText,
mentions,
messageId: messageId || undefined
mentions
});
const responses = outcome.responses;
// Encolar respuestas si las hay
if (responses.length > 0) {
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);
}
}
}
}
@ -565,13 +530,6 @@ export class WebhookServer {
Metrics.inc('onboarding_prompts_sent_total', 0);
Metrics.inc('onboarding_prompts_skipped_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 {}
if (process.env.NODE_ENV !== 'test') {

@ -17,7 +17,6 @@ type CommandContext = {
groupId: string; // full JID (e.g., xxx@g.us)
message: string; // raw message text
mentions: string[]; // array of raw JIDs mentioned
messageId?: string; // id del mensaje origen (para task_origins y reacciones)
};
export type CommandResponse = {
@ -26,12 +25,6 @@ export type CommandResponse = {
mentions?: string[]; // full JIDs to mention in the outgoing message
};
export type CommandOutcome = {
responses: CommandResponse[];
ok: boolean;
createdTaskIds?: number[];
};
export class CommandService {
static dbInstance: Database = db;
@ -1213,16 +1206,6 @@ 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
const createdTask = TaskService.getTaskById(taskId);
@ -1312,17 +1295,12 @@ export class CommandService {
}
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();
if (!/^\/(tarea|t)\b/i.test(msg)) {
return { responses: [], ok: true };
return [];
}
// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
// Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente
if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
@ -1330,7 +1308,7 @@ export class CommandService {
try {
if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {}
return { responses: [], ok: true };
return [];
}
} catch {
// Si falla el check, ser permisivos
@ -1339,79 +1317,12 @@ export class CommandService {
}
try {
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 };
return await this.processTareaCommand(context);
} catch (error) {
return {
responses: [{
recipient: context.sender,
message: 'Error processing command'
}],
ok: false
};
return [{
recipient: context.sender,
message: 'Error processing command'
}];
}
}
}

@ -42,7 +42,6 @@ export const ResponseQueue = {
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,
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)
CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false',
@ -107,44 +106,6 @@ 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 {
return {
apikey: process.env.EVOLUTION_API_KEY || '',
@ -161,46 +122,6 @@ export const ResponseQueue = {
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
const url = `${baseUrl}/message/sendText/${instance}`;
@ -373,13 +294,8 @@ export const ResponseQueue = {
continue;
}
// 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones)
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) {
// 5xx o error de red: reintento con backoff si no superó el máximo
if (attemptsNow >= this.MAX_ATTEMPTS) {
this.markFailed(item.id, errMsg, status, attemptsNow);
continue;
}

@ -2,8 +2,6 @@ import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue';
import { Metrics } from '../services/metrics';
type CreateTaskInput = {
description: string;
@ -280,52 +278,6 @@ export class TaskService {
`)
.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 {
status: 'updated',
task: {

@ -1,131 +0,0 @@
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');
});
});

@ -1,55 +0,0 @@
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');
});
});

@ -1,82 +0,0 @@
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' });
});
});

@ -1,158 +0,0 @@
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