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

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
*/
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
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.DEV) {
if (import.meta.env.DEV) {
const modModule: any = await import('node:module');
const require = modModule.createRequire(import.meta.url);
const mod = require('better-sqlite3');
return (mod as any).default || (mod as any).Database || mod;
}
// En producción (Bun en runtime), usar bun:sqlite nativo
const mod: any = await import('bun:sqlite');
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);
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';
// 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;
}
import { env } from '$env/dynamic/private';
/**
* 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);
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
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,335 +1,326 @@
<script lang="ts">
import logo from "$lib/assets/wtasklogo192.png";
import { page } from "$app/stores";
import Toast from "$lib/ui/feedback/Toast.svelte";
$: pathname = $page.url.pathname;
$: currentTitle =
pathname === "/app"
? "Tareas"
: pathname.startsWith("/app/groups")
? "Grupos"
: pathname.startsWith("/app/preferences")
? "Recordatorios"
: pathname.startsWith("/app/integrations")
? "Calendarios"
: "Tareas";
import { page } from "$app/stores";
import Toast from "$lib/ui/feedback/Toast.svelte";
$: pathname = $page.url.pathname;
$: currentTitle =
pathname === "/app"
? "Tareas"
: pathname.startsWith("/app/groups")
? "Grupos"
: pathname.startsWith("/app/preferences")
? "Recordatorios"
: pathname.startsWith("/app/integrations")
? "Calendarios"
: "Tareas";
</script>
<header class="app-header">
<div class="container row">
<a class="brand" href="/app" aria-label="Inicio"
><img src={logo} alt="Wtask" /></a
>
<nav class="nav">
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
>Recordatorios</a
>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
>Calendarios</a
>
</nav>
<form method="POST" action="/api/logout">
<button type="submit" class="logout">Cerrar sesión</button>
</form>
</div>
<div class="container row">
<a class="brand" href="/app" aria-label="Inicio">Tareas</a>
<nav class="nav">
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
>Recordatorios</a
>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
>Calendarios</a
>
</nav>
<form method="POST" action="/api/logout">
<button type="submit" class="logout">Cerrar sesión</button>
</form>
</div>
</header>
<!-- Barra superior móvil (solo título) -->
<div class="mobile-topbar" aria-hidden="true">
<div class="container topbar-inner">{currentTitle}</div>
<div class="container topbar-inner">{currentTitle}</div>
</div>
<svelte:head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<main class="container main">
<slot />
<slot />
</main>
<nav class="tabbar" aria-label="Navegación inferior">
<a
href="/app"
class:active={$page.url.pathname === "/app"}
aria-label="Tareas"
>
<span class="icon"
><svg viewBox="0 0 117.45 122.88">
<path
class="tabbar-icon-svg"
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
/>
</svg></span
>
<span class="label">Tareas</span>
</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}
aria-label="Grupos"
>
<span class="icon"
><svg viewBox="0 0 122.88 91.99"
><g
><path
class="tabbar-icon-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/></g
></svg
></span
>
<span class="label">Grupos</span>
</a>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
aria-label="Recordatorios"
>
<span class="icon"
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
><path
class="tabbar-icon-svg"
fill-rule="nonzero"
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
/></svg
></span
>
<span class="label">Alertas</span>
</a>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
aria-label="Calendarios"
>
<span class="icon"
><svg viewBox="0 0 110.01 122.88"
><g
><path
class="tabbar-icon-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
/></g
></svg
></span
>
<span class="label">Calendarios</span>
</a>
<form
method="POST"
action="/api/logout"
class="logout-tab"
aria-label="Salir"
>
<button type="submit">
<span class="icon"
><svg viewBox="0 0 113.525 122.879"
><g
><path
class="tabbar-icon-svg"
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
/></g
></svg
></span
>
<span class="label">Salir</span>
</button>
</form>
<a
href="/app"
class:active={$page.url.pathname === "/app"}
aria-label="Tareas"
>
<span class="icon"
><svg viewBox="0 0 117.45 122.88">
<path
class="tabbar-icon-svg"
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
/>
</svg></span
>
<span class="label">Tareas</span>
</a>
<a
href="/app/groups"
class:active={$page.url.pathname.startsWith("/app/groups")}
aria-label="Grupos"
>
<span class="icon"
><svg viewBox="0 0 122.88 91.99"
><g
><path
class="tabbar-icon-svg"
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
/></g
></svg
></span
>
<span class="label">Grupos</span>
</a>
<a
href="/app/preferences"
class:active={$page.url.pathname.startsWith("/app/preferences")}
aria-label="Recordatorios"
>
<span class="icon"
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
><path
class="tabbar-icon-svg"
fill-rule="nonzero"
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
/></svg
></span
>
<span class="label">Alertas</span>
</a>
<a
href="/app/integrations"
class="calendar"
class:active={$page.url.pathname.startsWith("/app/integrations")}
aria-label="Calendarios"
>
<span class="icon"
><svg viewBox="0 0 110.01 122.88"
><g
><path
class="tabbar-icon-svg"
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
/></g
></svg
></span
>
<span class="label">Calendarios</span>
</a>
<form
method="POST"
action="/api/logout"
class="logout-tab"
aria-label="Salir"
>
<button type="submit">
<span class="icon"
><svg viewBox="0 0 113.525 122.879"
><g
><path
class="tabbar-icon-svg"
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
/></g
></svg
></span
>
<span class="label">Salir</span>
</button>
</form>
</nav>
<Toast />
<style>
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
backdrop-filter: saturate(180%) blur(8px);
-webkit-backdrop-filter: saturate(180%) blur(8px);
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-3);
min-height: 58px;
}
.brand {
font-weight: 700;
color: var(--color-primary);
text-decoration: none;
letter-spacing: 0.2px;
font-size: 1.05rem;
}
.brand img {
display: block;
height: 48px;
border: 1px solid transparent;
border-radius: 50%;
}
.nav {
display: flex;
gap: var(--space-2);
margin-left: auto;
}
.nav a {
position: relative;
padding: 8px 12px;
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
}
.nav a:hover,
.nav a:focus-visible {
background: rgba(0, 0, 0, 0.04);
}
.nav a.active {
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-weight: 600;
}
.nav a.active::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 3px;
height: 2px;
background: var(--color-primary);
border-radius: 1px;
opacity: 0.9;
}
@media (prefers-color-scheme: dark) {
.nav a:hover,
.nav a:focus-visible {
background: rgba(255, 255, 255, 0.06);
}
.nav a.active {
background: rgba(96, 165, 250, 0.14);
}
}
.logout {
margin-left: var(--space-2);
min-height: 36px;
padding: 0 10px;
}
.main {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
.app-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
backdrop-filter: saturate(180%) blur(8px);
-webkit-backdrop-filter: saturate(180%) blur(8px);
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-3);
min-height: 58px;
}
.brand {
font-weight: 700;
color: var(--color-primary);
text-decoration: none;
letter-spacing: 0.2px;
font-size: 1.05rem;
}
.nav {
display: flex;
gap: var(--space-2);
margin-left: auto;
}
.nav a {
position: relative;
padding: 8px 12px;
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
}
.nav a:hover,
.nav a:focus-visible {
background: rgba(0, 0, 0, 0.04);
}
.nav a.active {
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-weight: 600;
}
.nav a.active::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 3px;
height: 2px;
background: var(--color-primary);
border-radius: 1px;
opacity: 0.9;
}
@media (prefers-color-scheme: dark) {
.nav a:hover,
.nav a:focus-visible {
background: rgba(255, 255, 255, 0.06);
}
.nav a.active {
background: rgba(96, 165, 250, 0.14);
}
}
.logout {
margin-left: var(--space-2);
min-height: 36px;
padding: 0 10px;
}
.main {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
/* Barra superior móvil oculta por defecto */
.mobile-topbar {
display: none;
}
/* Barra superior móvil oculta por defecto */
.mobile-topbar {
display: none;
}
/* Barra de pestañas inferior (solo móvil) */
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
display: none;
z-index: 20;
min-height: 48px;
padding-bottom: env(safe-area-inset-bottom);
}
@media (max-width: 768px) {
.tabbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: center;
}
.tabbar a,
.tabbar button {
display: grid;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
color: inherit;
text-decoration: none;
background: transparent;
border: 1px solid var(--color-surface);
box-shadow: 0 0 8px 4px var(--color-border);
}
.tabbar form.logout-tab {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
}
.tabbar a.active {
color: var(--color-primary);
font-weight: 600;
}
/* Atenuar la pestaña de Calendarios cuando está inactiva */
.tabbar a.calendar {
opacity: 0.8;
}
.tabbar a.calendar.active {
opacity: 1;
}
.tabbar .icon {
font-size: 16px;
line-height: 1;
justify-self: center;
}
.tabbar .label {
font-size: 12px;
line-height: 1;
font-family: monospace;
font-size: 0.6rem;
}
.tabbar-icon-svg {
fill: var(--color-text);
}
/* Reservar espacio en el main para no tapar contenido y la barra superior */
.main {
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
}
}
@media (max-width: 480px) {
.tabbar .label {
display: auto;
}
}
/* Barra de pestañas inferior (solo móvil) */
.tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
display: none;
z-index: 20;
min-height: 48px;
padding-bottom: env(safe-area-inset-bottom);
}
@media (max-width: 768px) {
.tabbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: center;
}
.tabbar a,
.tabbar button {
display: grid;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
color: inherit;
text-decoration: none;
background: transparent;
border: 1px solid var(--color-surface);
box-shadow: 0 0 8px 4px var(--color-border);
}
.tabbar form.logout-tab {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
}
.tabbar a.active {
color: var(--color-primary);
font-weight: 600;
}
/* Atenuar la pestaña de Calendarios cuando está inactiva */
.tabbar a.calendar {
opacity: 0.8;
}
.tabbar a.calendar.active {
opacity: 1;
}
.tabbar .icon {
font-size: 16px;
line-height: 1;
justify-self: center;
}
.tabbar .label {
font-size: 12px;
line-height: 1;
font-family: monospace;
font-size: 0.6rem;
}
.tabbar-icon-svg {
fill: var(--color-text);
}
/* Reservar espacio en el main para no tapar contenido y la barra superior */
.main {
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
}
}
@media (max-width: 480px) {
.tabbar .label {
display: auto;
}
}
/* Ocultar header y mostrar topbar en móvil */
@media (max-width: 768px) {
.app-header {
display: none;
}
.mobile-topbar {
display: block;
position: sticky;
top: 0;
z-index: 12;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
min-height: 24px;
padding-top: env(safe-area-inset-top);
}
.mobile-topbar .topbar-inner {
display: flex;
align-items: center;
min-height: 24px;
font-weight: 600;
}
}
/* Ocultar header y mostrar topbar en móvil */
@media (max-width: 768px) {
.app-header {
display: none;
}
.mobile-topbar {
display: block;
position: sticky;
top: 0;
z-index: 12;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
min-height: 24px;
padding-top: env(safe-area-inset-top);
}
.mobile-topbar .topbar-inner {
display: flex;
align-items: center;
min-height: 24px;
font-weight: 600;
}
}
</style>

