Compare commits

..

No commits in common. 'main' and 'webui' have entirely different histories.
main ... webui

3
.gitignore vendored

@ -44,6 +44,3 @@ docs/evolution-api.envs
tmp/ tmp/
apps/web/tmp/ apps/web/tmp/
apps/web/.sveltekit
apps/web/.build.lock
apps/web/.svelte-kit

@ -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. - 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.).
@ -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. 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 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. 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).
@ -70,10 +69,6 @@ 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).
@ -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 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

@ -28,21 +28,13 @@ function applyDefaultPragmas(instance: any): void {
* - En Node (Vite dev SSR): better-sqlite3 * - En Node (Vite dev SSR): better-sqlite3
*/ */
async function importSqliteDatabase(): Promise<any> { async function importSqliteDatabase(): Promise<any> {
// En tests, forzar bun:sqlite para evitar mezclar engines con la conexión de tests
const nodeEnv = String(process.env.NODE_ENV || '').toLowerCase();
if (nodeEnv === 'test') {
const mod: any = await import('bun:sqlite');
return (mod as any).Database || (mod as any).default || mod;
}
// En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS // En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.DEV) { if (import.meta.env.DEV) {
const modModule: any = await import('node:module'); const modModule: any = await import('node:module');
const require = modModule.createRequire(import.meta.url); const require = modModule.createRequire(import.meta.url);
const mod = require('better-sqlite3'); const mod = require('better-sqlite3');
return (mod as any).default || (mod as any).Database || mod; return (mod as any).default || (mod as any).Database || mod;
} }
// En producción (Bun en runtime), usar bun:sqlite nativo // En producción (Bun en runtime), usar bun:sqlite nativo
const mod: any = await import('bun:sqlite'); const mod: any = await import('bun:sqlite');
return (mod as any).Database || (mod as any).default || mod; return (mod as any).Database || (mod as any).default || mod;
@ -171,16 +163,3 @@ export async function getDb(filename: string = 'tasks.db'): Promise<any> {
_db = await openDb(filename); _db = await openDb(filename);
return _db; return _db;
} }
/**
* Cierra y resetea la instancia compartida (útil en tests para evitar manejar
* un descriptor abierto al borrar el archivo de la BD en disco).
*/
export function closeDb(): void {
try {
if (_db && typeof _db.close === 'function') {
_db.close();
}
} catch {}
_db = null;
}

@ -1,14 +1,5 @@
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { env } from '$env/dynamic/private';
// Carga compatible del entorno: en SvelteKit usa $env/dynamic/private;
// en tests/ejecución fuera de SvelteKit cae a process.env.
let env: any;
try {
const mod = await import('$env/dynamic/private');
env = (mod as any).env;
} catch {
env = process.env as any;
}
/** /**
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida. * Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
@ -53,10 +44,3 @@ export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN)
const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440); const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW)); export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000; export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;
// Reacciones (flags de característica para la web)
const REACTIONS_TTL_DAYS_RAW = Number(env.REACTIONS_TTL_DAYS || 14);
export const REACTIONS_TTL_DAYS = Math.max(1, Math.floor(REACTIONS_TTL_DAYS_RAW));
export const REACTIONS_ENABLED = toBool(env.REACTIONS_ENABLED || '');
export const REACTIONS_SCOPE = ((env.REACTIONS_SCOPE || 'groups').trim().toLowerCase() === 'all' ? 'all' : 'groups');
export const GROUP_GATING_MODE = (env.GROUP_GATING_MODE || 'off').trim().toLowerCase();

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import logo from "$lib/assets/wtasklogo192.png";
import { page } from "$app/stores"; import { page } from "$app/stores";
import Toast from "$lib/ui/feedback/Toast.svelte"; import Toast from "$lib/ui/feedback/Toast.svelte";
$: pathname = $page.url.pathname; $: pathname = $page.url.pathname;
@ -17,9 +16,7 @@
<header class="app-header"> <header class="app-header">
<div class="container row"> <div class="container row">
<a class="brand" href="/app" aria-label="Inicio" <a class="brand" href="/app" aria-label="Inicio">Tareas</a>
><img src={logo} alt="Wtask" /></a
>
<nav class="nav"> <nav class="nav">
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a> <a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
<a <a
@ -172,12 +169,6 @@
letter-spacing: 0.2px; letter-spacing: 0.2px;
font-size: 1.05rem; font-size: 1.05rem;
} }
.brand img {
display: block;
height: 48px;
border: 1px solid transparent;
border-radius: 50%;
}
.nav { .nav {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import "$lib/styles/tokens.css"; import '$lib/styles/tokens.css';
import "$lib/styles/base.css"; import '$lib/styles/base.css';
import favicon from "$lib/assets/favicon.ico"; import favicon from '$lib/assets/favicon.svg';
import Toast from "$lib/ui/feedback/Toast.svelte"; import Toast from '$lib/ui/feedback/Toast.svelte';
</script> </script>
<svelte:head> <svelte:head>
@ -10,5 +10,7 @@
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
</svelte:head> </svelte:head>
<slot /> <slot />
<Toast /> <Toast />

@ -1,6 +1,5 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db'; import { getDb } from '$lib/server/db';
import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null; const userId = event.locals.userId ?? null;
@ -102,75 +101,6 @@ export const POST: RequestHandler = async (event) => {
const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already'; const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already';
// Encolar reacción ✅ desde la web si procede (idéntico formato al bot)
try {
if (statusStr === 'updated' && REACTIONS_ENABLED) {
// Buscar origen con columnas opcionales (participant/from_me) si existen
let origin: any = null;
try {
origin = db.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
} catch {
origin = db.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);
// Scope: por defecto solo reaccionar en grupos
if (REACTIONS_SCOPE === 'all' || chatId.endsWith('@g.us')) {
// TTL (por defecto 14 días)
const ttlMs = REACTIONS_TTL_DAYS * 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 <= ttlMs) : false;
// Gating 'enforce' (solo aplica a grupos)
let allowed = true;
if (GROUP_GATING_MODE === 'enforce' && chatId.endsWith('@g.us')) {
const row = db.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`).get(chatId) as any;
allowed = !!row;
}
if (withinTtl && allowed) {
// Idempotencia 24h por metadata canónica exacta
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', '');
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', '');
const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) };
if (origin && (origin.from_me === 1 || origin.from_me === true)) meta.fromMe = true;
if (origin && origin.participant) meta.participant = String(origin.participant);
const metadata = JSON.stringify(meta);
const exists = db.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) {
db.prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?)
`).run(chatId, '', metadata, nowIso);
}
}
}
}
}
} catch {}
const body = { const body = {
status: statusStr, status: statusStr,
task: { task: {

@ -27,7 +27,7 @@
const groups = data.groups || []; const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {}; let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) { for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? [...(arr as any)] : []; itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
} }
function buildQuery(params: { unassignedFirst?: boolean }) { function buildQuery(params: { unassignedFirst?: boolean }) {
@ -103,21 +103,12 @@
queueMicrotask(() => window.scrollTo({ top: y })); queueMicrotask(() => window.scrollTo({ top: y }));
} }
function updateGroupTask( function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
groupId: string,
detail: {
id: number;
action: string;
patch: Partial<
Task & { completed?: boolean; completed_at?: string | null }
>;
},
) {
const { id, action, patch } = detail; const { id, action, patch } = detail;
const arr = itemsByGroup[groupId] || []; const arr = itemsByGroup[groupId] || [];
const idx = arr.findIndex((t) => t.id === id); const idx = arr.findIndex((t) => t.id === id);
if (action === "complete") { if (action === 'complete') {
if (idx >= 0) { if (idx >= 0) {
maintainScrollWhile(() => { maintainScrollWhile(() => {
arr.splice(idx, 1); arr.splice(idx, 1);
@ -237,8 +228,6 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
align-self: center;
white-space: nowrap;
} }
.badge.warn { .badge.warn {
border-color: var(--color-warning); border-color: var(--color-warning);

@ -40,34 +40,16 @@ export const GET: RequestHandler = async (event) => {
<title>Acceder</title> <title>Acceder</title>
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" /> <meta name="referrer" content="no-referrer" />
<meta name="color-scheme" content="light dark" />
<style> <style>
:root { color-scheme: light dark; } body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 2rem; }
body { .card { max-width: 480px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
font-family: sans-serif;
padding: 2rem;
background: #ffffff;
color: #111111;
}
.card {
max-width: 480px;
margin: 0 auto;
border: 1px solid #dddddd;
border-radius: 8px;
padding: 1.5rem;
background: #ffffff;
}
button[disabled] { opacity: .6; cursor: not-allowed; } button[disabled] { opacity: .6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #0b0b0b; color: #eeeeee; }
.card { border-color: #333333; background: #161616; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<h1>Acceso a las tareas</h1> <h1>Acceso seguro</h1>
<p>Pulsa Continuar. Si no funciona, copia y pega el enlace en tu navegador.</p> <p>Para continuar, pulsa Continuar. Si no funciona, asegúrate de abrir este enlace en tu navegador.</p>
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="token" value="${escapeHtml(token)}" /> <input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" id="nonceInput" name="nonce" value="${escapeHtml(nonce)}" /> <input type="hidden" id="nonceInput" name="nonce" value="${escapeHtml(nonce)}" />

@ -1,18 +0,0 @@
{
"metrics": [
"commands_alias_used_total",
"ver_dm_transition_total",
"web_tokens_issued_total",
"commands_unknown_total",
"commands_blocked_total",
"onboarding_prompts_sent_total",
"onboarding_prompts_skipped_total",
"onboarding_assign_failures_total",
"onboarding_bundle_sent_total",
"onboarding_recipients_capped_total",
"onboarding_dm_skipped_total"
],
"labels": {
"commands_alias_used_total": ["info", "mias", "todas"]
}
}

@ -1,57 +0,0 @@
{
"placeholders": {
"{id}": "ID o display_code de tarea",
"{desc}": "Descripción de la tarea",
"{date}": "Fecha formateada dd/MM",
"{url}": "URL absoluta",
"{group}": "Nombre del grupo",
"{list}": "Lista separada por comas",
"{bot}": "Número del bot sin prefijo"
},
"cta": [
" Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`"
],
"help": {
"transition_group_ver": "No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web",
"advanced_hint": "Ayuda avanzada: `/t ayuda avanzada`",
"legacy_quick_title": "Guía rápida:"
},
"usage": [
" Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)",
" Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)",
" Uso: `/t soltar 26`",
" Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`"
],
"errors": [
"⚠️ Tarea {id} no encontrada.",
"No puedes completar esta tarea porque no eres de este grupo.",
"No puedes tomar esta tarea porque no eres de este grupo.",
"⚠️ No puedes soltar esta tarea porque no eres de este grupo.",
"🚫 {id} — no permitido (no eres miembro activo).",
"⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).",
"Comando no reconocido",
"Acción {rawAction} no implementada aún"
],
"info": [
" Este comando se usa por privado. Envíame `/t web` por DM.",
"No tienes tareas pendientes.",
"⚠️ Se procesarán solo los primeros 10 IDs.",
"Resumen: ",
" Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.",
"✅ Recordatorios: {label}"
],
"states": [
"Ya estaba completada",
"ya estaba completada",
"ya la tenías",
"no la tenías",
"queda sin responsable."
],
"new_task_onboarding": [
"No puedo asignar a {list} aún (en el grupo {group}). Pídele que toque este enlace y diga 'activar': https://wa.me/{bot}",
"No puedo asignar a {list} aún. Pídele que toque este enlace y diga 'activar': https://wa.me/{bot}"
],
"web": [
"Acceso web: {url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar \"/t web\"."
]
}

@ -19,10 +19,6 @@ 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'
@ -115,10 +111,6 @@ 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

@ -1,312 +0,0 @@
# Plan por fases: Onboarding por DM y simplificación de comandos
Este documento define un plan incremental para:
- Introducir onboarding por DM no intrusivo (máx. 2 DMs por usuario, espaciados 14 días).
- Simplificar los comandos de visualización.
- Ajustar el material de ayuda.
- Añadir métricas y banderas de configuración.
- Mantener "cero mensajes en grupos" (solo reacciones).
Se prioriza minimizar migraciones y cambios de superficie, aprovechando infraestructura existente (ResponseQueue, GroupSync, Identity/Contacts, Help v2).
Estado actual relevante (resumen)
- El bot ya responde por DM a acciones de tareas y evita escribir en grupos (salvo reacciones).
- Listados de tareas: “ver grupo/mis/todas/sin”.
- Help v2 en src/services/messages/help.ts (copys).
- Envío de mensajes mediante ResponseQueue con metadata JSON (ya se usa para reacciones).
- GroupSync mantiene una cache y utilidades de membresía y snapshot frescas.
- No hay aún estado explícito de onboarding por usuario.
Principios
- Nunca escribimos mensajes en grupos (solo reacciones).
- Onboarding solo se dispara cuando se crea una tarea en un grupo (evento con “excusa” clara).
- Onboarding por usuario: máx. 2 paquetes (cada paquete = 2 DMs consecutivos con breve retraso), separados al menos 14 días y solo si no hubo interacción desde el primer paquete.
- Alias de comandos más cortos y claros para fomentar uso por DM.
---
## Fase 0 — Auditoría y decisiones de compatibilidad (Completada)
Objetivos
- Acordar defaults y compatibilidad de comandos.
- Confirmar feature flags y límites.
- Asegurar que no afectaremos flujos sensibles.
Archivos a consultar
- src/services/command.ts
- src/services/messages/help.ts
- src/utils/whatsapp.ts
- src/services/allowed-groups.ts
- tests relacionados con comandos de listado (no incluidos aquí)
Overview de cambios (sin código)
- Definir que en DM “/t ver” (sin argumentos) se comporte como “todas”.
- Mantener compatibilidad con “/t ayuda”, pero comunicar “/t info” como alias preferido.
- Aceptar “/t mias” y “/t todas” como atajos (alias de “ver mis” y “ver todas”).
- En contexto de grupo, cualquier intento de “ver …” no debe listar en el grupo; se responderá por DM (mensaje breve de transición).
- Flags/env de onboarding (ver Fase 4).
Criterios de aceptación
- Decisión documentada: “/t ver” en DM => “todas”.
- Alias permitidos y comunicados en help.
- Confirmación de “cero mensajes en grupo”.
---
## Fase 1 — Alias y routing de comandos (sin onboarding aún) (Completada)
Objetivos
- Añadir y mapear alias “info” → “ayuda”; “mias” → “ver mis”; “todas” → “ver todas”.
- Cambiar default de “/t ver” (en DM) a “todas”.
- En grupo, redirigir listados a DM con mensaje corto de transición (sin listar en el grupo).
Archivos a modificar
- src/services/command.ts
- src/services/messages/help.ts (copys actualizados con alias)
- src/utils/icons.ts y src/utils/formatting.ts (solo si se requiere algún símbolo/estilo nuevo)
Overview de cambios
- Extender ACTION_ALIASES y/o routing para nuevas acciones y scopes.
- Detectar contexto de grupo y bloquear listados en el grupo (enviar por DM un mensaje de transición: “No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web”).
- Help v2: mostrar “/t mias”, “/t todas”, “/t info”; retirar “ver grupo” de la guía básica y sugerir web para ver todo el grupo.
Impacto en tests
- Actualizar tests que esperen “/t ver” => “mis” en DM.
- Añadir tests de alias (“mias”, “todas”, “info”).
- Tests de transición desde grupo (no hay listados en el grupo; respuesta por DM).
---
## Fase 2 — Infra de Onboarding por DM en paquetes (2 DMs, migración mínima para interacción) (Completada)
Objetivos
- Enviar un paquete de 2 DMs (Mensaje 1 + Mensaje 2) por usuario cuando se crea una tarea en un grupo.
- Mensaje 1: CTA “/t tomar {CÓDIGO}” + “/t info”.
- Mensaje 2: minichuleta (“/t mias”, “/t todas”, “/t configurar …”, “/t web”), 510 s después del Mensaje 1.
- Repetir el mismo paquete una única vez más si pasan ≥ 14 días sin interacción del usuario (si hubo interacción, no se envía el segundo paquete).
- Cap por evento; sin mensajes en grupos.
Archivos a modificar
- src/services/group-sync.ts (añadir listActiveMemberIds(groupId): string[])
- src/services/response-queue.ts (añadir helpers para onboarding con metadata { kind='onboarding', variant, part, bundle_id } y soporte de retraso para el segundo DM del paquete; getOnboardingStats)
- src/services/command.ts (desencadenar el paquete tras crear tarea en grupo, respetando gating y caps; actualizar users.last_command_at al recibir cualquier comando)
- src/services/allowed-groups.ts (usado para gating en modo enforce)
- src/db/migrations/* (añadir columna users.last_command_at) y wiring en src/db/migrator.ts
- src/services/identity.ts y src/services/contacts.ts (solo consumo; no se cambian)
Overview de cambios
- GroupSyncService: nuevo helper para obtener IDs de miembros activos del grupo (solo dígitos, grupos activos, no comunidad/archivados).
- ResponseQueue:
- enqueueOnboarding(recipient, message, metadata) con metadata canónica: { kind: 'onboarding', variant: 'initial'|'reminder', part: 1|2, bundle_id, group_id, task_id, display_code }.
- getOnboardingStats(recipient): { total, lastSentAt, lastVariant?: 'initial'|'reminder' } consultando response_queue por metadata.kind='onboarding'.
- Soportar programar el segundo DM del paquete con un retraso aleatorio de 500010000ms.
- CommandService (en /t nueva):
- Tras crear la tarea en grupo, construir candidatos:
- miembros activos del grupo (GroupSync.listActiveMemberIds),
- excluir creador, asignados y el número del bot,
- filtrar por dígitos puros con longitud < 14,
- si GROUP_GATING_MODE=enforce y el grupo no está allowed → no enviar.
- Cap por evento (ONBOARDING_EVENT_CAP, p. ej. 30).
- Para cada destinatario:
- Si no hay paquetes previos → encolar paquete 'initial' con 2 DMs (part=1 ahora; part=2 con retraso).
- Si hay paquete 'initial' y han pasado ≥14 días SIN interacción (users.last_command_at ≤ primer paquete) → encolar paquete 'reminder' (2 DMs).
- En cualquier otro caso → no enviar.
- Envío del primer DM del paquete inmediato (next_attempt_at = now) y el segundo con pequeño retraso. Ventanas horarias opcionales (ver Fase 4).
Copys de onboarding (exactos)
- Mensaje 1 (en ambos disparos):
- “Hola, soy el bot de tareas. En {Grupo} acaban de crear una tarea: #{CÓDIGO} {descripción corta}
Encárgate: /t tomar {CÓDIGO} · Más info: /t info
Nota: nunca respondo en grupos; solo por privado.”
- Mensaje 2 (minichuleta; se envía tras 510 s, en ambos disparos):
- “Guía rápida (este es un mensaje único):
· Tus tareas: /t mias · Todas: /t todas
· Recordatorios: /t configurar diario | lv | semanal | off
· Web: /t web”
Impacto en tests
- Tests unitarios para:
- Construcción de destinatarios (exclusiones, cap).
- Paquetes: por disparo se encolan 2 DMs/usuario (part=1 y part=2 con retraso).
- Recordatorio a los ≥14 días solo si no hubo interacción desde el primer paquete; si la hubo, skip.
- Gating enforce (grupo no allowed → no enviar).
- Metadata de enqueue (kind='onboarding', variant initial|reminder, part 1|2, bundle_id).
Notas sobre migraciones
- Migración mínima recomendada: añadir users.last_command_at para registrar la última interacción del usuario y así decidir si enviar el segundo paquete tras ≥14 días. Actualizar este campo al procesar cualquier comando entrante.
- Si no se implementa, el recordatorio se puede degradar a “si han pasado ≥14 días desde el primer paquete”, sin comprobar interacción (menos preciso).
---
## Fase 3 — Ajustes de ayuda (Help v2) y refuerzos en DMs operativos ()
Objetivos
- Actualizar help rápido/completo con alias y nuevas recomendaciones.
- Añadir CTAs discretos al final de DMs operativos existentes (ack al crear y DM al asignado).
Archivos a modificar
- src/services/messages/help.ts
- src/services/command.ts (añadir línea discreta al final de acks y DMs al asignado)
Overview de cambios
- Help rápido:
- “Ver mis: /t mias (por privado)”
- “Ver todas: /t todas (por privado)”
- “Más info: /t info”
- Retirar “ver grupo” de la guía básica; sugerir web (“/t web”).
- Help completo: reflejar “mias/todas/info” y la política de “no responder en grupos”.
- CTAs discretos al final de DMs operativos:
- “Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web”
Impacto en tests
- Actualizar snapshots/expectativas del help y de los DMs.
---
## Fase 4 — Métricas y flags de configuración ()
Objetivos
- Medir adopción y salud del onboarding y de alias.
- Controlar comportamiento con variables de entorno.
Archivos a modificar
- src/services/command.ts (instrumentación Metrics.inc/set donde corresponda)
- src/services/response-queue.ts (contadores al encolar onboarding)
- src/services/group-sync.ts (counters si se desea, p. ej. “onboarding_skipped_not_allowed”)
- src/services/metrics.ts (no requiere cambios de API)
Métricas propuestas
- onboarding_dm_sent_total (labels: variant=initial|reminder, part=1|2, group_id)
- onboarding_bundle_sent_total (labels: variant=initial|reminder, group_id) — opcional
- onboarding_dm_skipped_total (labels: reason, group_id)
- reasons: 'cooldown_active', 'capped_event', 'not_allowed', 'disabled', 'no_members', 'no_group', 'not_group_context', 'no_display_code', 'had_interaction'
- onboarding_recipients_capped_total (labels: group_id)
- commands_alias_used_total (labels: action=info|mias|todas)
- commands_blocked_total (ya existe para gating; mantener)
Flags/env sugeridas
- ONBOARDING_DM_ENABLED=true
- ONBOARDING_DM_COOLDOWN_DAYS=14
- ONBOARDING_EVENT_CAP=30
- ONBOARDING_BUNDLE_DELAY_MS=4000
- ONBOARDING_ENABLE_IN_TEST=false (o true si se van a probar envíos en tests)
- GROUP_GATING_MODE=enforce|off (ya existente)
- Opcional (si se diferencia horario amable): ONBOARDING_SILENCE_HOURS=22-08 (futuro)
Overview de cambios
- Usar Metrics.inc/set en los puntos de decisión.
- Leer flags con defaults robustos y sin romper tests.
---
## Fase 5 — Tests y validación end-to-end ()
Objetivos
- Cubrir alias nuevos, cambios de routing, y lógica de onboarding.
- Garantizar “cero mensajes en grupo”.
Áreas de test
- Alias:
- “/t info” → ayuda
- “/t mias” → listado de asignadas
- “/t todas” → listado combinado (DM)
- “/t ver” (DM) → “todas”
- Contexto grupo:
- Invocar listados desde un grupo responde por DM con transición (no lista en el grupo).
- Onboarding:
- Por evento de creación en grupo se encolan 2 DMs/usuario (part=1 inmediato y part=2 con retraso).
- Tras ≥14 días sin interacción desde el primer paquete, se encola un segundo paquete idéntico; si hubo interacción, no se encola.
- Gating enforce: grupos no permitidos → no enviar.
- Metadata de response_queue correcta (kind, variant, part, bundle_id).
- Help v2 actualizado (snapshots).
- CTAs añadidos al final de DMs operativos.
---
## Fase 6 — Despliegue y control ()
Objetivos
- Desplegar con seguridad y capacidad de rollback.
- Observar métricas clave.
Checklist de despliegue
- Activar ONBOARDING_DM_ENABLED en staging, validar envíos y métricas.
- Validar alias y help actualizados.
- Monitorizar:
- onboarding_dm_sent_total vs skipped.
- Uso de “/t mias”, “/t todas”, “/t info”.
- web_tokens_issued_total (por “/t web”).
- Habilitar en producción. Ajustar ONBOARDING_EVENT_CAP si hay grupos muy grandes.
---
## Fase 7 — Consideraciones futuras (no bloqueantes) ()
Ideas a evaluar después
- “Bienvenida al primer DM inbound” (mensaje corto de bienvenida una única vez cuando el usuario inicia chat).
- Ventanas horarias: programar next_attempt_at si se detecta horario nocturno (requiere mínima lógica extra).
- Tabla explícita de onboarding (si se quiere persistir fuera de response_queue), p. ej. user_onboarding con timestamps y contadores.
- Resumen semanal optin (ya soportado con “/t configurar …”): medir retención y satisfacción.
---
## Resumen de archivos a cambiar (referencia)
- src/services/command.ts
- Alias y routing: “info”, “mias”, “todas”; “/t ver” en DM => “todas”.
- Mensaje de transición al detectar listados desde grupo (solo DM).
- Disparo de onboarding tras crear tarea en grupo (con caps y cooldown).
- CTAs discretos al final de acks y DMs al asignado.
- Instrumentación de métricas.
- src/services/messages/help.ts
- Actualizar copys a “/t mias”, “/t todas”, “/t info”.
- Retirar “ver grupo” del básico y empujar web para ver todo el grupo.
- src/services/group-sync.ts
- Añadir listActiveMemberIds(groupId): string[].
- src/services/response-queue.ts
- Añadir enqueueOnboarding() y getOnboardingStats().
- Incrementar métricas al encolar.
- src/services/allowed-groups.ts
- Solo consumo para gating (no cambios de API).
- src/utils/whatsapp.ts, src/utils/formatting.ts, src/utils/icons.ts
- Sin cambios estructurales; mantener estilos y símbolos.
- src/services/metrics.ts
- Reutilización. No requiere cambios, solo llamadas desde los puntos anteriores.
---
## Copys finales (para implementación)
1) Onboarding — Mensaje 1 (initial)
- “Hola, soy el bot de tareas. En {Grupo} acaban de crear una tarea: #{CÓDIGO} {descripción corta}
Encárgate: /t tomar {CÓDIGO} · Más info: /t info
Nota: nunca respondo en grupos; solo por privado.”
2) Onboarding — Mensaje 2 (reminder, único)
- “Guía rápida (este es un mensaje único):
· Tus tareas: /t mias · Todas: /t todas
· Recordatorios: /t configurar diario | lv | semanal | off
· Web: /t web”
3) Transición cuando se intenta listar desde grupo (responder por DM)
- “No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web”
4) Línea discreta al final de DMs operativos (ack creación y DM a asignados)
- “Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web”
---
## Riesgos y mitigaciones
- Saturación en grupos grandes → Cap por evento (ONBOARDING_EVENT_CAP, p. ej. 30) + cooldown 14 días por usuario.
- IDs no resueltos → Filtrar a /^\d+$/ y excluir alias no mapeados.
- Confusión por comandos antiguos → Alias “mias/todas/info” visibles en help; mensajes de transición desde grupo.
- Métricas y observabilidad → Añadir contadores con labels para entender adopción y fricción.
---

@ -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.

@ -1,205 +0,0 @@
# Plan de refactor de CommandService (enfoque A: handlers por acción)
Este documento describe un plan incremental y sin cambios de UX para refactorizar `src/services/command.ts` en módulos más pequeños y testeables, manteniendo `CommandService` como punto de entrada público (handle/handleWithOutcome) y preservando textos y métricas existentes.
## Objetivos
- Reducir tamaño y complejidad cognitiva de `command.ts`.
- Separar responsabilidades por comando y por utilidades comunes.
- Mantener compatibilidad de API, textos, límites y métricas (no romper tests ni dashboards).
- Mejorar testabilidad y facilidad de evolución.
## Principios
- Compatibilidad primero: no cambiar mensajes ni nombres de métricas.
- Refactor incremental: PRs pequeñas por etapa/handler.
- Dependencias hacia abajo: handlers dependen de servicios existentes (tasks, contacts, group-sync, etc.), nunca al revés.
- Flags centralizadas o inyectadas: evitar leer las mismas ENV en múltiples sitios.
- Sin reordenar líneas de salida salvo que sea necesario (p. ej., quitar la última línea en blanco, como ahora).
## Estructura objetivo
- src/services/commands/index.ts ← router/orquestador (sustituye a processTareaCommand)
- src/services/commands/shared.ts ← helpers comunes (alias, fechas, IDs, membresía, límites)
- src/services/commands/parsers/nueva.ts
- src/services/commands/handlers/
- ver.ts
- nueva.ts
- completar.ts
- tomar.ts
- soltar.ts
- configurar.ts
- web.ts
- src/services/onboarding.ts ← JIT y bundle de 2 DMs (disparado desde “nueva”)
- (opcional) src/services/web-access.ts ← gestión de tokens web (invalidate, generate, hash, URL)
Servicios existentes a reutilizar (no mover):
- TaskService, GroupSyncService, ContactsService, IdentityService, AllowedGroups, ResponseQueue, Metrics, utils/crypto, utils/formatting, utils/icons, utils/whatsapp, db.
## Etapas del refactor
Etapa 0 — Red de seguridad y decisiones
- Congelar mensajes de usuario y métricas actuales.
- Inventario de dependencias por comando (DB y servicios).
- Añadir/reforzar tests de integración por comando:
- Parseo multi-IDs (completar/tomar).
- Gating de grupo (enforce).
- TZ y vencidas en “ver”.
- Web tokens (invalida previos, expira 10 min).
- “nueva”: asignación por contexto, @yo, hoy/mañana, YY-MM-DD→20YY.
Etapa 1 — Router y shared
- Crear `commands/index.ts`:
- Resolver alias → acción canónica.
- Delegar por acción a handlers (temporalmente a un fallback que llama a la lógica actual).
- Mantener Help v1/v2 y CTA.
- Crear `commands/shared.ts`:
- ACTION_ALIASES y SCOPE_ALIASES.
- ymdInTZ(TZ), todayYMD.
- resolveTaskIdFromInput.
- parseMultipleIds(tokens, max=10) con truncado y dedup.
- enforceMembership(userId, task, flags) usando `GroupSyncService.isSnapshotFresh` y `GROUP_MEMBERS_ENFORCE`.
Etapa 2 — Parser de “nueva”
- Mover `parseNueva` a `parsers/nueva.ts`.
- Tests unitarios: hoy/mañana (con y sin acento), YYYY-MM-DD y YY-MM-DD→20YY, @yo, limpieza de puntuación, excluir @menciones de la descripción.
Etapa 3 — Handlers pequeños
- configurar.ts: upsert en `user_preferences`, validación `HH:MM`, etiquetas de respuesta.
- web.ts: invalidar tokens vigentes, generar token, hash (sha256), insertar y construir URL. Métrica `web_tokens_issued_total`.
- Conectar ambos en el router.
Etapa 4 — Handler de lectura “ver”
- Soportar scopes “mis” y “todas”, límite 10, DM vs grupo (transición a DM).
- Cálculo de vencidas según TZ (dd/MM con ⚠️ si vencida).
- Agrupar por grupo, “y X más”, nombres de asignados (ContactsService).
- Métricas: `commands_alias_used_total` (info/mias/todas), `ver_dm_transition_total`.
Etapa 5 — Handlers de mutación multi-ID
- completar.ts, tomar.ts, soltar.ts.
- Reutilizar parseMultipleIds, resolveTaskIdFromInput y enforceMembership.
- Mantener mensajes por caso (already/completed/not_found/bloqueadas) y resumen final, ayudas de uso en vacío.
Etapa 6 — Handler “nueva” y Onboarding
- nueva.ts:
- Normalización de menciones (@tokens y menciones del contexto), exclusión bot, IdentityService, plausibilidad, @yo.
- Asignación por contexto (grupo sin menciones → sin dueño; DM sin menciones → creador).
- Guardar `task_origins` cuando aplique. Actualizar `last_command_at`.
- Acks al creador y DMs a asignados (idéntico formato, menciones).
- onboarding.ts:
- JIT por menciones/tokens irrecuperables (flags: ONBOARDING_PROMPTS_ENABLED, ONBOARDING_ENABLE_IN_TEST).
- Bundle de 2 DMs con cap (`ONBOARDING_EVENT_CAP`), cooldown (`ONBOARDING_DM_COOLDOWN_DAYS`), gating AllowedGroups, delays (`ONBOARDING_BUNDLE_DELAY_MS`), ResponseQueue y métricas:
- onboarding_prompts_sent_total / skipped_total
- onboarding_bundle_sent_total
- onboarding_recipients_capped_total
- onboarding_dm_skipped_total
- Mantener `ResponseQueue.setOnboardingAggregatesMetrics()` tras comando.
Etapa 7 — Limpieza
- Reducir `CommandService` a:
- parseo de trigger (/t), registro de `last_command_at` y gating global inicial.
- delegación al router y clasificación de outcome (ok/error) como ahora.
- Centralizar CTA y textos estáticos compartidos si aplica.
- Opcional: centralizar flags en un `config.ts` liviano.
Etapa 8 — Hardening y observabilidad
- Revisar ciclos de import (handlers no deben importar CommandService).
- Confirmar puntos de métricas y logs condicionales (`NODE_ENV !== 'test'`).
- Tests de humo por handler con DB en memoria.
- Documentar contratos en `commands/README.md` (opcional).
## Detalles técnicos por handler
- configurar:
- Validar mapa de alias: diario/daily, lv/weekdays, semanal/weekly, off/apagar/ninguno.
- Hora opcional `HH:MM` (clamp 0023; minutos 0059).
- Upsert con `last_reminded_on` a NULL al cambiar frecuencia.
- web:
- Solo por DM (en grupo: mensaje instructivo).
- Requiere `WEB_BASE_URL`; si falta, advertir.
- Invalidar tokens vigentes (used_at = now) con `expires_at > now`.
- Generar token base64url (32 bytes), guardar hash SHA256, expira en 10 min.
- ver:
- “mis”: agrupar por `group_id` (nombre desde cache de grupos), due_date con `formatDDMM`, marcar vencidas según TZ local (no del host).
- “todas”: “Tus tareas” + “Sin responsable” por grupo (DM: usando snapshot fresca de membresía).
- En grupo: no listar; responder por DM (transición).
- completar/tomar/soltar:
- Multi-IDs: espacios y/o comas, dedup, máx. 10; resumen de resultados.
- enforceMembership si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresca disponible.
## Utilidades compartidas (shared.ts)
- ACTION_ALIASES y SCOPE_ALIASES (incluyendo: n/+/crear/nueva, ver/ls/listar/mostrar, x/hecho/completar/done, tomar/claim, soltar/unassign, ayuda/help/info/?, configurar/config, web).
- ymdInTZ(d, TZ) y todayYMD.
- formatters: reutilizar `formatDDMM`, `codeId`, `padTaskId`, `bold`, `italic`, `code`, `section`.
- parseMultipleIds(tokens: string[], max=10): retorna números válidos > 0, dedup, truncado.
- resolveTaskIdFromInput(n): `TaskService.getActiveTaskByDisplayCode`.
- enforceMembership(sender, task, flags): si `task.group_id` y snapshot fresca, bloquear si no es miembro activo.
## Flags/ENV a centralizar o respetar
- FEATURE_HELP_V2
- GROUP_GATING_MODE, GROUP_MEMBERS_ENFORCE
- TZ (por defecto: Europe/Madrid)
- WEB_BASE_URL, CHATBOT_PHONE_NUMBER
- ONBOARDING_PROMPTS_ENABLED, ONBOARDING_ENABLE_IN_TEST
- ONBOARDING_EVENT_CAP, ONBOARDING_DM_COOLDOWN_DAYS, ONBOARDING_BUNDLE_DELAY_MS
Sugerencia: leer una vez y pasar por contexto/env a los handlers.
## Métricas a preservar
- commands_alias_used_total (labels: action=info/mias/todas)
- ver_dm_transition_total
- web_tokens_issued_total
- commands_unknown_total
- commands_blocked_total
- onboarding_prompts_sent_total / onboarding_prompts_skipped_total
- onboarding_bundle_sent_total
- onboarding_recipients_capped_total
- onboarding_dm_skipped_total
- setOnboardingAggregatesMetrics() tras recibir comandos
## Riesgos y mitigaciones
- Cambios de texto rompen tests/UX: mantener textos y orden de líneas.
- Ciclos de import: mantener dependencias unidireccionales hacia servicios.
- Lectura repetida de ENV: centralizar o inyectar en contexto.
- Puntos de métricas: conservar nombres y ubicaciones lógicas.
## Criterios de aceptación por etapa
- Compila y pasa tests existentes.
- Salidas de texto idénticas (incluye saltos de línea).
- Mismas métricas y side effects (ResponseQueue).
- Para multi-IDs: mismo conteo y resumen.
- Para “ver”: mismas reglas de vencidas y agrupación.
- Para “web”: mismo comportamiento de invalidad y expiración de tokens.
## Estrategia de PRs
- 1 PR por etapa o por handler (máximo ~300500 LOC cambiadas).
- Cada PR:
- Mantiene API de `CommandService`.
- Sustituye una pieza con tests.
- Checklist (abajo).
- Sin reformat masivo no relacionado.
## Checklist rápida por PR
- [ ] Textos exactos (incluye emojis, mayúsculas, espacios).
- [ ] Métricas: nombres y contadores como antes.
- [ ] Flags: respetadas (valores por defecto idénticos).
- [ ] Lógica de límites (máx. 10 IDs, “y X más”, etc.).
- [ ] En grupo vs DM (transiciones e instrucciones).
- [ ] Onboarding: condiciones y métricas (si aplica).
- [ ] Sin ciclos de import; dependencias correctas.
- [ ] Tests de humo/integración cubren ruta feliz y errores principales.
## Notas de compatibilidad
- No cambiar el contrato público: `CommandService.handle` y `handleWithOutcome`.
- Mantener `CommandService.dbInstance` para DI mínima y compatibilidad con tests (inyectar en handlers cuando lo necesiten).
- Logs condicionales (`NODE_ENV !== 'test'`) como hoy.
Con este plan podrás avanzar en pasos pequeños, verificando en cada etapa que el comportamiento externo se mantiene mientras mejoras la mantenibilidad interna.

@ -453,56 +453,5 @@ 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);`);
}
},
{
version: 18,
name: 'task-origins-participant-fromme',
checksum: 'v18-task-origins-participant-fromme-2025-10-21',
up: (db: Database) => {
try {
const cols = db.query(`PRAGMA table_info(task_origins)`).all() as any[];
const hasParticipant = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'participant');
if (!hasParticipant) {
db.exec(`ALTER TABLE task_origins ADD COLUMN participant TEXT NULL;`);
}
const hasFromMe = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'from_me');
if (!hasFromMe) {
db.exec(`ALTER TABLE task_origins ADD COLUMN from_me INTEGER NULL;`);
}
} catch {}
}
},
{
version: 19,
name: 'users-last-command-at',
checksum: 'v19-users-last-command-at-2025-10-25',
up: (db: Database) => {
try {
const cols = db.query(`PRAGMA table_info(users)`).all() as any[];
const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'last_command_at');
if (!hasCol) {
db.exec(`ALTER TABLE users ADD COLUMN last_command_at TEXT NULL;`);
}
} catch {}
}
} }
]; ];

@ -209,14 +209,9 @@ export class WebhookServer {
console.log(' Handling groups upsert event:', { rawEvent: evt }); console.log(' Handling groups upsert event:', { rawEvent: evt });
} }
try { try {
const res = await GroupSyncService.syncGroups(); await GroupSyncService.syncGroups();
GroupSyncService.refreshActiveGroupsCache(); GroupSyncService.refreshActiveGroupsCache();
const changed = GroupSyncService.getLastChangedActive();
if (changed.length > 0) {
await GroupSyncService.syncMembersForGroups(changed);
} else {
await GroupSyncService.syncMembersForActiveGroups(); await GroupSyncService.syncMembersForActiveGroups();
}
} catch (e) { } catch (e) {
console.error('❌ Error handling groups.upsert:', e); console.error('❌ Error handling groups.upsert:', e);
} }
@ -440,14 +435,15 @@ export class WebhookServer {
return; return;
} }
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
console.log(' Group not active in cache — ensuring group (no immediate members sync)'); console.log(' Group not active in cache — ensuring group and triggering quick members sync');
} }
try { try {
GroupSyncService.ensureGroupExists(data.key.remoteJid); GroupSyncService.ensureGroupExists(data.key.remoteJid);
try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {} GroupSyncService.refreshActiveGroupsCache();
await GroupSyncService.syncMembersForGroup(data.key.remoteJid);
} catch (e) { } catch (e) {
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Failed to ensure group on-the-fly:', e); console.error('⚠️ Failed to ensure/sync group on-the-fly:', e);
} }
} }
} }
@ -481,60 +477,17 @@ 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 messageId = typeof data?.key?.id === 'string' ? data.key.id : null; const responses = await CommandService.handle({
const participantForKey = typeof data?.key?.participantAlt === 'string'
? data.key.participantAlt
: (typeof data?.key?.participant === 'string' ? data.key.participant : 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,
participant: participantForKey || undefined,
fromMe: !!data?.key?.fromMe
}); });
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 ? '🤖' : '⚠️';
const participant = typeof data?.key?.participantAlt === 'string'
? data.key.participantAlt
: (typeof data?.key?.participant === 'string' ? data.key.participant : undefined);
await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji, { participant, fromMe: !!data?.key?.fromMe });
} catch (e) {
// No romper el flujo por errores de reacción
if (process.env.NODE_ENV !== 'test') {
console.warn('⚠️ Reaction enqueue failed:', e);
}
}
} }
} }
@ -577,13 +530,6 @@ 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') {

File diff suppressed because it is too large Load Diff

@ -1,132 +0,0 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM } from '../../../utils/formatting';
import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleCompletar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
// Sin IDs: ayuda de uso
if (ids.length === 0) {
return [{
recipient: context.sender,
message: ' Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)'
}];
}
// Caso de 1 ID: mantener comportamiento actual
if (ids.length === 1) {
const idInput = ids[0];
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
}];
}
const task = TaskService.getTaskById(resolvedId);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (!enforceMembership(context.sender, task)) {
return [{
recipient: context.sender,
message: 'No puedes completar esta tarea porque no eres de este grupo.'
}];
}
const res = TaskService.completeTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (res.status === 'already') {
return [{
recipient: context.sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
return [{
recipient: context.sender,
message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
// Modo múltiple
let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0;
const lines: string[] = [];
if (truncated) {
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
}
for (const idInput of ids) {
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
cntNotFound++;
continue;
}
const task = TaskService.getTaskById(resolvedId);
if (task && !enforceMembership(context.sender, task)) {
lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
cntBlocked++;
continue;
}
const res = TaskService.completeTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'already') {
lines.push(` ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
cntAlready++;
} else if (res.status === 'updated') {
lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`);
cntUpdated++;
} else if (res.status === 'not_found') {
lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
cntNotFound++;
}
}
// Resumen final
const summary: string[] = [];
if (cntUpdated) summary.push(`completadas ${cntUpdated}`);
if (cntAlready) summary.push(`ya estaban ${cntAlready}`);
if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
if (summary.length) {
lines.push('');
lines.push(`Resumen: ${summary.join(', ')}.`);
}
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}

@ -1,87 +0,0 @@
import type { Database } from 'bun:sqlite';
import { ensureUserExists } from '../../../db';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] {
const tokens = (context.message || '').trim().split(/\s+/);
const optRaw = (tokens[2] || '').toLowerCase();
const map: Record<string, 'daily' | 'weekly' | 'off' | 'weekdays'> = {
'daily': 'daily',
'diario': 'daily',
'diaria': 'daily',
'l-v': 'weekdays',
'lv': 'weekdays',
'laborables': 'weekdays',
'weekdays': 'weekdays',
'semanal': 'weekly',
'weekly': 'weekly',
'off': 'off',
'apagar': 'off',
'ninguno': 'off'
};
const freq = map[optRaw];
// Hora opcional HH:MM
const timeRaw = tokens[3] || '';
let timeNorm: string | null = null;
if (timeRaw) {
const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw);
if (!m) {
return [{
recipient: context.sender,
message: ' Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}
const hh = Math.max(0, Math.min(23, parseInt(m[1], 10)));
timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`;
}
if (!freq) {
return [{
recipient: context.sender,
message: ' Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}
const ensured = ensureUserExists(context.sender, deps.db);
if (!ensured) {
throw new Error('No se pudo asegurar el usuario');
}
deps.db.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END,
updated_at = excluded.updated_at
`).run(ensured, freq, timeNorm, ensured, timeNorm);
let label: string;
if (freq === 'daily') {
label = timeNorm ? `diario (${timeNorm})` : 'diario';
} else if (freq === 'weekdays') {
label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)';
} else if (freq === 'weekly') {
label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)';
} else {
label = 'apagado';
}
return [{
recipient: context.sender,
message: `✅ Recordatorios: ${label}`
}];
}

@ -1,262 +0,0 @@
import type { Database } from 'bun:sqlite';
import { ensureUserExists } from '../../../db';
import { normalizeWhatsAppId, isGroupId } from '../../../utils/whatsapp';
import { TaskService } from '../../../tasks/service';
import { GroupSyncService } from '../../group-sync';
import { ContactsService } from '../../contacts';
import { IdentityService } from '../../identity';
import { Metrics } from '../../metrics';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM } from '../../../utils/formatting';
import { parseNueva } from '../parsers/nueva';
import { CTA_HELP } from '../shared';
import { buildJitAssigneePrompt, maybeEnqueueOnboardingBundle } from '../../onboarding';
type Ctx = {
sender: string;
groupId: string;
message: string;
mentions: string[];
messageId?: string;
participant?: string;
fromMe?: boolean;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
// Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles)
const MIN_FALLBACK_DIGITS = (() => {
const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim();
const n = parseInt(raw || '8', 10);
return Number.isFinite(n) && n > 0 ? n : 8;
})();
const MAX_FALLBACK_DIGITS = (() => {
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
const n = parseInt(raw || '15', 10);
return Number.isFinite(n) && n > 0 ? n : 15;
})();
type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
const isDigits = (s: string) => /^\d+$/.test(s);
const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
if (!s) return { ok: false, reason: 'invalid' };
if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
return { ok: true };
};
const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => {
try {
const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
} catch { }
};
// 1) Menciones aportadas por el backend (JIDs crudos)
const unresolvedAssigneeDisplays: string[] = [];
const mentionsNormalizedFromContext = Array.from(new Set(
(context.mentions || []).map((j) => {
const norm = normalizeWhatsAppId(j);
if (!norm) {
// agregar a no resolubles para JIT (mostrar sin @ ni dominio)
const raw = String(j || '');
const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
if (disp) unresolvedAssigneeDisplays.push(disp);
incOnboardingFailure('mentions', 'invalid');
return null;
}
const resolved = IdentityService.resolveAliasOrNull(norm);
if (resolved) return resolved;
// detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
const fromLid = dom.includes('lid');
const p = plausibility(norm, { fromLid });
if (p.ok) return norm;
// conservar para copy JIT
unresolvedAssigneeDisplays.push(norm);
incOnboardingFailure('mentions', p.reason!);
return null;
}).filter((id): id is string => !!id)
));
// 2) Tokens de texto que empiezan por '@' como posibles asignados
const atTokenCandidates = tokens.slice(2)
.filter(t => t.startsWith('@'))
.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
const normalizedFromAtTokens = Array.from(new Set(
atTokenCandidates.map((v) => {
// Token especial: '@yo' → autoasignación; no cuenta como fallo
if (String(v).toLowerCase() === 'yo') {
return null;
}
const norm = normalizeWhatsAppId(v);
if (!norm) {
// agregar a no resolubles para JIT (texto ya viene sin @/+)
if (v) unresolvedAssigneeDisplays.push(v);
incOnboardingFailure('tokens', 'invalid');
return null;
}
const resolved = IdentityService.resolveAliasOrNull(norm);
if (resolved) return resolved;
const p = plausibility(norm, { fromLid: false });
if (p.ok) return norm;
// conservar para copy JIT (preferimos el token limpio v)
unresolvedAssigneeDisplays.push(v);
incOnboardingFailure('tokens', p.reason!);
return null;
}).filter((id): id is string => !!id)
));
// 3) Unir y deduplicar
const combinedAssigneeCandidates = Array.from(new Set([
...mentionsNormalizedFromContext,
...normalizedFromAtTokens
]));
const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext);
// Asegurar creador
const createdBy = ensureUserExists(context.sender, deps.db);
if (!createdBy) {
throw new Error('No se pudo asegurar el usuario creador');
}
// Normalizar menciones y excluir duplicados y el número del bot
const botNumber = process.env.CHATBOT_PHONE_NUMBER || '';
const assigneesNormalized = Array.from(new Set(
[
...(selfAssign ? [context.sender] : []),
...combinedAssigneeCandidates
].filter(id => !botNumber || id !== botNumber)
));
// Asegurar usuarios asignados
const ensuredAssignees = assigneesNormalized
.map(id => ensureUserExists(id, deps.db))
.filter((id): id is string => !!id);
// Asignación por defecto según contexto:
// - En grupos: si no hay menciones → sin dueño (ningún asignado)
// - En DM: si no hay menciones → asignada al creador
let assignmentUserIds: string[] = [];
if (ensuredAssignees.length > 0) {
assignmentUserIds = ensuredAssignees;
} else {
assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy];
}
// Definir group_id solo si el grupo está activo
const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
? context.groupId
: null;
// Crear tarea y asignaciones
const taskId = TaskService.createTask(
{
description: description || '',
due_date: dueDate ?? null,
group_id: groupIdToUse,
created_by: createdBy,
},
assignmentUserIds.map(uid => ({
user_id: uid,
assigned_by: createdBy,
}))
);
// Registrar origen del comando para esta tarea (si aplica)
try {
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
const participant = typeof context.participant === 'string' ? context.participant : null;
const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null;
try {
deps.db.prepare(`
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
VALUES (?, ?, ?, ?, ?)
`).run(taskId, groupIdToUse, context.messageId, participant, fromMe);
} catch {
deps.db.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);
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
// Resolver nombres útiles
const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;
const assignedDisplayNames = await Promise.all(
assignmentUserIds.map(async uid => {
const name = await ContactsService.getDisplayName(uid);
return name || uid;
})
);
const responses: Msg[] = [];
// 1) Ack al creador con formato compacto
const dueFmt = formatDDMM(dueDate);
const ownerPart = assignmentUserIds.length === 0
? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}`
: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
const ackLines = [
`${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`,
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
ownerPart
].filter(Boolean);
responses.push({
recipient: createdBy,
message: [ackLines.join('\n'), '', CTA_HELP].join('\n'),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
for (const uid of assignmentUserIds) {
if (uid === createdBy) continue;
responses.push({
recipient: uid,
message: [
`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
`${description || '(sin descripción)'}`,
formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
groupName ? `Grupo: ${groupName}` : null,
`- Completar: \`/t x ${createdTask?.display_code}\``,
`- Soltar: \`/t soltar ${createdTask?.display_code}\``
].filter(Boolean).join('\n') + '\n\n' + CTA_HELP,
mentions: [`${createdBy}@s.whatsapp.net`]
});
}
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays));
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
try {
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
maybeEnqueueOnboardingBundle(deps.db, {
gid,
createdBy,
assignmentUserIds,
taskId,
displayCode: createdTask?.display_code ?? null,
description: description || ''
});
} catch {}
return responses;
}

@ -1,104 +0,0 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM, italic } from '../../../utils/formatting';
import { resolveTaskIdFromInput, enforceMembership } from '../shared';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleSoltar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const idToken = tokens[2];
const idInput = idToken ? parseInt(idToken, 10) : NaN;
if (!idInput || Number.isNaN(idInput)) {
return [{
recipient: context.sender,
message: ' Uso: `/t soltar 26`'
}];
}
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
}];
}
const task = TaskService.getTaskById(resolvedId);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (!enforceMembership(context.sender, task)) {
return [{
recipient: context.sender,
message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.'
}];
}
const res = TaskService.unassignTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'forbidden_personal') {
return [{
recipient: context.sender,
message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'
}];
}
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (res.status === 'completed') {
return [{
recipient: context.sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.status === 'not_assigned') {
return [{
recipient: context.sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.now_unassigned) {
const lines = [
`${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`,
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '',
italic('queda sin responsable.')
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}
const lines = [
`${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`,
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}

@ -1,148 +0,0 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM, italic } from '../../../utils/formatting';
import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleTomar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
// Sin IDs: ayuda de uso
if (ids.length === 0) {
return [{
recipient: context.sender,
message: ' Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)'
}];
}
// Caso de 1 ID: mantener comportamiento actual
if (ids.length === 1) {
const idInput = ids[0];
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
}];
}
const task = TaskService.getTaskById(resolvedId);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (!enforceMembership(context.sender, task)) {
return [{
recipient: context.sender,
message: 'No puedes tomar esta tarea porque no eres de este grupo.'
}];
}
const res = TaskService.claimTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
if (res.status === 'completed') {
return [{
recipient: context.sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
if (res.status === 'already') {
return [{
recipient: context.sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`
}];
}
const lines = [
italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`),
`${res.task?.description || '(sin descripción)'}`,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''
].filter(Boolean);
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}
// Modo múltiple
let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0;
const lines: string[] = [];
if (truncated) {
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
}
for (const idInput of ids) {
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
lines.push(`⚠️ ${codeId(idInput)} no encontrada.`);
cntNotFound++;
continue;
}
const task = TaskService.getTaskById(resolvedId);
if (task && !enforceMembership(context.sender, task)) {
lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`);
cntBlocked++;
continue;
}
const res = TaskService.claimTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
if (res.status === 'already') {
lines.push(` ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`);
cntAlready++;
} else if (res.status === 'claimed') {
lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`);
cntClaimed++;
} else if (res.status === 'completed') {
lines.push(` ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`);
cntCompleted++;
} else if (res.status === 'not_found') {
lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`);
cntNotFound++;
}
}
// Resumen final
const summary: string[] = [];
if (cntClaimed) summary.push(`tomadas ${cntClaimed}`);
if (cntAlready) summary.push(`ya las tenías ${cntAlready}`);
if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`);
if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`);
if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`);
if (summary.length) {
lines.push('');
lines.push(`Resumen: ${summary.join(', ')}.`);
}
return [{
recipient: context.sender,
message: lines.join('\n')
}];
}

@ -1,179 +0,0 @@
import { TaskService } from '../../../tasks/service';
import { GroupSyncService } from '../../group-sync';
import { ContactsService } from '../../contacts';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM, bold, italic } from '../../../utils/formatting';
import { SCOPE_ALIASES, todayYMD } from '../shared';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleVer(context: Ctx): Promise<Msg[]> {
const trimmed = (context.message || '').trim();
const tokens = trimmed.split(/\s+/);
const rawAction = (tokens[1] || '').toLowerCase();
const scopeRaw = (tokens[2] || '').toLowerCase();
const scope = scopeRaw
? (SCOPE_ALIASES[scopeRaw] || scopeRaw)
: ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos'));
const LIMIT = 10;
const today = todayYMD();
if (scope === 'todos') {
const sections: string[] = [];
// Encabezado fijo para la sección de tareas del usuario
sections.push(bold('Tus tareas'));
// Tus tareas (mis)
const myItems = TaskService.listUserPending(context.sender, LIMIT);
if (myItems.length > 0) {
// Agrupar por grupo como en "ver mis"
const byGroup = new Map<string, typeof myItems>();
for (const t of myItems) {
const key = t.group_id || '(sin grupo)';
const arr = byGroup.get(key) || [];
arr.push(t);
byGroup.set(key, arr);
}
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
sections.push(groupName);
const rendered = await Promise.all(arr.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned} sin responsable`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < today : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart}${owner}`;
}));
sections.push(...rendered);
sections.push('');
}
// Quitar línea en blanco final si procede
if (sections.length > 0 && sections[sections.length - 1] === '') {
sections.pop();
}
const totalMy = TaskService.countUserPending(context.sender);
if (totalMy > myItems.length) {
sections.push(`… y ${totalMy - myItems.length} más`);
}
} else {
sections.push(italic('_No tienes tareas pendientes._'));
}
// En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender);
if (memberGroups.length > 0) {
const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT);
for (const gid of perGroup.keys()) {
const unassigned = perGroup.get(gid)!;
const groupName =
(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
gid;
if (unassigned.length > 0) {
if (sections.length && sections[sections.length - 1] !== '') sections.push('');
sections.push(`${groupName} — Sin responsable`);
const renderedUnassigned = unassigned.map((t) => {
const isOverdue = t.due_date ? t.due_date < today : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart}${ICONS.unassigned}`;
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(gid);
if (totalUnassigned > unassigned.length) {
sections.push(`… y ${totalUnassigned - unassigned.length} más`);
}
}
}
} else {
// Si no hay snapshot fresca de membresía, nota instructiva
sections.push(' Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.');
}
return [{
recipient: context.sender,
message: sections.join('\n')
}];
}
// Ver mis
const items = TaskService.listUserPending(context.sender, LIMIT);
if (items.length === 0) {
return [{
recipient: context.sender,
message: italic('No tienes tareas pendientes.')
}];
}
const total = TaskService.countUserPending(context.sender);
// Agrupar por grupo
const byGroup = new Map<string, typeof items>();
for (const t of items) {
const key = t.group_id || '(sin grupo)';
const arr = byGroup.get(key) || [];
arr.push(t);
byGroup.set(key, arr);
}
const sections: string[] = [bold('Tus tareas')];
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
sections.push(groupName);
const rendered = await Promise.all(arr.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid)
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned}`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
const isOverdue = t.due_date ? t.due_date < today : false;
const datePart = t.due_date ? `${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : '';
return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart}${owner}`;
}));
sections.push(...rendered);
sections.push('');
}
// Quitar línea en blanco final si procede
if (sections.length > 0 && sections[sections.length - 1] === '') {
sections.pop();
}
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: sections.join('\n')
}];
}

@ -1,71 +0,0 @@
import type { Database } from 'bun:sqlite';
import { ensureUserExists } from '../../../db';
import { isGroupId } from '../../../utils/whatsapp';
import { randomTokenBase64Url, sha256Hex } from '../../../utils/crypto';
import { Metrics } from '../../metrics';
type Ctx = {
sender: string;
groupId: string;
message: string;
};
type Msg = {
recipient: string;
message: string;
mentions?: string[];
};
export async function handleWeb(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
// Solo por DM
if (isGroupId(context.groupId)) {
return [{
recipient: context.sender,
message: ' Este comando se usa por privado. Envíame `/t web` por DM.'
}];
}
const base = (process.env.WEB_BASE_URL || '').trim();
if (!base) {
return [{
recipient: context.sender,
message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).'
}];
}
const ensured = ensureUserExists(context.sender, deps.db);
if (!ensured) {
throw new Error('No se pudo asegurar el usuario');
}
const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '');
const now = new Date();
const nowIso = toIso(now);
const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos
// Invalidar tokens vigentes (uso único)
deps.db.prepare(`
UPDATE web_tokens
SET used_at = ?
WHERE user_id = ?
AND used_at IS NULL
AND expires_at > ?
`).run(nowIso, ensured, nowIso);
// Generar nuevo token y guardar solo el hash
const token = randomTokenBase64Url(32);
const tokenHash = await sha256Hex(token);
deps.db.prepare(`
INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata)
VALUES (?, ?, ?, NULL)
`).run(ensured, tokenHash, expiresIso);
try { Metrics.inc('web_tokens_issued_total'); } catch { }
const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString();
return [{
recipient: context.sender,
message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".`
}];
}

@ -1,166 +0,0 @@
/**
* Router de comandos (Etapa 3)
* Maneja 'configurar' y 'web', y delega el resto al código actual (null fallback).
* Nota: No importar CommandService aquí para evitar ciclos de import.
*/
import type { Database } from 'bun:sqlite';
import { ACTION_ALIASES } from './shared';
import { handleConfigurar } from './handlers/configurar';
import { handleWeb } from './handlers/web';
import { handleVer } from './handlers/ver';
import { handleCompletar } from './handlers/completar';
import { handleTomar } from './handlers/tomar';
import { handleSoltar } from './handlers/soltar';
import { handleNueva } from './handlers/nueva';
import { ResponseQueue } from '../response-queue';
import { isGroupId } from '../../utils/whatsapp';
import { Metrics } from '../metrics';
function getQuickHelp(): string {
return [
'Guía rápida:',
'- Ver tus tareas: `/t mias`',
'- Ver todas: `/t todas`',
'- Crear: `/t n Descripción 2028-11-26 @Ana`',
'- Completar: `/t x 123`',
'- Tomar: `/t tomar 12`',
'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
'- Web: `/t web`'
].join('\n');
}
function getFullHelp(): string {
return [
'Ayuda avanzada:',
'Comandos y alias:',
' · Crear: `n`, `nueva`, `crear`, `+`',
' · Ver: `ver`, `listar`, `mostrar`, `ls` (scopes: `mis` | `todas`)',
' · Completar: `x`, `hecho`, `completar`, `done`',
' · Tomar: `tomar`, `claim`',
' · Soltar: `soltar`, `unassign`',
'Preferencias:',
' · `/t configurar diario|l-v|semanal|off [HH:MM]`',
'Fechas:',
' · `YYYY-MM-DD` o `YY-MM-DD` → `20YY-MM-DD` (ej.: 27-09-04)',
' · Palabras: `hoy`, `mañana`',
'Acceso web:',
' · `/t web`',
'Atajos:',
' · `/t mias`',
' · `/t todas`'
].join('\n');
}
function buildUnknownHelp(): string {
const header = '❓ COMANDO NO RECONOCIDO';
const cta = 'Prueba `/t ayuda`';
return [header, cta, '', getQuickHelp()].join('\n');
}
export type RoutedMessage = {
recipient: string;
message: string;
mentions?: string[];
};
export type RouteContext = {
sender: string;
groupId: string;
message: string;
mentions: string[];
messageId?: string;
participant?: string;
fromMe?: boolean;
};
export async function route(context: RouteContext, deps?: { db: Database }): Promise<RoutedMessage[] | null> {
const trimmed = (context.message || '').trim();
const tokens = trimmed.split(/\s+/);
const rawAction = (tokens[1] || '').toLowerCase();
const action = ACTION_ALIASES[rawAction] || rawAction;
// Ayuda (no requiere DB)
if (action === 'ayuda') {
// Métrica de alias "info" (compatibilidad con legacy)
try {
if (rawAction === 'info' || rawAction === '?') {
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
}
} catch {}
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
const message = isAdvanced
? getFullHelp()
: [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n');
return [{
recipient: context.sender,
message
}];
}
// Requiere db inyectada para poder operar (CommandService la inyecta)
const database = deps?.db;
if (!database) return null;
if (action === 'nueva') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleNueva(context as any, { db: database });
}
if (action === 'ver') {
// Métricas de alias (mias/todas) como en el código actual
try {
if (rawAction === 'mias' || rawAction === 'mías') {
Metrics.inc('commands_alias_used_total', 1, { action: 'mias' });
} else if (rawAction === 'todas' || rawAction === 'todos') {
Metrics.inc('commands_alias_used_total', 1, { action: 'todas' });
}
} catch {}
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
// En grupo: transición a DM
if (isGroupId(context.groupId)) {
try { Metrics.inc('ver_dm_transition_total'); } catch {}
return [{
recipient: context.sender,
message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
}];
}
return await handleVer(context as any);
}
if (action === 'completar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleCompletar(context as any);
}
if (action === 'tomar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleTomar(context as any);
}
if (action === 'soltar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleSoltar(context as any);
}
if (action === 'configurar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return handleConfigurar(context as any, { db: database });
}
if (action === 'web') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleWeb(context as any, { db: database });
}
// Desconocido → ayuda rápida
try { Metrics.inc('commands_unknown_total'); } catch {}
return [{
recipient: context.sender,
message: buildUnknownHelp()
}];
}