@ -1,14 +1,16 @@
<script lang="ts">
import "$lib/styles/tokens.css";
import "$lib/styles/base.css";
import favicon from "$lib/assets/favicon.ico";
import Toast from "$lib/ui/feedback/Toast.svelte";
import '$lib/styles/tokens.css';
import '$lib/styles/base.css';
import favicon from '$lib/assets/favicon.svg';
import Toast from '$lib/ui/feedback/Toast.svelte';
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<meta name="robots" content="noindex,nofollow" />
<link rel="icon" href={favicon} />
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<slot />
<Toast />

@ -1,6 +1,5 @@
import type { RequestHandler } from './$types';
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) => {
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';
// 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 = {
status: statusStr,
task: {

@ -1,251 +1,240 @@
<script lang="ts">
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Card from "$lib/ui/layout/Card.svelte";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
type Task = {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
group_id?: string | null;
assignees?: string[];
};
export let data: {
userId: string | null;
groups: GroupItem[];
itemsByGroup: Record<string, Task[]>;
unassignedFirst?: boolean;
};
const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? [...(arr as any)] : [];
}
function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
return sp.toString();
}
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
let collapsed: Record<string, boolean> = {};
function hasTasks(groupId: string): boolean {
const arr = itemsByGroup[groupId] || [];
return Array.isArray(arr) && arr.length > 0;
}
function defaultCollapsedFor(groupId: string): boolean {
// Por defecto, colapsado si no tiene tareas abiertas
return !hasTasks(groupId);
}
function isOpen(groupId: string): boolean {
const v = collapsed[groupId];
if (typeof v === "boolean") return !v;
return !defaultCollapsedFor(groupId);
}
function saveCollapsed() {
try {
const currentIds = new Set(groups.map((g) => g.id));
const pruned: Record<string, boolean> = {};
for (const id of Object.keys(collapsed)) {
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
}
localStorage.setItem(storageKey, JSON.stringify(pruned));
} catch {}
}
function handleToggle(groupId: string, e: Event) {
const open = (e.currentTarget as HTMLDetailsElement).open;
collapsed = { ...collapsed, [groupId]: !open };
saveCollapsed();
}
onMount(() => {
try {
const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : {};
const map: Record<string, boolean> = {};
const currentIds = new Set(groups.map((g) => g.id));
for (const g of groups) {
map[g.id] =
typeof saved?.[g.id] === "boolean"
? !!saved[g.id]
: defaultCollapsedFor(g.id);
}
// Limpieza de claves obsoletas en storage
const cleaned: Record<string, boolean> = {};
for (const k of Object.keys(saved || {})) {
if (currentIds.has(k)) cleaned[k] = !!saved[k];
}
collapsed = map;
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
} catch {
// si falla, dejamos los defaults (basados en tareas)
collapsed = {};
}
});
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateGroupTask(
groupId: string,
detail: {
id: number;
action: string;
patch: Partial<
Task & { completed?: boolean; completed_at?: string | null }
>;
},
) {
const { id, action, patch } = detail;
const arr = itemsByGroup[groupId] || [];
const idx = arr.findIndex((t) => t.id === id);
if (action === "complete") {
if (idx >= 0) {
maintainScrollWhile(() => {
arr.splice(idx, 1);
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
});
}
return;
}
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
}
}
import TaskItem from "$lib/ui/data/TaskItem.svelte";
import Card from "$lib/ui/layout/Card.svelte";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
type GroupItem = {
id: string;
name: string | null;
counts: { open: number; unassigned: number };
};
type Task = {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
group_id?: string | null;
assignees?: string[];
};
export let data: {
userId: string | null;
groups: GroupItem[];
itemsByGroup: Record<string, Task[]>;
unassignedFirst?: boolean;
};
const groups = data.groups || [];
let itemsByGroup: Record<string, Task[]> = {};
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
}
function buildQuery(params: { unassignedFirst?: boolean }) {
const sp = new URLSearchParams();
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
return sp.toString();
}
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
let collapsed: Record<string, boolean> = {};
function hasTasks(groupId: string): boolean {
const arr = itemsByGroup[groupId] || [];
return Array.isArray(arr) && arr.length > 0;
}
function defaultCollapsedFor(groupId: string): boolean {
// Por defecto, colapsado si no tiene tareas abiertas
return !hasTasks(groupId);
}
function isOpen(groupId: string): boolean {
const v = collapsed[groupId];
if (typeof v === "boolean") return !v;
return !defaultCollapsedFor(groupId);
}
function saveCollapsed() {
try {
const currentIds = new Set(groups.map((g) => g.id));
const pruned: Record<string, boolean> = {};
for (const id of Object.keys(collapsed)) {
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
}
localStorage.setItem(storageKey, JSON.stringify(pruned));
} catch {}
}
function handleToggle(groupId: string, e: Event) {
const open = (e.currentTarget as HTMLDetailsElement).open;
collapsed = { ...collapsed, [groupId]: !open };
saveCollapsed();
}
onMount(() => {
try {
const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : {};
const map: Record<string, boolean> = {};
const currentIds = new Set(groups.map((g) => g.id));
for (const g of groups) {
map[g.id] =
typeof saved?.[g.id] === "boolean"
? !!saved[g.id]
: defaultCollapsedFor(g.id);
}
// Limpieza de claves obsoletas en storage
const cleaned: Record<string, boolean> = {};
for (const k of Object.keys(saved || {})) {
if (currentIds.has(k)) cleaned[k] = !!saved[k];
}
collapsed = map;
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
} catch {
// si falla, dejamos los defaults (basados en tareas)
collapsed = {};
}
});
function maintainScrollWhile(mutate: () => void) {
const y = window.scrollY;
mutate();
queueMicrotask(() => window.scrollTo({ top: y }));
}
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
const { id, action, patch } = detail;
const arr = itemsByGroup[groupId] || [];
const idx = arr.findIndex((t) => t.id === id);
if (action === 'complete') {
if (idx >= 0) {
maintainScrollWhile(() => {
arr.splice(idx, 1);
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
});
}
return;
}
if (idx >= 0) {
arr[idx] = { ...arr[idx], ...patch };
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
}
}
</script>
<svelte:head>
<title>Grupos</title>
<meta name="robots" content="noindex,nofollow" />
<title>Grupos</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
{#if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p>
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1 class="title">Grupos</h1>
<div class="toolbar">
<label class="toggle">
<input
type="checkbox"
checked={!!data.unassignedFirst}
on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`;
}}
/>
Sin responsable primero
</label>
</div>
{#each groups as g (g.id)}
<details
class="group"
open={isOpen(g.id)}
on:toggle={(e) => handleToggle(g.id, e)}
>
<summary class="group-header">
<span class="name">{g.name ?? g.id}</span>
<span class="counts">
<span class="badge">tareas: {g.counts.open}</span>
<span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span>
</span>
</summary>
{#if isOpen(g.id)}
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
<Card>
<ul class="list">
{#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem
id={t.id}
description={t.description}
due_date={t.due_date}
display_code={t.display_code}
assignees={t.assignees || []}
currentUserId={data.userId}
groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/>
{/each}
</ul>
</Card>
</div>
{/if}
</details>
{/each}
<h1 class="title">Grupos</h1>
<div class="toolbar">
<label class="toggle">
<input
type="checkbox"
checked={!!data.unassignedFirst}
on:change={(e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
const q = buildQuery({ unassignedFirst: checked });
location.href = q ? `/app/groups?${q}` : `/app/groups`;
}}
/>
Sin responsable primero
</label>
</div>
{#each groups as g (g.id)}
<details
class="group"
open={isOpen(g.id)}
on:toggle={(e) => handleToggle(g.id, e)}
>
<summary class="group-header">
<span class="name">{g.name ?? g.id}</span>
<span class="counts">
<span class="badge">tareas: {g.counts.open}</span>
<span class="badge warn">🙅‍♂️: {g.counts.unassigned}</span>
</span>
</summary>
{#if isOpen(g.id)}
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
<Card>
<ul class="list">
{#each itemsByGroup[g.id] || [] as t (t.id)}
<TaskItem
id={t.id}
description={t.description}
due_date={t.due_date}
display_code={t.display_code}
assignees={t.assignees || []}
currentUserId={data.userId}
groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
on:changed={(e) => updateGroupTask(g.id, e.detail)}
/>
{/each}
</ul>
</Card>
</div>
{/if}
</details>
{/each}
{/if}
<style>
.title {
margin-bottom: 0.75rem;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.toggle {
display: inline-flex;
gap: 6px;
align-items: center;
}
.group {
margin: 0.5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.group-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.group-header .name {
font-weight: 600;
}
.counts {
display: inline-flex;
gap: 0.5rem;
}
.badge {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 12px;
align-self: center;
white-space: nowrap;
}
.badge.warn {
border-color: var(--color-warning);
}
.list {
margin: 0;
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
list-style: none;
}
.title {
margin-bottom: 0.75rem;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
}
.toggle {
display: inline-flex;
gap: 6px;
align-items: center;
}
.group {
margin: 0.5rem 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.group-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.group-header .name {
font-weight: 600;
}
.counts {
display: inline-flex;
gap: 0.5rem;
}
.badge {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 12px;
}
.badge.warn {
border-color: var(--color-warning);
}
.list {
margin: 0;
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
list-style: none;
}
</style>

@ -40,34 +40,16 @@ export const GET: RequestHandler = async (event) => {
<title>Acceder</title>
<meta name="robots" content="noindex,nofollow" />
<meta name="referrer" content="no-referrer" />
<meta name="color-scheme" content="light dark" />
<style>
:root { color-scheme: light dark; }
body {
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;
}
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 2rem; }
.card { max-width: 480px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
button[disabled] { opacity: .6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #0b0b0b; color: #eeeeee; }
.card { border-color: #333333; background: #161616; }
}
</style>
</head>
<body>
<div class="card">
<h1>Acceso a las tareas</h1>
<p>Pulsa Continuar. Si no funciona, copia y pega el enlace en tu navegador.</p>
<h1>Acceso seguro</h1>
<p>Para continuar, pulsa Continuar. Si no funciona, asegúrate de abrir este enlace en tu navegador.</p>
<form method="POST" action="/login">
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<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).
- REMINDERS_GRACE_MINUTES: minutos de gracia tras la hora programada para enviar recordatorios atrasados (por defecto 60).
- GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover'
- REACTIONS_ENABLED: 'true'|'false' para activar reacciones (por defecto 'false'). Ej.: REACTIONS_ENABLED='true'
- REACTIONS_SCOPE: 'groups'|'all' para limitar reacciones a grupos o permitir en DMs (por defecto 'groups'). Ej.: REACTIONS_SCOPE='groups'
- REACTIONS_TTL_DAYS: días para permitir la reacción ✅ al completar respecto al mensaje origen (por defecto 14). Ej.: REACTIONS_TTL_DAYS='14'
- RQ_REACTIONS_MAX_ATTEMPTS: reintentos máximos para jobs de reacción (si no se define, aplica el global). Ej.: RQ_REACTIONS_MAX_ATTEMPTS='3'
- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222'
- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us'
- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true'
@ -115,10 +111,6 @@ Métricas de referencia
- commands_blocked_total (counter).
- sync_skipped_group_total (counter).
- admin_actions_total_allow, admin_actions_total_block (counters).
- Reacciones del bot:
- reactions_enqueued_total{emoji=robot|warn|check|other}
- reactions_sent_total{emoji=...}
- reactions_failed_total{emoji=...}
- Añadir nuevas métricas usando Metrics.inc/set y documentarlas aquí.
Buenas prácticas

@ -1,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);`);
} 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 });
}
try {
const res = await GroupSyncService.syncGroups();
await GroupSyncService.syncGroups();
GroupSyncService.refreshActiveGroupsCache();
const changed = GroupSyncService.getLastChangedActive();
if (changed.length > 0) {
await GroupSyncService.syncMembersForGroups(changed);
} else {
await GroupSyncService.syncMembersForActiveGroups();
}
await GroupSyncService.syncMembersForActiveGroups();
} catch (e) {
console.error('❌ Error handling groups.upsert:', e);
}
@ -440,14 +435,15 @@ export class WebhookServer {
return;
}
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 {
GroupSyncService.ensureGroupExists(data.key.remoteJid);
try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {}
GroupSyncService.refreshActiveGroupsCache();
await GroupSyncService.syncMembersForGroup(data.key.remoteJid);
} catch (e) {
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;
// Delegar el manejo del comando
const messageId = typeof data?.key?.id === 'string' ? data.key.id : null;
const participantForKey = typeof data?.key?.participantAlt === 'string'
? data.key.participantAlt
: (typeof data?.key?.participant === 'string' ? data.key.participant : null);
const outcome = await CommandService.handleWithOutcome({
const responses = await CommandService.handle({
sender: normalizedSenderId,
groupId: data.key.remoteJid,
message: messageText,
mentions,
messageId: messageId || undefined,
participant: participantForKey || undefined,
fromMe: !!data?.key?.fromMe
mentions
});
const responses = outcome.responses;
// Encolar respuestas si las hay
if (responses.length > 0) {
await ResponseQueue.add(responses);
}
// Reaccionar al mensaje del comando con outcome explícito
try {
const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(reactionsEnabled);
if (!enabled) return;
if (!messageId) return;
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
const isGroup = isGroupId(data.key.remoteJid);
if (scope !== 'all' && !isGroup) return;
// Respetar gating 'enforce'
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce' && isGroup) {
try {
if (!AllowedGroups.isAllowed(data.key.remoteJid)) {
return;
}
} catch {}
}
const emoji = outcome.ok ? '🤖' : '⚠️';
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_skipped_total', 0);
Metrics.inc('onboarding_assign_failures_total', 0);
// Precalentar métricas de reacciones por emoji
for (const emoji of ['robot', 'warn', 'check', 'other']) {
Metrics.inc('reactions_enqueued_total', 0, { emoji });
Metrics.inc('reactions_sent_total', 0, { emoji });
Metrics.inc('reactions_failed_total', 0, { emoji });
}
} catch {}
if (process.env.NODE_ENV !== 'test') {

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 _groupsIntervalMs: 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 }> {
if (!this.shouldSync(force)) {
@ -84,7 +82,6 @@ export class GroupSyncService {
const startedAt = Date.now();
Metrics.inc('sync_runs_total');
let newlyActivatedIds: string[] = [];
try {
const groups = await this.fetchGroupsFromAPI();
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 });
}
// 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 }> = [];
for (const [id, b] of beforeMap.entries()) {
const a = afterMap.get(id);
@ -190,8 +176,6 @@ export class GroupSyncService {
// Duración opcional
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;
} catch (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.
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
// 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');
} else {
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`);
}
} catch (e) {
@ -606,11 +578,6 @@ export class GroupSyncService {
});
if (!response.ok) {
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)}`);
}
const raw = await response.text();
@ -683,22 +650,6 @@ export class GroupSyncService {
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.
* Idempotente y atómico por grupo.
@ -970,57 +921,10 @@ export class GroupSyncService {
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 {
this.cacheActiveGroups();
}
public static getLastChangedActive(): string[] {
try {
return Array.from(this._lastChangedActive || []);
} catch {
return [];
}
}
public static startGroupsScheduler(): void {
if (process.env.NODE_ENV === 'test') return;
if (this._groupsSchedulerRunning) return;
@ -1279,29 +1183,4 @@ export class GroupSyncService {
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 { db, ensureUserExists } from '../db';
import { db } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
@ -16,8 +16,6 @@ export class IdentityService {
const a = normalizeWhatsAppId(alias || '');
const u = normalizeWhatsAppId(userId || '');
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 {
this.dbInstance.prepare(`
INSERT INTO user_aliases (alias, user_id, source, created_at, updated_at)
@ -27,14 +25,8 @@ export class IdentityService {
source = COALESCE(excluded.source, source),
updated_at = excluded.updated_at
`).run(a, u, source ?? null);
} catch (e) {
} catch {
// 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
this.inMemoryAliases.set(a, u);

@ -10,20 +10,16 @@ export function getQuickHelp(baseUrl?: string): string {
parts.push(section('Comandos básicos'));
parts.push(
bullets([
`Crear: ${code('/t n Descripción 27-11-14 @Ana')}`,
`Ver mis: ${code('/t mias')} _por privado_`,
`Ver todas: ${code('/t todas')} _por privado_`,
`Más info: ${code('/t info')}`,
`Completar: ${code('/t x 26')} _(máx. 10 a la vez)_`,
`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')}`,
`${code('/t n ...')} crear (acepta fecha y menciones)`,
`${code('/t ver mis')} por DM · ${code('/t ver todos')}`,
`${code('/t x 26')} completar (máx. 10) · ${code('/t tomar 12')} · ${code('/t soltar 26')}`,
`${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
`${code('/t web')}`,
])
);
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');
@ -37,7 +33,7 @@ export function getFullHelp(baseUrl?: string): string {
out.push(
bullets([
`${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”.',
'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(
bullets([
`${code('/t mias')} tus pendientes (por privado).`,
`${code('/t todas')} tus pendientes + “sin responsable”.`,
'Nota: no respondo en grupos; usa estos comandos por privado.',
`${code('/t ver grupo')} pendientes del grupo actual (desde grupo activo).`,
`${code('/t ver mis')} tus pendientes (por DM).`,
`${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.',
'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(
bullets([
'Puedes escribir fechas en formato `2027-09-04` o `27-09-04`',
'`hoy` y `mañana` también son expresiones válidas',
'`YYYY-MM-DD` o `YY-MM-DD` (se expande a `20YY-MM-DD`).',
'`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(
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([
'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.',
'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,
BASE_BACKOFF_MS: process.env.RQ_BASE_BACKOFF_MS ? Number(process.env.RQ_BASE_BACKOFF_MS) : 5000,
MAX_BACKOFF_MS: process.env.RQ_MAX_BACKOFF_MS ? Number(process.env.RQ_MAX_BACKOFF_MS) : 3600000,
REACTIONS_MAX_ATTEMPTS: process.env.RQ_REACTIONS_MAX_ATTEMPTS ? Number(process.env.RQ_REACTIONS_MAX_ATTEMPTS) : null,
// Limpieza/retención (configurable por entorno)
CLEANUP_ENABLED: process.env.RQ_CLEANUP_ENABLED !== 'false',
@ -107,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 {
return {
apikey: process.env.EVOLUTION_API_KEY || '',
@ -254,51 +122,6 @@ export const ResponseQueue = {
return { ok: false, error: msg };
}
// Detectar jobs de reacción
let meta: any = null;
try { meta = item.metadata ? JSON.parse(item.metadata) : null; } catch {}
if (meta && meta.kind === 'reaction') {
const reactionUrl = `${baseUrl}/message/sendReaction/${instance}`;
const chatId = String(meta.chatId || '');
const messageId = String(meta.messageId || '');
const emoji = String(meta.emoji || '');
const emojiLabel = emoji === '✅' ? 'check' : (emoji === '🤖' ? 'robot' : (emoji === '⚠️' ? 'warn' : 'other'));
if (!chatId || !messageId || !emoji) {
return { ok: false, error: 'invalid_reaction_metadata' };
}
const 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
const url = `${baseUrl}/message/sendText/${instance}`;
@ -415,15 +238,6 @@ export const ResponseQueue = {
updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')
WHERE 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) {
@ -453,50 +267,6 @@ export const ResponseQueue = {
`).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) {
while (this._running) {
try {
@ -524,13 +294,8 @@ export const ResponseQueue = {
continue;
}
// 5xx o error de red: reintento con backoff si no superó el máximo (ajustado para reacciones)
let metaForMax: any = null;
try { metaForMax = item.metadata ? JSON.parse(String(item.metadata)) : null; } catch {}
const isReactionJob = !!(metaForMax && metaForMax.kind === 'reaction');
const effectiveMax = isReactionJob && this.REACTIONS_MAX_ATTEMPTS ? this.REACTIONS_MAX_ATTEMPTS : this.MAX_ATTEMPTS;
if (attemptsNow >= effectiveMax) {
// 5xx o error de red: reintento con backoff si no superó el máximo
if (attemptsNow >= this.MAX_ATTEMPTS) {
this.markFailed(item.id, errMsg, status, attemptsNow);
continue;
}

@ -70,29 +70,13 @@ export class WebhookManager {
}
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 {
webhook: {
url: process.env.WEBHOOK_URL!,
enabled: true,
webhook_by_events: 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 { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp';
import { ResponseQueue } from '../services/response-queue';
import { Metrics } from '../services/metrics';
type CreateTaskInput = {
description: string;
@ -280,63 +278,6 @@ export class TaskService {
`)
.run(ensured, taskId);
// Fase 2: reacción ✅ al completar dentro del TTL y con gating
try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true','1','yes','on'].includes(rxEnabled);
if (enabled) {
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 {
status: 'updated',
task: {
@ -412,7 +353,7 @@ export class TaskService {
const existing = this.dbInstance
.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
WHERE id = ?
`)
@ -483,7 +424,7 @@ export class TaskService {
const existing = this.dbInstance
.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
WHERE id = ?
`)
@ -515,7 +456,7 @@ export class TaskService {
`).get(ensuredUser, taskId) as any;
const cnt = Number(stats?.cnt || 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 {
status: 'forbidden_personal',
task: {
@ -607,7 +548,7 @@ export class TaskService {
const row = this.dbInstance.prepare(`
SELECT id, description, due_date, display_code
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
`).get(displayCode) as any;
if (!row) return null;

@ -1,14 +1,14 @@
export const ICONS = {
create: '📝',
complete: '✅',
assignNotice: '📬',
reminder: '⏰',
date: '📅',
unassigned: '🙅',
take: '✋',
unassign: '↩️',
info: '',
warn: '⚠️',
person: '👤',
people: '👥',
create: '📝',
complete: '✅',
assignNotice: '📬',
reminder: '⏰',
date: '📅',
unassigned: '🚫👤',
take: '✋',
unassign: '↩️',
info: '',
warn: '⚠️',
person: '👤',
people: '👥',
} as const;

@ -71,7 +71,7 @@ describe('Database', () => {
.query("PRAGMA table_info(users)")
.all()
.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', () => {

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

File diff suppressed because it is too large Load Diff

@ -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;
}
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) => ({
sender,
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 () => {
const taskId = createTask('Desc tomar', '999', '2025-09-12');
const dc = getDisplayCode(taskId);
const r1 = await CommandService.handle(ctx('111', `/t tomar ${dc}`));
const r1 = await CommandService.handle(ctx('111', `/t tomar ${taskId}`));
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('📅'); // 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');
});
@ -82,9 +72,8 @@ describe('CommandService - /t tomar y /t soltar', () => {
const comp = TaskService.completeTask(taskId, '111');
expect(comp.status).toBe('updated');
const dc = getDisplayCode(taskId);
const res = await CommandService.handle(ctx('222', `/t tomar ${dc}`));
expect(res[0].message).toContain('no encontrada');
const res = await CommandService.handle(ctx('222', `/t tomar ${taskId}`));
expect(res[0].message).toContain('ya estaba completada');
});
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 () => {
const taskId = createTask('Desc soltar', '999', '2025-09-12', ['111']);
const dc = getDisplayCode(taskId);
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`));
const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
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 () => {
const taskId = createTask('Nunca asignada a 111', '999', null, ['222']);
const dc = getDisplayCode(taskId);
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`));
const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
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');
expect(comp.status).toBe('updated');
const dc = getDisplayCode(taskId);
const res = await CommandService.handle(ctx('111', `/t soltar ${dc}`));
expect(res[0].message).toContain('no encontrada');
const res = await CommandService.handle(ctx('111', `/t soltar ${taskId}`));
expect(res[0].message).toContain('ya estaba completada');
});
});

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

@ -7,325 +7,334 @@ import { GroupSyncService } from '../../../src/services/group-sync';
let memDb: Database;
const testContextBase = {
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [] as string[],
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [] as string[],
};
beforeEach(() => {
memDb = new Database(':memory:');
initializeDatabase(memDb);
(CommandService as any).dbInstance = memDb;
(TaskService as any).dbInstance = memDb;
(GroupSyncService as any).dbInstance = memDb;
GroupSyncService.activeGroupsCache.clear();
memDb = new Database(':memory:');
initializeDatabase(memDb);
(CommandService as any).dbInstance = memDb;
(TaskService as any).dbInstance = memDb;
(GroupSyncService as any).dbInstance = memDb;
GroupSyncService.activeGroupsCache.clear();
});
test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”', async () => {
// Insert group and cache it as active
memDb.exec(`
// Insert group and cache it as active
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// Crear 12 tareas sin asignados en el grupo
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Task ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '1234567890',
});
}
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('No respondo en grupos.');
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// Crear 12 tareas sin asignados en el grupo
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Task ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '1234567890',
});
}
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
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 () => {
// Insert groups and cache them
memDb.exec(`
// Insert groups and cache them
memDb.exec(`
INSERT OR REPLACE INTO groups (id, community_id, name, active) VALUES
('test-group@g.us', 'test-community', 'Test Group', 1),
('group-2@g.us', 'test-community', 'Group 2', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
GroupSyncService.activeGroupsCache.set('group-2@g.us', 'Group 2');
// Crear 2 tareas asignadas al usuario en distintos grupos
const t1 = TaskService.createTask({
description: 'G1 Task',
due_date: '2025-11-20',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const t2 = TaskService.createTask({
description: 'G2 Task',
due_date: '2025-11-25',
group_id: 'group-2@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
const responses = await CommandService.handle({
sender: '1234567890',
// Contexto de DM: usar un JID que NO sea de grupo
groupId: '1234567890@s.whatsapp.net',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message;
expect(msg).toContain('Test Group');
expect(msg).toContain('Group 2');
expect(msg).toMatch(/- `\d{4}` G1 Task/);
expect(msg).toMatch(/- `\d{4}` G2 Task/);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
GroupSyncService.activeGroupsCache.set('group-2@g.us', 'Group 2');
// Crear 2 tareas asignadas al usuario en distintos grupos
const t1 = TaskService.createTask({
description: 'G1 Task',
due_date: '2025-11-20',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const t2 = TaskService.createTask({
description: 'G2 Task',
due_date: '2025-11-25',
group_id: 'group-2@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
const responses = await CommandService.handle({
sender: '1234567890',
// Contexto de DM: usar un JID que NO sea de grupo
groupId: '1234567890@s.whatsapp.net',
mentions: [],
message: '/t ver'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message;
expect(msg).toContain('Test Group');
expect(msg).toContain('Group 2');
expect(msg).toMatch(/- `\d{4}` G1 Task/);
expect(msg).toMatch(/- `\d{4}` G2 Task/);
});
test('completar tarea: camino feliz, ya completada y no encontrada', async () => {
// Insertar grupo y cache
memDb.exec(`
// Insertar grupo y cache
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
const taskId = TaskService.createTask({
description: 'Completar yo',
due_date: '2025-10-10',
group_id: 'test-group@g.us',
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
let responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${dc}`
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toMatch(/^✅ `\d{4}` _completada_/);
// 2) Ya completada (ahora no debe resolverse por display_code → no encontrada)
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${dc}`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('no encontrada');
// 3) No encontrada
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x 999999`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('no encontrada');
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
const taskId = TaskService.createTask({
description: 'Completar yo',
due_date: '2025-10-10',
group_id: 'test-group@g.us',
created_by: '1111111111',
});
// 1) Camino feliz
let responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${taskId}`
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toMatch(/^✅ `\d{4}` completada/);
// 2) Ya completada
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x ${taskId}`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('ya estaba completada');
// 3) No encontrada
responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: `/t x 999999`
});
expect(responses.length).toBe(1);
expect(responses[0].message).toContain('no encontrada');
});
test('ver sin en grupo activo: solo sin dueño y paginación', async () => {
// Insertar grupo y cachearlo como activo
memDb.exec(`
// Insertar grupo y cachearlo como activo
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// 12 tareas sin dueño (para provocar “… y 2 más” con límite 10)
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Unassigned ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '9999999999',
});
}
// 2 tareas asignadas (no deben aparecer en "ver sin")
TaskService.createTask({
description: 'Asignada 1',
due_date: '2025-11-01',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
TaskService.createTask({
description: 'Asignada 2',
due_date: '2025-11-02',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver sin'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message;
expect(msg).toContain('No respondo en grupos.');
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// 12 tareas sin dueño (para provocar “… y 2 más” con límite 10)
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Unassigned ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '9999999999',
});
}
// 2 tareas asignadas (no deben aparecer en "ver sin")
TaskService.createTask({
description: 'Asignada 1',
due_date: '2025-11-01',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
TaskService.createTask({
description: 'Asignada 2',
due_date: '2025-11-02',
group_id: 'test-group@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver sin'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message;
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 () => {
const responses = await CommandService.handle({
sender: '1234567890',
// DM: no es un JID de grupo
groupId: '1234567890@s.whatsapp.net',
mentions: [],
message: '/t ver sin'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('No tienes tareas pendientes.');
const responses = await CommandService.handle({
sender: '1234567890',
// DM: no es un JID de grupo
groupId: '1234567890@s.whatsapp.net',
mentions: [],
message: '/t ver sin'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
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 () => {
// Insertar grupo y cachearlo como activo
memDb.exec(`
// Insertar grupo y cachearlo como activo
memDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('test-group@g.us', 'test-community', 'Test Group', 1)
`);
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// Tus tareas (2 asignadas al usuario)
TaskService.createTask({
description: 'Mi Tarea 1',
due_date: '2025-10-10',
group_id: 'test-group@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
TaskService.createTask({
description: 'Mi Tarea 2',
due_date: '2025-10-11',
group_id: 'test-group@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
// 12 sin dueño en el grupo (provoca “… y 2 más” en esa sección)
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '9999999999',
});
}
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver todos'
});
expect(responses.length).toBe(1);
const msg = responses[0].message;
expect(msg).toContain('No respondo en grupos.');
GroupSyncService.activeGroupsCache.clear();
GroupSyncService.activeGroupsCache.set('test-group@g.us', 'Test Group');
// Tus tareas (2 asignadas al usuario)
TaskService.createTask({
description: 'Mi Tarea 1',
due_date: '2025-10-10',
group_id: 'test-group@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
TaskService.createTask({
description: 'Mi Tarea 2',
due_date: '2025-10-11',
group_id: 'test-group@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
// 12 sin dueño en el grupo (provoca “… y 2 más” en esa sección)
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'test-group@g.us',
created_by: '9999999999',
});
}
const responses = await CommandService.handle({
sender: '1234567890',
groupId: 'test-group@g.us',
mentions: [],
message: '/t ver todos'
});
expect(responses.length).toBe(1);
const msg = responses[0].message;
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 () => {
// 2 tareas asignadas al usuario en cualquier grupo (no importa para este test)
TaskService.createTask({
description: 'Mi Tarea A',
due_date: '2025-11-20',
group_id: 'group-1@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
TaskService.createTask({
description: 'Mi Tarea B',
due_date: '2025-11-21',
group_id: 'group-2@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const responses = await CommandService.handle({
sender: '1234567890',
groupId: '1234567890@s.whatsapp.net', // DM
mentions: [],
message: '/t ver todos'
});
expect(responses.length).toBe(1);
const msg = responses[0].message;
expect(msg).toContain('Tus tareas');
expect(msg).toContain(' Para ver tareas sin responsable');
// 2 tareas asignadas al usuario en cualquier grupo (no importa para este test)
TaskService.createTask({
description: 'Mi Tarea A',
due_date: '2025-11-20',
group_id: 'group-1@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
TaskService.createTask({
description: 'Mi Tarea B',
due_date: '2025-11-21',
group_id: 'group-2@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const responses = await CommandService.handle({
sender: '1234567890',
groupId: '1234567890@s.whatsapp.net', // DM
mentions: [],
message: '/t ver todos'
});
expect(responses.length).toBe(1);
const msg = responses[0].message;
expect(msg).toContain('Tus tareas');
expect(msg).toContain(' Para ver tareas sin responsable');
});
afterEach(() => {
try { memDb.close(); } catch { }
try { memDb.close(); } catch {}
});
describe('CommandService', () => {
test('should ignore non-tarea commands', async () => {
const responses = await CommandService.handle({
...testContextBase,
message: '/othercommand'
});
expect(responses).toEqual([]);
});
test('acepta alias /t y responde con formato compacto', async () => {
const responses = await CommandService.handle({
...testContextBase,
message: '/t n Test task'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
// Debe empezar con "📝 `0001` "
expect(responses[0].message).toMatch(/^📝 `\d{4}` /);
// Debe mostrar la descripción en texto plano (sin cursiva)
expect(responses[0].message).toContain(' Test task');
expect(responses[0].message).not.toContain('_Test task_');
// No debe usar el texto antiguo "Tarea <id> creada"
expect(responses[0].message).not.toMatch(/Tarea \d+ creada/);
});
test('should return error response on failure', async () => {
// Forzar error temporalmente
const original = TaskService.createTask;
(TaskService as any).createTask = () => { throw new Error('forced'); };
const responses = await CommandService.handle({
...testContextBase,
message: '/tarea nueva Test task'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toBe('Error processing command');
// Restaurar
(TaskService as any).createTask = original;
});
test('should ignore non-tarea commands', async () => {
const responses = await CommandService.handle({
...testContextBase,
message: '/othercommand'
});
expect(responses).toEqual([]);
});
test('acepta alias /t y responde con formato compacto', async () => {
const responses = await CommandService.handle({
...testContextBase,
message: '/t n Test task'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
// Debe empezar con "📝 `0001` "
expect(responses[0].message).toMatch(/^📝 `\d{4}` /);
// Debe mostrar la descripción en texto plano (sin cursiva)
expect(responses[0].message).toContain(' Test task');
expect(responses[0].message).not.toContain('_Test task_');
// No debe usar el texto antiguo "Tarea <id> creada"
expect(responses[0].message).not.toMatch(/Tarea \d+ creada/);
});
test('should return error response on failure', async () => {
// Forzar error temporalmente
const original = TaskService.createTask;
(TaskService as any).createTask = () => { throw new Error('forced'); };
const responses = await CommandService.handle({
...testContextBase,
message: '/tarea nueva Test task'
});
expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toBe('Error processing command');
// Restaurar
(TaskService as any).createTask = original;
});
});

@ -16,7 +16,7 @@ describe('CommandService - comando desconocido devuelve ayuda rápida', () => {
expect(msg).toContain('COMANDO NO RECONOCIDO');
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 configurar');
});

@ -5,7 +5,7 @@ describe('Help content (centralizado)', () => {
it('quick help incluye comandos básicos y /t web', () => {
const s = getQuickHelp();
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 configurar');
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', () => {
const s = getFullHelp();
// Scopes
expect(s).toContain('/t mias');
expect(s).toContain('/t todas');
expect(s).toContain('/t ver grupo');
expect(s).toContain('/t ver mis');
expect(s).toContain('/t ver todos');
expect(s).toContain('/t ver sin');
// Fechas
expect(s).toContain('27-09-04');
expect(s).toContain('YY-MM-DD');
expect(s).toContain('20YY');
expect(s).toContain('hoy');
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