@ -1,128 +0,0 @@
export function parseNueva(message: string, _mentionsNormalized: string[]): {
action: string;
description: string;
dueDate: string | null;
selfAssign: boolean;
} {
const parts = (message || '').trim().split(/\s+/);
const action = (parts[1] || '').toLowerCase();
// Zona horaria configurable (por defecto Europe/Madrid)
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
// Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host
const ymdFromDateInTZ = (d: Date): string => {
const fmt = new Intl.DateTimeFormat('es-ES', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(d);
const get = (t: string) => fmt.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')}`;
};
const addDaysToYMD = (ymd: string, days: number): string => {
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
base.setUTCDate(base.getUTCDate() + days);
return ymdFromDateInTZ(base);
};
const todayYMD = ymdFromDateInTZ(new Date());
// Helpers para validar y normalizar fechas explícitas
const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
const daysInMonth = (y: number, m: number) => {
if (m === 2) return isLeap(y) ? 29 : 28;
return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
};
const isValidYMD = (ymd: string): boolean => {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) return false;
const Y = parseInt(m[1], 10);
const MM = parseInt(m[2], 10);
const DD = parseInt(m[3], 10);
if (MM < 1 || MM > 12) return false;
const dim = daysInMonth(Y, MM);
if (!dim || DD < 1 || DD > dim) return false;
return true;
};
const normalizeDateToken = (t: string): string | null => {
// YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) {
return isValidYMD(t) ? t : null;
}
// YY-MM-DD -> 20YY-MM-DD
const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t);
if (m) {
const yy = parseInt(m[1], 10);
const mm = m[2];
const dd = m[3];
const yyyy = 2000 + yy;
const ymd = `${String(yyyy)}-${mm}-${dd}`;
return isValidYMD(ymd) ? ymd : null;
}
return null;
};
type DateCandidate = { index: number; ymd: string };
const dateCandidates: DateCandidate[] = [];
const dateTokenIndexes = new Set<number>();
const selfTokenIndexes = new Set<number>();
let selfAssign = false;
for (let i = 2; i < parts.length; i++) {
// Normalizar token: minúsculas y sin puntuación adyacente simple
const raw = parts[i];
const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, '');
// Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY)
{
const norm = normalizeDateToken(low);
if (norm && norm >= todayYMD) {
dateCandidates.push({ index: i, ymd: norm });
dateTokenIndexes.add(i);
continue;
}
}
// Tokens naturales "hoy"/"mañana" (con o sin acento)
if (low === 'hoy') {
dateCandidates.push({ index: i, ymd: todayYMD });
dateTokenIndexes.add(i);
continue;
}
if (low === 'mañana' || low === 'manana') {
dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) });
dateTokenIndexes.add(i);
continue;
}
// Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple)
if (low === 'yo' || low === '@yo') {
selfAssign = true;
selfTokenIndexes.add(i);
continue;
}
}
const dueDate = dateCandidates.length > 0
? dateCandidates[dateCandidates.length - 1].ymd
: null;
const isMentionToken = (token: string) => token.startsWith('@');
const descriptionTokens: string[] = [];
for (let i = 2; i < parts.length; i++) {
if (dateTokenIndexes.has(i)) continue;
if (selfTokenIndexes.has(i)) continue;
const token = parts[i];
if (isMentionToken(token)) continue;
descriptionTokens.push(token);
}
const description = descriptionTokens.join(' ').trim();
return { action, description, dueDate, selfAssign };
}

@ -1,125 +0,0 @@
/**
* Utilidades compartidas para handlers de comandos (Etapa 1).
* Aún no se usan desde CommandService; servirán en etapas siguientes.
*/
import { TaskService } from '../../tasks/service';
import { GroupSyncService } from '../group-sync';
export const ACTION_ALIASES: Record<string, string> = {
'n': 'nueva',
'nueva': 'nueva',
'crear': 'nueva',
'+': 'nueva',
'ver': 'ver',
'mostrar': 'ver',
'listar': 'ver',
'ls': 'ver',
'mias': 'ver',
'mías': 'ver',
'todas': 'ver',
'todos': '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',
'info': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar',
'web': 'web'
};
export const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
'todo': 'todos',
'todos': 'todos',
'todas': 'todos',
'mis': 'mis',
'mias': 'mis',
'mías': 'mis',
'yo': 'mis'
};
export const CTA_HELP = ' Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`';
/**
* Formatea un Date a YYYY-MM-DD respetando TZ (por defecto Europe/Madrid).
*/
export function ymdInTZ(d: Date, tz?: string): string {
const TZ = (tz && tz.trim()) || (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid';
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function todayYMD(tz?: string): string {
return ymdInTZ(new Date(), tz);
}
/**
* Parsea múltiples IDs desde tokens, deduplica, y aplica límite.
*/
export function parseMultipleIds(tokens: string[], max: number = 10): { ids: number[]; truncated: boolean } {
const raw = (tokens || []).join(' ').trim();
const all = raw
? raw
.split(/[,\s]+/)
.map(t => t.trim())
.filter(Boolean)
.map(t => parseInt(t, 10))
.filter(n => Number.isFinite(n) && n > 0)
: [];
const dedup: number[] = [];
const seen = new Set<number>();
for (const n of all) {
if (!seen.has(n)) {
seen.add(n);
dedup.push(n);
}
}
const truncated = dedup.length > max;
const ids = dedup.slice(0, max);
return { ids, truncated };
}
/**
* Resuelve un ID de entrada (display_code) a task.id si está activa.
*/
export function resolveTaskIdFromInput(n: number): number | null {
const byCode = TaskService.getActiveTaskByDisplayCode(n);
return byCode ? byCode.id : null;
}
/**
* Aplica la política de membresía para acciones sobre una tarea.
* Devuelve true si el usuario está permitido según flags/env.
*/
export function enforceMembership(sender: string, task: { group_id?: string | null }, enforceFlag?: boolean): boolean {
const enforce =
typeof enforceFlag === 'boolean'
? enforceFlag
: String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
const gid = task?.group_id || null;
if (!gid) return true; // tareas personales no requieren membresía
if (!enforce) return true;
if (!GroupSyncService.isSnapshotFresh(gid)) return true;
return GroupSyncService.isUserActiveInGroup(sender, gid);
}

@ -74,8 +74,6 @@ export class GroupSyncService {
private static _membersSchedulerRunning = false; private static _membersSchedulerRunning = false;
private static _groupsIntervalMs: number | null = null; private static _groupsIntervalMs: number | null = null;
private static _groupsNextTickAt: number | null = null; private static _groupsNextTickAt: number | null = null;
private static _membersGlobalCooldownUntil: number = 0;
private static _lastChangedActive: string[] = [];
static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> { static async syncGroups(force: boolean = false): Promise<{ added: number; updated: number }> {
if (!this.shouldSync(force)) { if (!this.shouldSync(force)) {
@ -84,7 +82,6 @@ export class GroupSyncService {
const startedAt = Date.now(); const startedAt = Date.now();
Metrics.inc('sync_runs_total'); Metrics.inc('sync_runs_total');
let newlyActivatedIds: string[] = [];
try { try {
const groups = await this.fetchGroupsFromAPI(); const groups = await this.fetchGroupsFromAPI();
console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2)); console.log(' Grupos crudos de la API:', JSON.stringify(groups, null, 2));
@ -109,17 +106,6 @@ export class GroupSyncService {
afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null }); afterMap.set(String(r.id), { active: Number(r.active || 0), archived: Number(r.archived || 0), is_community: Number((r as any).is_community || 0), name: r.name ? String(r.name) : null });
} }
// Determinar grupos que pasaron a estar activos (nuevos o reactivados)
const newlyActivatedLocal: string[] = [];
for (const [id, a] of afterMap.entries()) {
const b = beforeMap.get(id);
const becameActive = Number(a.active) === 1 && Number(a.archived) === 0 && Number((a as any).is_community || 0) === 0;
if (becameActive && (!b || Number(b.active) !== 1)) {
newlyActivatedLocal.push(id);
}
}
newlyActivatedIds = newlyActivatedLocal;
const newlyDeactivated: Array<{ id: string; name: string | null }> = []; const newlyDeactivated: Array<{ id: string; name: string | null }> = [];
for (const [id, b] of beforeMap.entries()) { for (const [id, b] of beforeMap.entries()) {
const a = afterMap.get(id); const a = afterMap.get(id);
@ -190,8 +176,6 @@ export class GroupSyncService {
// Duración opcional // Duración opcional
Metrics.set('last_sync_duration_ms', Date.now() - (typeof startedAt !== 'undefined' ? startedAt : Date.now())); Metrics.set('last_sync_duration_ms', Date.now() - (typeof startedAt !== 'undefined' ? startedAt : Date.now()));
// Guardar lista de grupos que han pasado a activos para consumo externo
this._lastChangedActive = Array.isArray(newlyActivatedIds) ? newlyActivatedIds : [];
return result; return result;
} catch (error) { } catch (error) {
console.error('Group sync failed:', error); console.error('Group sync failed:', error);
@ -501,13 +485,6 @@ export class GroupSyncService {
// Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes. // Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes.
private static async fetchGroupMembersFromAPI(groupId: string): Promise<Array<{ userId: string; isAdmin: boolean }>> { private static async fetchGroupMembersFromAPI(groupId: string): Promise<Array<{ userId: string; isAdmin: boolean }>> {
// Cooldown global por rate limit 429 (evitar ráfagas)
try {
if (this._membersGlobalCooldownUntil && Date.now() < this._membersGlobalCooldownUntil) {
console.warn('⚠️ Skipping members fetch due to global cooldown');
return [];
}
} catch {}
// En tests se recomienda simular fetch; no retornamos temprano para permitir validar el parser // En tests se recomienda simular fetch; no retornamos temprano para permitir validar el parser
// 1) Intento preferente: endpoint de Evolution "Find Group Members" // 1) Intento preferente: endpoint de Evolution "Find Group Members"
@ -583,11 +560,6 @@ export class GroupSyncService {
console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups'); console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups');
} else { } else {
const body = await r1.text().catch(() => ''); const body = await r1.text().catch(() => '');
if (r1.status === 429) {
console.warn(`⚠️ /group/participants rate-limited (429): ${body.slice(0, 200)}`);
this._membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
console.warn(`⚠️ /group/participants failed: ${r1.status} ${r1.statusText} - ${body.slice(0, 200)}. Falling back to fetchAllGroups`); console.warn(`⚠️ /group/participants failed: ${r1.status} ${r1.statusText} - ${body.slice(0, 200)}. Falling back to fetchAllGroups`);
} }
} catch (e) { } catch (e) {
@ -606,11 +578,6 @@ export class GroupSyncService {
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ''); const body = await response.text().catch(() => '');
if (response.status === 429) {
console.warn(`⚠️ fetchAllGroups(getParticipants=true) rate-limited (429): ${body.slice(0, 200)}`);
this._membersGlobalCooldownUntil = Date.now() + 2 * 60 * 1000;
return [];
}
throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`); throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`);
} }
const raw = await response.text(); const raw = await response.text();
@ -683,22 +650,6 @@ export class GroupSyncService {
return resolved; return resolved;
} }
/**
* Upsert optimista de la membresía a partir de un mensaje recibido en el grupo.
* Marca al usuario como activo y actualiza last_seen_at sin consultar Evolution API.
*/
static upsertMemberSeen(groupId: string, userId: string, nowIso?: string): void {
if (!groupId || !userId) return;
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', '');
this.dbInstance.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES (?, ?, 0, 1, ?, ?)
ON CONFLICT(group_id, user_id) DO UPDATE SET
is_active = 1,
last_seen_at = excluded.last_seen_at
`).run(groupId, userId, now, now);
}
/** /**
* Reconciles current DB membership state for a group with a fresh snapshot. * Reconciles current DB membership state for a group with a fresh snapshot.
* Idempotente y atómico por grupo. * Idempotente y atómico por grupo.
@ -970,57 +921,10 @@ export class GroupSyncService {
return { groups, added, updated, deactivated }; return { groups, added, updated, deactivated };
} }
static async syncMembersForGroups(ids: string[]): Promise<{ groups: number; added: number; updated: number; deactivated: number }> {
if (process.env.NODE_ENV === 'test') {
return { groups: 0, added: 0, updated: 0, deactivated: 0 };
}
if (!Array.isArray(ids) || ids.length === 0) {
return { groups: 0, added: 0, updated: 0, deactivated: 0 };
}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
const enforce = mode === 'enforce';
if (enforce) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
}
let groups = 0, added = 0, updated = 0, deactivated = 0;
for (const groupId of ids) {
try {
if (enforce) {
try {
if (!AllowedGroups.isAllowed(groupId)) {
try { Metrics.inc('sync_skipped_group_total'); } catch {}
continue;
}
} catch {}
}
const snapshot = await this.fetchGroupMembersFromAPI(groupId);
const res = this.reconcileGroupMembers(groupId, snapshot);
groups++;
added += res.added;
updated += res.updated;
deactivated += res.deactivated;
} catch (e) {
console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e));
}
}
console.log(' Targeted members sync summary:', { groups, added, updated, deactivated });
return { groups, added, updated, deactivated };
}
public static refreshActiveGroupsCache(): void { public static refreshActiveGroupsCache(): void {
this.cacheActiveGroups(); this.cacheActiveGroups();
} }
public static getLastChangedActive(): string[] {
try {
return Array.from(this._lastChangedActive || []);
} catch {
return [];
}
}
public static startGroupsScheduler(): void { public static startGroupsScheduler(): void {
if (process.env.NODE_ENV === 'test') return; if (process.env.NODE_ENV === 'test') return;
if (this._groupsSchedulerRunning) return; if (this._groupsSchedulerRunning) return;
@ -1279,29 +1183,4 @@ export class GroupSyncService {
return { added: 0, updated: 0, deactivated: 0 }; return { added: 0, updated: 0, deactivated: 0 };
} }
} }
/**
* Devuelve los IDs de usuario activos del grupo, filtrados a dígitos puros con longitud < 14.
* No devuelve duplicados.
*/
public static listActiveMemberIds(groupId: string): string[] {
if (!groupId) return [];
try {
const rows = this.dbInstance.prepare(`
SELECT user_id
FROM group_members
WHERE group_id = ? AND is_active = 1
`).all(groupId) as Array<{ user_id: string }>;
const out = new Set<string>();
for (const r of rows) {
const uid = String(r.user_id || '').trim();
if (/^\d+$/.test(uid) && uid.length < 14) {
out.add(uid);
}
}
return Array.from(out);
} catch {
return [];
}
}
} }

@ -1,5 +1,5 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db'; import { db } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp'; import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
@ -16,8 +16,6 @@ export class IdentityService {
const a = normalizeWhatsAppId(alias || ''); const a = normalizeWhatsAppId(alias || '');
const u = normalizeWhatsAppId(userId || ''); const u = normalizeWhatsAppId(userId || '');
if (!a || !u || a === u) return false; if (!a || !u || a === u) return false;
// Asegurar que el user_id numérico exista para no violar la FK (user_aliases.user_id -> users.id)
try { ensureUserExists(u, this.dbInstance); } catch {}
try { try {
this.dbInstance.prepare(` this.dbInstance.prepare(`
INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at) INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at)
@ -27,14 +25,8 @@ export class IdentityService {
source = COALESCE(excluded.source, source), source = COALESCE(excluded.source, source),
updated_at = excluded.updated_at updated_at = excluded.updated_at
`).run(a, u, source ?? null); `).run(a, u, source ?? null);
} catch (e) { } catch {
// Si la tabla no existe o hay error de DB, continuamos con la caché en memoria // Si la tabla no existe o hay error de DB, continuamos con la caché en memoria
try { Metrics.inc('identity_alias_upsert_errors_total'); } catch {}
try {
if (String(process.env.NODE_ENV || '').toLowerCase() !== 'test') {
console.warn('[IdentityService.upsertAlias] Falló persistir alias en DB (se mantiene en caché en memoria):', e);
}
} catch {}
} }
// Actualizar siempre la caché en memoria para disponibilizar el alias inmediatamente // Actualizar siempre la caché en memoria para disponibilizar el alias inmediatamente
this.inMemoryAliases.set(a, u); this.inMemoryAliases.set(a, u);

@ -10,20 +10,16 @@ export function getQuickHelp(baseUrl?: string): string {
parts.push(section('Comandos básicos')); parts.push(section('Comandos básicos'));
parts.push( parts.push(
bullets([ bullets([
`Crear: ${code('/t n Descripción 27-11-14 @Ana')}`, `${code('/t n ...')} crear (acepta fecha y menciones)`,
`Ver mis: ${code('/t mias')} _por privado_`, `${code('/t ver mis')} por DM · ${code('/t ver todos')}`,
`Ver todas: ${code('/t todas')} _por privado_`, `${code('/t x 26')} completar (máx. 10) · ${code('/t tomar 12')} · ${code('/t soltar 26')}`,
`Más info: ${code('/t info')}`, `${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
`Completar: ${code('/t x 26')} _(máx. 10 a la vez)_`, `${code('/t web')}`,
`Tomar: ${code('/t tomar 12')} _(máx. 10 a la vez)_`,
`Soltar: ${code('/t soltar 26')} _(máx. 10 a la vez)_`,
`Recordatorios: ${code('/t configurar diario|l-v|semanal|off [HH:MM]')} _por privado_`,
`Versión web: ${code('/t web')}`,
]) ])
); );
parts.push( parts.push(
italic('El bot responde por privado, incluso si escribes desde un grupo.') italic('El bot responde por DM, incluso si escribes desde un grupo.')
); );
return parts.join('\n'); return parts.join('\n');
@ -37,7 +33,7 @@ export function getFullHelp(baseUrl?: string): string {
out.push( out.push(
bullets([ bullets([
`${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`, `${code('/t n Descripción [YYYY-MM-DD|YY-MM-DD|hoy|mañana] [@menciones...]')}`,
'En privado: sin menciones → asignada a quien la crea.', 'En DM: sin menciones → asignada a quien la crea.',
'En grupo: sin menciones → queda “sin responsable”.', 'En grupo: sin menciones → queda “sin responsable”.',
'Fechas: usa la última válida encontrada; no acepta pasadas.', 'Fechas: usa la última válida encontrada; no acepta pasadas.',
]) ])
@ -48,9 +44,12 @@ export function getFullHelp(baseUrl?: string): string {
out.push(section('Listados')); out.push(section('Listados'));
out.push( out.push(
bullets([ bullets([
`${code('/t mias')} tus pendientes (por privado).`, `${code('/t ver grupo')} pendientes del grupo actual (desde grupo activo).`,
`${code('/t todas')} tus pendientes + “sin responsable”.`, `${code('/t ver mis')} tus pendientes (por DM).`,
'Nota: no respondo en grupos; usa estos comandos por privado.', `${code('/t ver todos')} tus pendientes + “sin responsable”.`,
'En grupo: “sin responsable” solo del grupo actual.',
'En DM: “sin responsable” de tus grupos.',
`${code('/t ver sin')} solo “sin responsable” del grupo actual (desde grupo).`,
'Máx. 10 elementos por sección; se añade “… y N más” si hay más.', 'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
'Fechas en DD/MM y ⚠️ si están vencidas.', 'Fechas en DD/MM y ⚠️ si están vencidas.',
]) ])
@ -61,8 +60,8 @@ export function getFullHelp(baseUrl?: string): string {
out.push(section('Fechas')); out.push(section('Fechas'));
out.push( out.push(
bullets([ bullets([
'Puedes escribir fechas en formato `2027-09-04` o `27-09-04`', '`YYYY-MM-DD` o `YY-MM-DD` (se expande a `20YY-MM-DD`).',
'`hoy` y `mañana` también son expresiones válidas', '`hoy` y `mañana` (según TZ; por defecto Europe/Madrid).',
]) ])
); );
@ -82,7 +81,7 @@ export function getFullHelp(baseUrl?: string): string {
out.push(section('Acceso web')); out.push(section('Acceso web'));
out.push( out.push(
bullets([ bullets([
`${code('/t web')} genera un enlace de acceso de un solo uso (dura 10 min, una vez entras dura 2 horas).`, `${code('/t web')} genera un enlace de acceso de un solo uso (10 min).`,
]) ])
); );
@ -93,6 +92,7 @@ export function getFullHelp(baseUrl?: string): string {
bullets([ bullets([
'IDs visibles con 4 dígitos, pero puedes escribirlos sin ceros (ej.: 26).', 'IDs visibles con 4 dígitos, pero puedes escribirlos sin ceros (ej.: 26).',
'Máx. 10 IDs en completar/tomar; separa por espacios o comas.', 'Máx. 10 IDs en completar/tomar; separa por espacios o comas.',
'En “gating” estricto de grupos, el bot puede no responder en grupos no permitidos.',
]) ])
); );

@ -1,198 +0,0 @@
import type { Database } from 'bun:sqlite';
import { ResponseQueue } from './response-queue';
import { GroupSyncService } from './group-sync';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { randomTokenBase64Url } from '../utils/crypto';
import { ICONS } from '../utils/icons';
import { codeId, code, bold, padTaskId } from '../utils/formatting';
type CommandResponse = {
recipient: string;
message: string;
mentions?: string[];
};
/**
* Construye (si aplica) el DM JIT al creador cuando hay menciones/tokens irrecuperables.
* Aplica flags y métricas exactamente como en CommandService.
*/
export function buildJitAssigneePrompt(createdBy: string, groupId: string, unresolvedAssigneeDisplays: string[]): CommandResponse[] {
const responses: CommandResponse[] = [];
const unresolvedList = Array.from(new Set((unresolvedAssigneeDisplays || []).filter(Boolean)));
if (unresolvedList.length === 0) return responses;
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
const enabled = isTest
? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'
: (() => {
const v = process.env.ONBOARDING_PROMPTS_ENABLED;
return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase());
})();
const groupLabel = String(groupId && groupId.includes('@g.us') ? groupId : 'dm');
if (!enabled) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { }
return responses;
}
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { }
return responses;
}
const list = unresolvedList.join(', ');
let groupCtx = '';
if (groupId && groupId.includes('@g.us')) {
const name = GroupSyncService.activeGroupsCache.get(groupId) || groupId;
groupCtx = ` (en el grupo ${name})`;
}
const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`;
responses.push({ recipient: createdBy, message: msg });
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { }
return responses;
}
/**
* Encola el paquete de 2 DMs de onboarding para miembros del grupo (si aplica).
* Respeta gating AllowedGroups, cap, cooldown, delays y métricas.
*/
export function maybeEnqueueOnboardingBundle(db: Database, params: {
gid: string | null;
createdBy: string;
assignmentUserIds: string[];
taskId: number;
displayCode: number | null;
description: string;
}): void {
const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test';
const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase());
const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true');
const gid = params.gid;
if (!enabled) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
return;
}
if (!gid) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: '' }); } catch {}
return;
}
// Gating enforce
let allowed = true;
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = db; } catch {}
allowed = AllowedGroups.isAllowed(gid);
}
} catch {}
if (!allowed) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
return;
}
const displayCode = params.displayCode;
if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
return;
}
// Candidatos
let members = GroupSyncService.listActiveMemberIds(gid);
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]);
members = members
.filter(id => /^\d+$/.test(id) && id.length < 14)
.filter(id => !exclude.has(id))
.filter(id => !bot || id !== bot);
if (members.length === 0) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {}
return;
}
const capRaw = Number(process.env.ONBOARDING_EVENT_CAP);
const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30;
let recipients = members;
if (recipients.length > cap) {
try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {}
recipients = recipients.slice(0, cap);
}
const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS);
const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14;
const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS);
const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 510s
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
const codeStr = String(displayCode);
const desc = (params.description || '(sin descripción)').trim();
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
const codeInline = codeId(params.taskId, displayCode);
const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`);
const cmdInfo = code(`/t info`);
const groupBold = bold(`${groupLabel}`);
const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_
- Para asignártela, escríbeme ${cmdTake} por privado
- Más info: ${cmdInfo} (por privado también)
${ICONS.info} Nunca escribo en grupos.
${ICONS.info} Cuando reciba tu primer mensaje no te enviaré más este recordatorio`;
const msg2 = `*GUÍA RÁPIDA*
Puedes interactuar con el bot escribiéndome por privado:
- Ver todas las tareas: ${code('/t todas')}
- Ver solo tus tareas: ${code('/t mias')}
- ¿Quieres recordatorios?: ${code('/t configurar diario|l-v|semanal|off')}
- Web: ${code('/t web')}`;
for (const rcpt of recipients) {
const stats = ResponseQueue.getOnboardingStats(rcpt);
let variant: 'initial' | 'reminder' | null = null;
if (!stats || (stats.total || 0) === 0) {
variant = 'initial';
} else if (stats.firstInitialAt) {
let firstMs = NaN;
try {
const s = String(stats.firstInitialAt);
const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z');
firstMs = Date.parse(iso);
} catch {}
const nowMs = Date.now();
const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false;
// Interacción del usuario desde el primer paquete
let hadInteraction = false;
try {
const row = db.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any;
const lcRaw = row?.last_command_at ? String(row.last_command_at) : null;
if (lcRaw) {
const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z');
const lcMs = Date.parse(lcIso);
hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs;
}
} catch {}
if (okCooldown && !hadInteraction) {
variant = 'reminder';
} else {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {}
}
}
if (!variant) continue;
const bundleId = randomTokenBase64Url(12);
try {
ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, 0);
ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, delay2);
try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
} catch {}
}
}

@ -42,7 +42,6 @@ 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',
@ -107,137 +106,6 @@ export const ResponseQueue = {
} }
}, },
// Encolar un DM de onboarding (part=1 inmediato, part=2 con retraso)
enqueueOnboarding(
recipient: string,
message: string,
metadata: {
variant: 'initial' | 'reminder';
part: 1 | 2;
bundle_id: string;
group_id?: string | null;
task_id?: number | null;
display_code?: number | null;
},
delayMs?: number
): void {
if (!recipient || !message) return;
const botNumber = (process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (botNumber && recipient === botNumber) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'bot_number', group_id: String(metadata.group_id || '') }); } catch {}
return;
}
const metaObj: any = {
kind: 'onboarding',
variant: metadata.variant,
part: metadata.part,
bundle_id: metadata.bundle_id,
group_id: metadata.group_id ?? null,
task_id: metadata.task_id ?? null,
display_code: metadata.display_code ?? null
};
const nextAt = delayMs && delayMs > 0 ? this.futureIso(delayMs) : this.nowIso();
this.dbInstance.prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?)
`).run(recipient, message, JSON.stringify(metaObj), nextAt);
try { Metrics.inc('onboarding_dm_sent_total', 1, { variant: metadata.variant, part: String(metadata.part), group_id: String(metadata.group_id || '') }); } catch {}
},
// Estadísticas de onboarding por destinatario (consulta simple sobre response_queue)
getOnboardingStats(recipient: string): { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } {
if (!recipient) return { total: 0, lastSentAt: null, firstInitialAt: undefined, lastVariant: null };
const rows = this.dbInstance.prepare(`
SELECT status, created_at, updated_at, metadata
FROM response_queue
WHERE recipient = ? AND metadata IS NOT NULL
`).all(recipient) as Array<{ status: string; created_at: string; updated_at: string; metadata: string | null }>;
let total = 0;
let lastSentAt: string | null = null;
let firstInitialAt: string | null | undefined = undefined;
let lastVariant: 'initial' | 'reminder' | null = null;
let lastTsMs = -1;
for (const r of rows) {
let meta: any = null;
try { meta = r.metadata ? JSON.parse(r.metadata) : null; } catch { meta = null; }
if (!meta || meta.kind !== 'onboarding') continue;
total++;
// Elegir timestamp de referencia
const tRaw = (r.updated_at || r.created_at || '').toString();
const iso = tRaw.includes('T') ? tRaw : (tRaw.replace(' ', 'T') + 'Z');
const ts = Date.parse(iso);
if (Number.isFinite(ts) && ts > lastTsMs) {
lastTsMs = ts;
lastSentAt = tRaw || null;
lastVariant = (meta.variant === 'reminder' ? 'reminder' : 'initial');
}
// Primer initial (preferimos part=1)
if (meta.variant === 'initial') {
const created = (r.created_at || '').toString();
if (!firstInitialAt) {
firstInitialAt = created || null;
} else {
// mantener el más antiguo
try {
const curIso = (firstInitialAt as string).includes('T') ? firstInitialAt as string : ((firstInitialAt as string).replace(' ', 'T') + 'Z');
const curMs = Date.parse(curIso);
const newIso = created.includes('T') ? created : (created.replace(' ', 'T') + 'Z');
const newMs = Date.parse(newIso);
if (Number.isFinite(newMs) && (!Number.isFinite(curMs) || newMs < curMs)) {
firstInitialAt = created || null;
}
} catch {}
}
}
}
return { total, lastSentAt, firstInitialAt, lastVariant };
},
// Encolar una reacción con idempotencia (24h) usando metadata canónica
async enqueueReaction(chatId: string, messageId: string, emoji: string, opts?: { participant?: string; fromMe?: boolean }): Promise<void> {
try {
if (!chatId || !messageId || !emoji) return;
// Construir JSON canónico (incluir participant/fromMe si están disponibles)
const metaObj: any = { kind: 'reaction', emoji, chatId, messageId };
if (typeof opts?.fromMe === 'boolean') metaObj.fromMe = !!opts.fromMe;
if (opts?.participant) metaObj.participant = opts.participant;
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 || '',
@ -254,51 +122,6 @@ 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 fromMe = !!meta.fromMe;
const key: any = { remoteJid: chatId, fromMe, id: messageId };
if (meta.participant) {
key.participant = String(meta.participant);
}
const payload = {
key,
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}`;
@ -415,15 +238,6 @@ export const ResponseQueue = {
updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')
WHERE id = ? WHERE id = ?
`).run(statusCode ?? null, id); `).run(statusCode ?? null, id);
// Recalcular métricas agregadas de onboarding si aplica
try {
const row = this.dbInstance.prepare(`SELECT metadata FROM response_queue WHERE id = ?`).get(id) as any;
let meta: any = null;
try { meta = row?.metadata ? JSON.parse(String(row.metadata)) : null; } catch {}
if (meta && meta.kind === 'onboarding') {
this.setOnboardingAggregatesMetrics();
}
} catch {}
}, },
markFailed(id: number, errorMsg: string, statusCode?: number, attempts?: number) { markFailed(id: number, errorMsg: string, statusCode?: number, attempts?: number) {
@ -453,50 +267,6 @@ export const ResponseQueue = {
`).run(nextAttempts, nextAttemptAt, msg, statusCode ?? null, id); `).run(nextAttempts, nextAttemptAt, msg, statusCode ?? null, id);
}, },
setOnboardingAggregatesMetrics(): void {
try {
// Total de mensajes de onboarding enviados
const sentRow = this.dbInstance.prepare(`
SELECT COUNT(*) AS c
FROM response_queue
WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%'
`).get() as any;
const sentAbs = Number(sentRow?.c || 0);
// Destinatarios únicos con al menos 1 onboarding enviado
const rcptRow = this.dbInstance.prepare(`
SELECT COUNT(DISTINCT recipient) AS c
FROM response_queue
WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%'
`).get() as any;
const recipientsAbs = Number(rcptRow?.c || 0);
// Usuarios convertidos: last_command_at > primer onboarding enviado
const convRow = this.dbInstance.prepare(`
SELECT COUNT(*) AS c
FROM users u
JOIN (
SELECT recipient, MIN(created_at) AS first_at
FROM response_queue
WHERE status = 'sent' AND metadata LIKE '%"kind":"onboarding"%'
GROUP BY recipient
) f ON f.recipient = u.id
WHERE u.last_command_at IS NOT NULL
AND u.last_command_at > f.first_at
`).get() as any;
const convertedAbs = Number(convRow?.c || 0);
const rate = recipientsAbs > 0 ? Math.max(0, Math.min(1, convertedAbs / recipientsAbs)) : 0;
try { Metrics.set('onboarding_dm_sent_abs', sentAbs); } catch {}
try { Metrics.set('onboarding_recipients_abs', recipientsAbs); } catch {}
try { Metrics.set('onboarding_converted_users_abs', convertedAbs); } catch {}
try { Metrics.set('onboarding_conversion_rate', rate); } catch {}
} catch {
// no-op
}
},
async workerLoop(workerId: number) { async workerLoop(workerId: number) {
while (this._running) { while (this._running) {
try { try {
@ -524,13 +294,8 @@ export const ResponseQueue = {
continue; continue;
} }
// 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones) // 5xx o error de red: reintento con backoff si no superó el máximo
let metaForMax: any = null; if (attemptsNow >= this.MAX_ATTEMPTS) {
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;
} }

@ -70,29 +70,13 @@ export class WebhookManager {
} }
private static getConfig(): { webhook: WebhookConfig } { private static getConfig(): { webhook: WebhookConfig } {
const fromEnv = String(process.env.WEBHOOK_EVENTS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const allow = [
'APPLICATION_STARTUP',
'MESSAGES_UPSERT',
'GROUPS_UPSERT',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'PRESENCE_UPDATE',
'CONTACTS_UPDATE',
'CHATS_UPDATE'
];
const events = (fromEnv.length ? fromEnv : ['APPLICATION_STARTUP','MESSAGES_UPSERT','GROUPS_UPSERT'])
.filter(e => allow.includes(e));
return { return {
webhook: { webhook: {
url: process.env.WEBHOOK_URL!, url: process.env.WEBHOOK_URL!,
enabled: true, enabled: true,
webhook_by_events: true, webhook_by_events: true,
webhook_base64: true, webhook_base64: true,
events events: this.REQUIRED_EVENTS,
} }
}; };
} }

@ -2,8 +2,6 @@ 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;
@ -280,63 +278,6 @@ 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) {
let origin: any = null;
try {
origin = this.dbInstance.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
} catch {
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
const participant = origin && origin.participant ? String(origin.participant) : undefined;
const fromMe = (origin && (origin.from_me === 1 || origin.from_me === true)) ? true : undefined;
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', { participant, fromMe })
.catch(() => {});
}
}
}
}
}
} catch {}
return { return {
status: 'updated', status: 'updated',
task: { task: {
@ -412,7 +353,7 @@ export class TaskService {
const existing = this.dbInstance const existing = this.dbInstance
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, completed, completed_at, display_code SELECT id, description, due_date, completed, completed_at, display_code
FROM tasks FROM tasks
WHERE id = ? WHERE id = ?
`) `)
@ -483,7 +424,7 @@ export class TaskService {
const existing = this.dbInstance const existing = this.dbInstance
.prepare(` .prepare(`
SELECT id, description, due_date, group_id, completed, completed_at, display_code SELECT id, description, due_date, completed, completed_at, display_code
FROM tasks FROM tasks
WHERE id = ? WHERE id = ?
`) `)
@ -515,7 +456,7 @@ export class TaskService {
`).get(ensuredUser, taskId) as any; `).get(ensuredUser, taskId) as any;
const cnt = Number(stats?.cnt || 0); const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0; const mine = Number(stats?.mine || 0) > 0;
if (existing.group_id == null && cnt === 1 && mine) { if ((existing.group_id == null || existing.group_id === null) && cnt === 1 && mine) {
return { return {
status: 'forbidden_personal', status: 'forbidden_personal',
task: { task: {
@ -607,7 +548,7 @@ export class TaskService {
const row = this.dbInstance.prepare(` const row = this.dbInstance.prepare(`
SELECT id, description, due_date, display_code SELECT id, description, due_date, display_code
FROM tasks FROM tasks
WHERE display_code = ? AND COALESCE(completed, 0) = 0 AND completed_at IS NULL WHERE display_code = ? AND COALESCE(completed, 0) = 0
LIMIT 1 LIMIT 1
`).get(displayCode) as any; `).get(displayCode) as any;
if (!row) return null; if (!row) return null;

@ -4,7 +4,7 @@ export const ICONS = {
assignNotice: '📬', assignNotice: '📬',
reminder: '⏰', reminder: '⏰',
date: '📅', date: '📅',
unassigned: '🙅', unassigned: '🚫👤',
take: '✋', take: '✋',
unassign: '↩️', unassign: '↩️',
info: '', info: '',

@ -71,7 +71,7 @@ describe('Database', () => {
.query("PRAGMA table_info(users)") .query("PRAGMA table_info(users)")
.all() .all()
.map((c: any) => c.name); .map((c: any) => c.name);
expect(columns).toEqual(['id', 'first_seen', 'last_seen', 'last_command_at']); expect(columns).toEqual(['id', 'first_seen', 'last_seen']);
}); });
test('tasks table should have required columns', () => { test('tasks table should have required columns', () => {

@ -79,14 +79,9 @@ describe('Migrator', () => {
// Segunda ejecución: no hay pendientes // Segunda ejecución: no hay pendientes
Migrator.migrateToLatest(mem, { withBackup: false, allowBaseline: false }); Migrator.migrateToLatest(mem, { withBackup: false, allowBaseline: false });
// El fichero puede no existir en algunos entornos (FS/CWD distintos). // El fichero debe existir y contener eventos conocidos
// Si no existe, no fallamos el test (lo importante es que migrar sea idempotente).
const logPath = 'data/migrations.log'; const logPath = 'data/migrations.log';
if (!existsSync(logPath)) { expect(existsSync(logPath)).toBe(true);
expect(true).toBe(true);
return;
}
const content = readFileSync(logPath, 'utf-8'); const content = readFileSync(logPath, 'utf-8');
expect(content).toContain('"event":"startup_summary"'); expect(content).toContain('"event":"startup_summary"');
expect(content).toContain('"event":"no_pending"'); expect(content).toContain('"event":"no_pending"');

@ -61,9 +61,8 @@ beforeEach(() => {
// Ensure database is initialized (recreates tables if dropped) // Ensure database is initialized (recreates tables if dropped)
initializeDatabase(testDb); initializeDatabase(testDb);
// Reset database state between tests (borrar raíz primero; ON DELETE CASCADE limpia assignments) // Reset database state between tests
testDb.exec('DELETE FROM response_queue'); testDb.exec('DELETE FROM task_assignments');
try { testDb.exec('DELETE FROM task_origins'); } catch { }
testDb.exec('DELETE FROM tasks'); testDb.exec('DELETE FROM tasks');
testDb.exec('DELETE FROM users'); testDb.exec('DELETE FROM users');
testDb.exec('DELETE FROM groups'); testDb.exec('DELETE FROM groups');
@ -854,7 +853,8 @@ describe('WebhookServer', () => {
expect(r.recipient.endsWith('@g.us')).toBe(false); expect(r.recipient.endsWith('@g.us')).toBe(false);
} }
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.'); expect(msg).toContain('sin responsable');
expect(msg).toContain('… y 2 más');
}); });
test('should process "/t ver sin" in DM returning instruction', async () => { test('should process "/t ver sin" in DM returning instruction', async () => {
@ -875,7 +875,7 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No tienes tareas pendientes.'); expect(msg).toContain('Este comando se usa en grupos');
}); });
test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => { test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => {
@ -921,7 +921,9 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.'); expect(msg).toContain('Tus tareas');
expect(msg).toContain('sin responsable');
expect(msg).toContain('… y 2 más');
}); });
test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => { test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => {

@ -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_origins;
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');
});
});

@ -36,15 +36,6 @@ describe('CommandService - /t tomar y /t soltar', () => {
return taskId; return taskId;
} }
function getDisplayCode(id: number): number {
const row = memdb.prepare('SELECT display_code FROM tasks WHERE id = ?').get(id) as any;
return Number(row?.display_code || 0);
}
function code4(n: number): string {
return '`' + String(n).padStart(4, '0') + '`';
}
const ctx = (sender: string, message: string) => ({ const ctx = (sender: string, message: string) => ({
sender, sender,
groupId: '', // DM o vacío; sin relevancia para tomar/soltar groupId: '', // DM o vacío; sin relevancia para tomar/soltar
@ -66,14 +57,13 @@ describe('CommandService - /t tomar y /t soltar', () => {
it('tomar: happy y luego already', async () => { it('tomar: happy y luego already', async () => {
const taskId = createTask('Desc tomar', '999', '2025-09-12'); const taskId = createTask('Desc tomar', '999', '2025-09-12');
const dc = getDisplayCode(taskId); const r1 = await CommandService.handle(ctx('111', `/t tomar ${taskId}`));
const r1 = await CommandService.handle(ctx('111', `/t tomar ${dc}`));
expect(r1[0].message).toContain('Has tomado'); expect(r1[0].message).toContain('Has tomado');
expect(r1[0].message).toContain(code4(dc)); expect(r1[0].message).toContain(String(taskId));
expect(r1[0].message).toContain('Desc tomar'); expect(r1[0].message).toContain('Desc tomar');
expect(r1[0].message).toContain('📅'); // formato dd/MM expect(r1[0].message).toContain('📅'); // formato dd/MM
const r2 = await CommandService.handle(ctx('111', `/t tomar ${dc}`)); const r2 = await CommandService.handle(ctx('111', `/t tomar ${taskId}`));
expect(r2[0].message).toContain('ya la tenías'); expect(r2[0].message).toContain('ya la tenías');
}); });
@ -82,9 +72,8 @@ describe('CommandService - /t tomar y /t soltar', () => {
const comp = TaskService.completeTask(taskId, '111'); const comp = TaskService.completeTask(taskId, '111');
expect(comp.status).toBe('updated'); expect(comp.status).toBe('updated');
const dc = getDisplayCode(taskId); const res = await CommandService.handle(ctx('222', `/t tomar ${taskId}`));
const res = await CommandService.handle(ctx('222', `/t tomar ${dc}`)); expect(res[0].message).toContain('ya estaba completada');
expect(res[0].message).toContain('no encontrada');
}); });
it('soltar: uso inválido (sin id)', async () => { it('soltar: uso inválido (sin id)', async () => {
@ -99,15 +88,13 @@ describe('CommandService - /t tomar y /t soltar', () => {
it('soltar: personal única asignación → denegado', async () => { it('soltar: personal única asignación → denegado', async () => {
const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']); const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']);
const dc = getDisplayCode(taskId); const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`));
expect(res[0].message).toContain('No puedes soltar una tarea personal. Márcala como completada para eliminarla'); expect(res[0].message).toContain('No puedes soltar una tarea personal. Márcala como completada para eliminarla');
}); });
it('soltar: not_assigned muestra mensaje informativo', async () => { it('soltar: not_assigned muestra mensaje informativo', async () => {
const taskId = createTask('Nunca asignada a 111', '999', null, ['222']); const taskId = createTask('Nunca asignada a 111', '999', null, ['222']);
const dc = getDisplayCode(taskId); const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`));
expect(res[0].message).toContain('no la tenías asignada'); expect(res[0].message).toContain('no la tenías asignada');
}); });
@ -116,8 +103,7 @@ describe('CommandService - /t tomar y /t soltar', () => {
const comp = TaskService.completeTask(taskId, '111'); const comp = TaskService.completeTask(taskId, '111');
expect(comp.status).toBe('updated'); expect(comp.status).toBe('updated');
const dc = getDisplayCode(taskId); const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`)); expect(res[0].message).toContain('ya estaba completada');
expect(res[0].message).toContain('no encontrada');
}); });
}); });

@ -14,7 +14,7 @@ describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado
expect(res.length).toBeGreaterThan(0); expect(res.length).toBeGreaterThan(0);
const msg = res[0].message; const msg = res[0].message;
expect(msg).toContain('/t mias'); expect(msg).toContain('/t ver mis');
expect(msg).toContain('/t web'); expect(msg).toContain('/t web');
expect(msg).toContain('Ayuda avanzada'); expect(msg).toContain('Ayuda avanzada');
expect(msg).toContain('/t ayuda avanzada'); expect(msg).toContain('/t ayuda avanzada');
@ -32,10 +32,12 @@ describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado
const msg = res[0].message; const msg = res[0].message;
// Scopes de ver // Scopes de ver
expect(msg).toContain('/t mias'); expect(msg).toContain('/t ver sin');
expect(msg).toContain('/t todas'); expect(msg).toContain('/t ver grupo');
expect(msg).toContain('/t ver todos');
// Formatos de fecha // Formatos de fecha
expect(msg).toContain('27-09-04'); expect(msg).toContain('YY-MM-DD');
expect(msg).toContain('20YY');
// Configurar etiquetas en español // Configurar etiquetas en español
expect(msg).toContain('diario|l-v|semanal|off'); expect(msg).toContain('diario|l-v|semanal|off');
}); });

@ -1,159 +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 { Metrics } from '../../../src/services/metrics';
describe('CommandService - autoasignación con "yo" / "@yo"', () => {
let memdb: Database;
beforeAll(() => {
memdb = new Database(':memory:');
initializeDatabase(memdb);
TaskService.dbInstance = memdb;
CommandService.dbInstance = memdb;
});
beforeEach(() => {
process.env.NODE_ENV = 'test';
process.env.METRICS_ENABLED = 'true';
Metrics.reset?.();
memdb.exec(`
DELETE FROM task_assignments;
DELETE FROM tasks;
DELETE FROM users;
DELETE FROM user_preferences;
`);
});
function getLastTask() {
return memdb.prepare(`SELECT id, description FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
}
function getAssignees(taskId: number): string[] {
const rows = memdb.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`).all(taskId) as any[];
return rows.map(r => String(r.user_id));
}
it('en grupo: "yo" autoasigna al remitente y no queda en la descripción', async () => {
const sender = '600111222';
await CommandService.handle({
sender,
groupId: '12345@g.us', // contexto grupo
message: '/t n Hacer algo yo',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(assignees).toContain(sender);
expect(String(t.description)).toBe('Hacer algo');
});
it('en grupo: "@yo" autoasigna y no incrementa métricas de fallo', async () => {
const sender = '600222333';
await CommandService.handle({
sender,
groupId: 'group@g.us',
message: '/t n Revisar docs @yo',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(assignees).toContain(sender);
expect(String(t.description)).toBe('Revisar docs');
const prom = Metrics.render?.('prom') || '';
expect(prom).not.toContain('onboarding_assign_failures_total');
});
it('no falsos positivos: "yoyo" y "hoyo" no autoasignan en grupo (queda sin dueño)', async () => {
const sender = '600333444';
// yoyo
await CommandService.handle({
sender,
groupId: 'grp@g.us',
message: '/t n Caso yoyo',
mentions: [],
});
let t = getLastTask();
let assignees = getAssignees(Number(t.id));
expect(assignees.length).toBe(0);
// hoyo
await CommandService.handle({
sender,
groupId: 'grp@g.us',
message: '/t n Voy a cavar un hoyo',
mentions: [],
});
t = getLastTask();
assignees = getAssignees(Number(t.id));
expect(assignees.length).toBe(0);
});
it('combinado: "yo @34600123456" asigna al remitente y al otro usuario', async () => {
const sender = '600444555';
await CommandService.handle({
sender,
groupId: 'g@g.us',
message: '/t n Tarea combinada yo @34600123456',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(new Set(assignees)).toEqual(new Set([sender, '34600123456']));
});
it('en DM: "yo" también se asigna al remitente y no queda en la descripción', async () => {
const sender = '600555666';
await CommandService.handle({
sender,
groupId: `${sender}@s.whatsapp.net`, // DM
message: '/t n Mi tarea yo',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(assignees).toContain(sender);
expect(String(t.description)).toBe('Mi tarea');
});
it('en grupo: "@yo," autoasigna y no incrementa métricas de fallo', async () => {
const sender = '600666777';
await CommandService.handle({
sender,
groupId: 'group2@g.us',
message: '/t n Revisar algo @yo,',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(assignees).toContain(sender);
expect(String(t.description)).toBe('Revisar algo');
const prom = Metrics.render?.('prom') || '';
expect(prom).not.toContain('onboarding_assign_failures_total');
});
it('en grupo: "(yo)" autoasigna y no queda en la descripción', async () => {
const sender = '600777888';
await CommandService.handle({
sender,
groupId: 'grp2@g.us',
message: '/t n Hacer (yo)',
mentions: [],
});
const t = getLastTask();
const assignees = getAssignees(Number(t.id));
expect(assignees).toContain(sender);
expect(String(t.description)).toBe('Hacer');
});
});

@ -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');
});
});

@ -49,7 +49,11 @@ test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”'
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('No respondo en grupos.'); expect(responses[0].message).toContain('Test Group');
// Debe indicar que hay 2 más (límite 10)
expect(responses[0].message).toContain('… y 2 más');
// Debe mostrar “sin responsable”
expect(responses[0].message).toContain('sin responsable');
}); });
test('listar “mis” por defecto en DM con /t ver', async () => { test('listar “mis” por defecto en DM con /t ver', async () => {
@ -111,28 +115,26 @@ test('completar tarea: camino feliz, ya completada y no encontrada', async () =>
created_by: '1111111111', created_by: '1111111111',
}); });
const dc = Number((memDb.prepare(`SELECT display_code FROM tasks WHERE id = ?`).get(taskId) as any)?.display_code || 0);
// 1) Camino feliz // 1) Camino feliz
let responses = await CommandService.handle({ let responses = await CommandService.handle({
sender: '1234567890', sender: '1234567890',
groupId: 'test-group@g.us', groupId: 'test-group@g.us',
mentions: [], mentions: [],
message: `/t x ${dc}` message: `/t x ${taskId}`
}); });
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toMatch(/^✅ `\d{4}` _completada_/); expect(responses[0].message).toMatch(/^✅ `\d{4}` completada/);
// 2) Ya completada (ahora no debe resolverse por display_code → no encontrada) // 2) Ya completada
responses = await CommandService.handle({ responses = await CommandService.handle({
sender: '1234567890', sender: '1234567890',
groupId: 'test-group@g.us', groupId: 'test-group@g.us',
mentions: [], mentions: [],
message: `/t x ${dc}` message: `/t x ${taskId}`
}); });
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].message).toContain('no encontrada'); expect(responses[0].message).toContain('ya estaba completada');
// 3) No encontrada // 3) No encontrada
responses = await CommandService.handle({ responses = await CommandService.handle({
@ -189,7 +191,11 @@ test('ver sin en grupo activo: solo sin dueño y paginación', async () => {
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message; const msg = responses[0].message;
expect(msg).toContain('No respondo en grupos.'); expect(msg).toContain('Test Group');
expect(msg).toContain('sin responsable');
expect(msg).toContain('… y 2 más');
expect(msg).not.toContain('Asignada 1');
expect(msg).not.toContain('Asignada 2');
}); });
test('ver sin por DM devuelve instrucción', async () => { test('ver sin por DM devuelve instrucción', async () => {
@ -203,7 +209,7 @@ test('ver sin por DM devuelve instrucción', async () => {
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('No tienes tareas pendientes.'); expect(responses[0].message).toContain('Este comando se usa en grupos');
}); });
test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con paginación en la sección sin dueño', async () => { test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con paginación en la sección sin dueño', async () => {
@ -249,7 +255,10 @@ test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
const msg = responses[0].message; const msg = responses[0].message;
expect(msg).toContain('No respondo en grupos.'); expect(msg).toContain('Tus tareas');
expect(msg).toContain('Test Group');
expect(msg).toContain('sin responsable');
expect(msg).toContain('… y 2 más'); // paginación en la sección “sin dueño”
}); });
test('ver todos por DM: “Tus tareas” + nota instructiva para ver sin dueño desde el grupo', async () => { test('ver todos por DM: “Tus tareas” + nota instructiva para ver sin dueño desde el grupo', async () => {

@ -16,7 +16,7 @@ describe('CommandService - comando desconocido devuelve ayuda rápida', () => {
expect(msg).toContain('COMANDO NO RECONOCIDO'); expect(msg).toContain('COMANDO NO RECONOCIDO');
expect(msg).toContain('/t ayuda'); expect(msg).toContain('/t ayuda');
expect(msg).toContain('/t mias'); expect(msg).toContain('/t ver mis');
expect(msg).toContain('/t web'); expect(msg).toContain('/t web');
expect(msg).toContain('/t configurar'); expect(msg).toContain('/t configurar');
}); });

@ -5,7 +5,7 @@ describe('Help content (centralizado)', () => {
it('quick help incluye comandos básicos y /t web', () => { it('quick help incluye comandos básicos y /t web', () => {
const s = getQuickHelp(); const s = getQuickHelp();
expect(s).toContain('/t n'); expect(s).toContain('/t n');
expect(s).toContain('/t mias'); expect(s).toContain('/t ver mis');
expect(s).toContain('/t x 26'); expect(s).toContain('/t x 26');
expect(s).toContain('/t configurar'); expect(s).toContain('/t configurar');
expect(s).toContain('/t web'); expect(s).toContain('/t web');
@ -17,11 +17,14 @@ describe('Help content (centralizado)', () => {
it('full help cubre scopes de "ver", formatos de fecha y límites', () => { it('full help cubre scopes de "ver", formatos de fecha y límites', () => {
const s = getFullHelp(); const s = getFullHelp();
// Scopes // Scopes
expect(s).toContain('/t mias'); expect(s).toContain('/t ver grupo');
expect(s).toContain('/t todas'); expect(s).toContain('/t ver mis');
expect(s).toContain('/t ver todos');
expect(s).toContain('/t ver sin');
// Fechas // Fechas
expect(s).toContain('27-09-04'); expect(s).toContain('YY-MM-DD');
expect(s).toContain('20YY');
expect(s).toContain('hoy'); expect(s).toContain('hoy');
expect(s).toContain('mañana'); expect(s).toContain('mañana');

@ -1,97 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ResponseQueue } from '../../../src/services/response-queue';
describe('ResponseQueue - payload de reacción', () => {
const OLD_FETCH = globalThis.fetch;
beforeEach(() => {
process.env.EVOLUTION_API_URL = 'http://evolution.local';
process.env.EVOLUTION_API_INSTANCE = 'instance-1';
});
afterEach(() => {
globalThis.fetch = OLD_FETCH;
delete process.env.EVOLUTION_API_URL;
delete process.env.EVOLUTION_API_INSTANCE;
});
it('incluye participant y fromMe cuando están presentes', async () => {
const calls: any[] = [];
globalThis.fetch = (async (url: any, init?: any) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
text: async () => ''
} as any;
}) as any;
const item = {
id: 1,
recipient: '12345-67890@g.us',
message: '',
metadata: JSON.stringify({
kind: 'reaction',
emoji: '✅',
chatId: '12345-67890@g.us',
messageId: 'MSG-123',
fromMe: true,
participant: '34600123456@s.whatsapp.net'
}),
attempts: 0
};
const res = await ResponseQueue.sendOne(item as any);
expect(res.ok).toBe(true);
expect(calls.length).toBe(1);
const { url, init } = calls[0];
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
const payload = JSON.parse(String(init.body || '{}'));
expect(payload).toBeTruthy();
expect(payload.reaction).toBe('✅');
expect(payload.key).toEqual({
remoteJid: '12345-67890@g.us',
fromMe: true,
id: 'MSG-123',
participant: '34600123456@s.whatsapp.net'
});
});
it('omite participant y usa fromMe=false por defecto cuando no se proveen', async () => {
const calls: any[] = [];
globalThis.fetch = (async (url: any, init?: any) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
text: async () => ''
} as any;
}) as any;
const item = {
id: 2,
recipient: '12345-67890@g.us',
message: '',
metadata: JSON.stringify({
kind: 'reaction',
emoji: '✅',
chatId: '12345-67890@g.us',
messageId: 'MSG-456'
}),
attempts: 0
};
const res = await ResponseQueue.sendOne(item as any);
expect(res.ok).toBe(true);
const { url, init } = calls[0];
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
const payload = JSON.parse(String(init.body || '{}'));
expect(payload.reaction).toBe('✅');
expect(payload.key.remoteJid).toBe('12345-67890@g.us');
expect(payload.key.id).toBe('MSG-456');
expect(payload.key.fromMe).toBe(false);
expect('participant' in payload.key).toBe(false);
});
});

@ -1,112 +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' });
});
it('sendOne incluye key.participant cuando viene en metadata (grupo, fromMe:false)', async () => {
const item = {
id: 43,
recipient: '120363401791776728@g.us',
message: '',
attempts: 0,
metadata: JSON.stringify({
kind: 'reaction',
emoji: '✅',
chatId: '120363401791776728@g.us',
messageId: 'MSG-100',
participant: '34650861805:32@s.whatsapp.net',
fromMe: false
}),
};
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: '120363401791776728@g.us',
fromMe: false,
id: 'MSG-100',
participant: '34650861805:32@s.whatsapp.net'
});
});
});

@ -1,157 +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_origins;
DELETE FROM tasks;
DELETE FROM users;
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);
});
});

@ -1,194 +0,0 @@
import { beforeEach, afterEach, describe, expect, it } from 'bun:test';
import { createTempDb } from './helpers/db';
// Los imports del handler y closeDb se hacen dinámicos dentro de cada test/teardown
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
describe('Web API - completar tarea encola reacción ✅', () => {
let cleanup: () => void;
let db: any;
let path: string;
const USER = '34600123456';
const GROUP_ID = '12345-67890@g.us';
beforeEach(() => {
const tmp = createTempDb();
cleanup = tmp.cleanup;
db = tmp.db;
path = tmp.path;
process.env.NODE_ENV = 'test';
process.env.DB_PATH = path;
process.env.REACTIONS_ENABLED = 'true';
process.env.REACTIONS_SCOPE = 'groups';
process.env.REACTIONS_TTL_DAYS = '14';
process.env.GROUP_GATING_MODE = 'enforce';
// Sembrar usuario y grupo permitido + membresía activa
db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER);
db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Group', 1)`).run(GROUP_ID);
db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'Test', 'allowed')`).run(GROUP_ID);
db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER);
});
afterEach(async () => {
// Cerrar la conexión singleton de la web antes de borrar el archivo
try {
const { closeDb } = await import('../../apps/web/src/lib/server/db.ts');
closeDb();
} catch {}
if (cleanup) cleanup();
// Limpiar env relevantes
delete process.env.DB_PATH;
delete process.env.REACTIONS_ENABLED;
delete process.env.REACTIONS_SCOPE;
delete process.env.REACTIONS_TTL_DAYS;
delete process.env.GROUP_GATING_MODE;
});
it('caso feliz: encola 1 reacción ✅ con metadata canónica', async () => {
// Crear tarea en grupo (no completada)
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Probar reacción', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
// Origen reciente con participant y from_me=1
const messageId = 'MSG-abc-123';
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at, participant, from_me)
VALUES (?, ?, ?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()), `${USER}@s.whatsapp.net`, 1);
// Ejecutar endpoint
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const payload = await res.json();
expect(payload.status).toBe('updated');
// Verificar encolado
const row = db.prepare(`SELECT recipient, message, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
expect(String(row.recipient)).toBe(GROUP_ID);
expect(String(row.message)).toBe('');
const meta = JSON.parse(String(row.metadata || '{}'));
expect(meta).toEqual({
kind: 'reaction',
emoji: '✅',
chatId: GROUP_ID,
messageId,
fromMe: true,
participant: `${USER}@s.whatsapp.net`
});
// Idempotencia del endpoint: segunda llamada no crea nuevo job
const res2 = await completeHandler(event);
expect(res2.status).toBe(200);
const body2 = await res2.json();
expect(body2.status).toBe('already');
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata = ?`).get(JSON.stringify(meta)) as any;
expect(Number(cnt.c || 0)).toBe(1);
});
it('TTL vencido: no encola reacción', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Vieja', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-old-001';
const old = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000); // 20 días
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(old));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('updated');
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c || 0)).toBe(0);
});
it('scope=groups: origen DM no encola', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('DM scope', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-dm-001';
const dmChat = `${USER}@s.whatsapp.net`; // no @g.us
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, dmChat, messageId, toIsoSql(new Date()));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c || 0)).toBe(0);
});
it('sin participant/from_me: metadata no incluye claves opcionales', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Sin opcionales', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-nopts-001';
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const row = db.prepare(`SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
const meta = JSON.parse(String(row.metadata || '{}'));
expect(meta).toEqual({
kind: 'reaction',
emoji: '✅',
chatId: GROUP_ID,
messageId
});
expect('fromMe' in meta).toBe(false);
expect('participant' in meta).toBe(false);
});
});
Loading…
Cancel
Save