refactor: webhook handler, group sync, command handlers, tests

- Refactor webhook handler with improved error handling and auto-ensure
- Break group-sync into modular services (changes, deactivation, membership, scheduler)
- Add startup.ts bootstrap with health checks and metrics
- Refactor command handlers (nueva, completar, tomar, soltar, ver) for gating/resilience
- Remove unused Svelte UI components (Badge, Skeleton, GroupCard, etc.)
- Add ICS helpers, task helpers, preferences helpers to web lib
- Remove legacy help.ts message service
- Restructure tests: split monolithic server.test.ts into focused files
- Add server test harness and coverage/conformance tests
- Update docs (commands inventory, user guide, operational docs)
- Command trigger simplified to 't' and task name (no slash)
- Add .gitignore entries for fallow, sift, sq artifacts
main
borja 1 month ago
parent 82d633124a
commit b7ed1ad013

7
.gitignore vendored

@ -35,6 +35,13 @@ docs/evolution-api.envs
.DS_Store
.aider*
# Tool artifacts (fallow, sift, sq)
.fallow*
.sift*
dupes.json
tasks.jsonl
README.old.md
# DB
*.db
*.db-wal

@ -43,9 +43,8 @@ EXPOSE 3000
# Declare volume for persistent data by default
VOLUME ["/app/data"]
# Make script executable
COPY startup.sh ./
RUN chmod +x startup.sh
# Copy startup script
COPY startup.ts ./
# Start via wrapper script
CMD ["./startup.sh"]
CMD ["bun", "run", "startup.ts"]

@ -18,7 +18,7 @@ Taskbot ayuda a coordinar grupos en WhatsApp: crea y asigna tareas, recuerda pen
- Control de acceso por grupos: modos off, discover y enforce; aprobación y bloqueo por admins.
- Sincronización de grupos y miembros con cachés y schedulers configurables.
- Alias de identidad con normalización de IDs.
- Acceso web por token mágico (/t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso.
- Acceso web por token mágico (t web) con página intermedia anti-preview y sesión por cookie (idle 2h); tokens de 10 min de un solo uso.
- Métricas listas para Prometheus en el endpoint /metrics.
- 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.
@ -82,7 +82,7 @@ Variables clave:
- ALLOWED_GROUPS (semilla inicial), NOTIFY_ADMINS_ON_DISCOVERY.
- HEALTH_CHECK_INTERVAL_MS (ms, por defecto 60000) y HEALTH_CHECK_RESTART_COOLDOWN_MS (ms, por defecto 900000).
- METRICS_ENABLED, PORT.
- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por /t web).
- WEB_BASE_URL (host público de la web para generar enlaces absolutos; usado por t web).
- Rate limit: RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST.
- Intervalos y retención: GROUP_SYNC_INTERVAL_MS, GROUP_MEMBERS_SYNC_INTERVAL_MS, GROUP_MEMBERS_INACTIVE_RETENTION_DAYS.
- DB_PATH: ruta al archivo SQLite. Tiene prioridad sobre DATA_DIR y permite aislar BD por rama/entorno. Ej.: DB_PATH='./data/tasks.db'

@ -1,293 +0,0 @@
# Task WhatsApp Chatbot
Un chatbot de WhatsApp para gestionar tareas en grupos, integrado con Evolution API. Diseño “solo DM”: el bot no publica en grupos; todas las respuestas se envían por mensaje directo al autor (opcionalmente puede enviarse un breve resumen al grupo al crear, configurable).
## Cómo se usa (mini guía para usuarios)
- Principios
- Comandos con prefijo “/t” o “/tarea”.
- En grupo: el bot responde por DM al autor (no escribe en el grupo).
- Fechas en formato dd/MM en mensajes; puedes escribir “hoy” o “mañana” al crear; la zona horaria se controla con la variable de entorno TZ (por defecto Europe/Madrid).
- Comandos y alias principales
- Crear: “/t nueva Acta reunión mañana @600123456
- Ver pendientes del grupo: “/t ver grupo”
- Ver tus pendientes: “/t ver mis”
- Completar: “/t x 26” (alias: hecho, completar, done)
- Tomar: “/t tomar 26”
- Soltar: “/t soltar 26”
- Configurar recordatorios: “/t configurar daily|weekly|off”
- Ayuda: “/t” o “/t ayuda”
- Notas y reglas
- Si creas en grupo y no mencionas a nadie: queda “sin responsable”.
- Si creas por DM y no mencionas a nadie: se asigna al creador.
- En DM, WhatsApp no muestra chips de mención de terceros; se incluye @número como texto para acción rápida.
- Guía completa con alias, reglas y ejemplos: docs/USER_GUIDE.md
## Características
- Crear, listar, completar, tomar/soltar tareas; ayuda por DM.
- Recordatorios por DM (daily/weekly) por usuario; evita duplicados y respeta TZ.
- Cola de respuestas persistente con reintentos (backoff exponencial + jitter) y recuperación tras reinicios.
- Nombres amigables vía caché de contactos (sin llamadas de red en tests).
- Sincronización de miembros de grupos (snapshot periódica + webhooks incrementales; tolerante a fallos).
- Mensajes compactos con emojis y cursiva; fechas dd/MM; vencidas con ⚠️.
- Observabilidad mínima: /metrics (Prometheus por defecto, JSON opcional) y /health detallado.
## Requisitos
- Evolution API accesible (recomendado: misma red interna Docker).
- Bun para desarrollo local; Docker/CapRover para despliegue.
- SQLite embebido con persistencia en data/.
## Variables de entorno
- Requeridas
- EVOLUTION_API_URL: URL de Evolution API (p.ej., http://evolution-api:3000).
- EVOLUTION_API_KEY: API key de Evolution.
- EVOLUTION_API_INSTANCE: nombre de la instancia en Evolution.
- WHATSAPP_COMMUNITY_ID: comunidad principal desde la que sincronizar grupos (jid @g.us).
- CHATBOT_PHONE_NUMBER: número normalizado del bot (evita auto-respuestas).
- WEBHOOK_URL: URL (interna) donde Evolution enviará webhooks.
- PORT: puerto del servidor webhook (p.ej., 3007).
- Opcionales — comportamiento
- TZ: zona horaria para “hoy/mañana” y render de fechas; por defecto Europe/Madrid.
- NOTIFY_GROUP_ON_CREATE: si “true”, envía resumen al grupo al crear (por defecto false).
- GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos; por defecto 24h (mín 10s en desarrollo).
- GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros; por defecto 6h (mín 10s en desarrollo).
- MAX_MEMBERS_SNAPSHOT_AGE_MS: edad máxima (ms) para considerar "fresca" la snapshot de miembros; por defecto 24h.
- GROUP_MEMBERS_ENFORCE: si "true", aplica validación estricta de membresía cuando la snapshot es fresca; por defecto false.
- REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP: si "true", añade sección "sin responsable" en recordatorios solo de tus grupos con membresía activa; por defecto false.
- RATE_LIMIT_PER_MIN: límite por usuario (tokens/min); por defecto 15.
- RATE_LIMIT_BURST: capacidad del bucket; por defecto = RATE_LIMIT_PER_MIN.
- Opcionales — cola de respuestas
- RQ_MAX_ATTEMPTS: reintentos máximos; por defecto 6.
- RQ_BASE_BACKOFF_MS: backoff base en ms; por defecto 5000.
- RQ_MAX_BACKOFF_MS: backoff máximo en ms; por defecto 3600000.
- Opcionales — migraciones
- MIGRATOR_CHECKSUM_STRICT: si "false" desactiva validación estricta de checksum de migraciones; por defecto "true".
- MIGRATIONS_LOG_PATH: ruta del fichero de log de migraciones; por defecto data/migrations.log.
- Opcionales — métricas y mantenimiento
- METRICS_ENABLED: "true"|"false" para habilitar /metrics; por defecto true (desactivado en test).
- METRICS_FORMAT: "prom"|"json"; por defecto "prom".
- GROUP_MEMBERS_INACTIVE_RETENTION_DAYS: días para purgar miembros inactivos; 180 por defecto; 0 desactiva.
- FORCE_SCHEDULERS: "true" para forzar arranque de jobs en NODE_ENV=test.
- Entorno
- NODE_ENV: production | development | test.
Consulta .env.example para un listado comentado con valores de ejemplo.
## Puesta en marcha (local)
- bun install
- Copia .env.example a .env y ajústalo.
- bun run dev (arranca servidor con recarga).
- bun test (ejecuta pruebas con SQLite en memoria).
## Despliegue con Docker/CapRover
- Crea una app y configura:
- Variables de entorno (ver arriba).
- Health check: GET /health.
- Volumen persistente: mapea /app/data a un volumen (persistencia de SQLite).
- Red interna con Evolution API (ideal: no exponer públicamente el webhook).
- El worker de la cola arranca con el servidor (en NODE_ENV=test se desactiva).
- Plan operativo mínimo (CI/CD, healthcheck y backups): ver docs/CI-CD-PLAN.md (decisiones pendientes marcadas).
## Seguridad y buenas prácticas
- Mantén WEBHOOK_URL accesible desde Evolution API preferiblemente en red interna; si se expone, restringe IPs o usa reverse proxy/firewall.
- Gestiona secretos (API keys) como variables en el orquestador.
- Configura backups periódicos del fichero data/tasks.db (las migraciones hacen VACUUM INTO, pero no sustituyen un backup programado).
## Limitaciones conocidas
- Sin orden garantizado por destinatario en la cola.
- /metrics básico sin histogramas ni etiquetas; mejoras futuras.
- Permisos/roles y validación estricta de pertenencia a grupos no implementados.
## Roadmap
- Próximos pasos y estado detallado: ver STATUS.md.
## Testing
- Ejecuta la suite con “bun test”. Llama a Evolution API solo fuera de test.
## Contribución
- PRs bienvenidas. Añade pruebas, ejecuta “bun test” y describe los cambios.
6) Permisos y pertenencia a grupos
- Objetivo: control de quién puede qué, y pertenencia válida.
- Implica: roles y/o verificación de pertenencia; posibles migraciones y sincronización de miembros.
7) Historial de tareas (auditoría ligera)
- Objetivo: trazabilidad de cambios.
- Implica: tabla task_events; eventos en crear/asignar/tomar/soltar/completar; consulta “historial”.
8) Rate limiting (operación segura) — completado
- Objetivo: proteger ante abuso o loops.
- Implementado: token bucket por usuario con límites configurables (RATE_LIMIT_PER_MIN, RATE_LIMIT_BURST) y aviso con cooldown.
9) Sincronización de miembros (opcional)
- Objetivo: conocer miembros activos por grupo para features avanzadas.
- Implica: endpoints Evolution API para miembros; cache/migraciones.
10) Métricas y observabilidad
- Objetivo: visibilidad con bajo coste.
- Implementado: /metrics (Prometheus/JSON) con counters/gauges y /health detallado; pendiente: histogramas/latencias y logging estructurado avanzado.
## 🔑 Key Considerations & Caveats
* **WhatsApp ID Normalization:** Crucial for consistently identifying users and groups. Needs careful implementation to handle edge cases. (Utility function exists).
* **Response Latency:** Sending responses requires an API call back to Evolution. Ensure the `ResponseQueue` processing is efficient.
* **Cola de respuestas:** Persistente en DB; con reintentos (backoff exponencial + jitter), recuperación tras reinicios y limpieza/retención configurables.
* **Group Sync:** The current full sync might be slow or rate-limited with many groups. Delta updates are recommended long-term.
* **Error Handling:** Failures in command processing or response sending should be logged clearly and potentially reported back to the user. Database operations should use transactions for atomicity (especially task+assignment creation).
* **State Management:** The current design is stateless. Complex interactions might require state persistence later.
* **Security:** Ensure group/user validation logic is robust once integrated.
## 🧪 Testing
### Running Tests
```bash
bun test
```
### Test Coverage
- Database initialization and basic operations (`db.test.ts`).
- Webhook validation (basic) (`webhook-manager.test.ts`).
- Command parsing (basic structure) (`command.test.ts`).
- Environment checks (`server.test.ts`).
- Basic error handling (`server.test.ts`).
- WhatsApp ID normalization (`whatsapp.test.ts`).
- Group sync operations (`group-sync.test.ts`).
- Webhook handlers de membresías (alta/baja/cambio de rol) (`group-sync.*.test.ts`).
- **Needed:** Tests for `ensureUserExists` integration, `isGroupActive` integration, `CommandService` logic, `ResponseQueue` processing (mocking API), `TaskService` operations.
- All 170 unit tests passing. Added unit tests for CommandService (date parsing "hoy/mañana", DM help, dd/MM formatting, default assignment rules) y para RemindersService (daily/weekly, duplicados por día, hora/TZ, “… y X más”) y configuración de recordatorios.
## 🧑‍💻 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/implement-user-validation`)
3. Add/update tests for new functionality
4. Ensure tests pass (`bun test`)
5. Submit a pull request
## 📚 Documentation
For detailed API documentation and architecture decisions, see the [docs/](docs/) directory (if created).
---
## 📐 Diseño UX acordado (MVP y siguientes iteraciones)
Este apartado documenta las decisiones de UX aprobadas para el MVP y su evolución inmediata. Objetivo: mínima fricción, cero ruido en grupos y mensajes compactos.
### Principios
- Silencio en grupos: el bot NO publica mensajes en grupos. Cuando alguien usa un comando en un grupo, el bot responde solo por DM al autor, sin dejar mensaje en el grupo.
- Homogeneidad: mismos comandos y comportamiento tanto en la comunidad “Casa” como en la del AMPA.
- Mensajes compactos: máximo 23 líneas, usando emojis y formato WhatsApp (negritas, monoespacio) para legibilidad.
- Aprendizaje progresivo: alias cortos y ayuda accesible por DM.
### Comando base y alias
- Prefijo admitido: “/t” y “/tarea”.
- Subcomandos y sinónimos (aceptar cualquiera, mapear a una acción canónica):
- Crear: n, nueva, crear, +
- Ver: ver, mostrar, listar, ls
- Completar: x, hecho, completar, done
- Tomar: tomar, claim
- Soltar: soltar, unassign
- Ayuda: ayuda, help, ?
- Configurar: configurar, config
### Gramática de “crear tarea”
- Texto libre: descripción.
- Fecha: soportar tokens “hoy” y “mañana” (MVP). Futuro: +2d, +1w, lun/mar/…
- Menciones: “@…” y menciones reales del cliente.
- Asignación por defecto:
- En grupos: si no hay menciones → tarea queda “sin responsable”.
- En DM: si no hay menciones → asignada al creador.
- Comandos de gestión de asignación:
- /t tomar <id> → el usuario se asigna la tarea.
- /t soltar <id> → elimina su asignación, devolviendo la tarea a “sin responsable” si no quedan asignados.
### Listados
- /t ver grupo → devuelve por DM las pendientes del grupo desde el que se invoca (incluye sección “sin responsable”).
- /t ver mis → devuelve por DM las pendientes del usuario agregadas de todos sus grupos.
- Listas extensas: mostrar top N (p. ej., 10) y resumen “y X más…”.
### Completar
- /t x <id> (alias: /t hecho <id>, /t completar <id>)
- Registro de quién completó. Por ahora no se restringe a asignados (permite fluidez); política configurable en el futuro.
- Confirmación solo por DM.
### Ayuda y onboarding
- “/t” sin parámetros o “ayuda” → siempre por DM, con guía corta y 23 ejemplos.
- En grupos: no se escribe nada en el grupo; únicamente el DM al autor.
### Mensajes: plantillas compactas
- Confirmación al crear (DM al creador):
- 📝 26 _Acta de la reunión_
- 📅 12/09
- 🚫👤 sin responsable (Junta AMPA) — o — 👤 @Juan
- DM a asignados:
- 📬 Tarea 26 — 📅 12/09
- _Acta de la reunión_
- Grupo: Junta AMPA
- Completar: /t x 26
- Listado (enviado por DM):
- Junta AMPA
- 26) _Acta…_ — 📅 12/09 — 👤 @Juan
- 27) _Carteles fiesta_ — 📅 10/09 — 🚫👤 sin responsable
- … y 3 más
- Completar (feedback por DM):
- ✅ 26 completada — _Acta…_
- Gracias, Juan.
### Preferencias (MVP)
- Única preferencia: frecuencia de recordatorios por DM: daily | off.
- MVP sin web: el usuario escribe “configurar” por DM y el bot le ofrece elegir “diario” u “off”.
- Por defecto: off (evitar spam). Futuro: hora y zona horaria configurables; magic link a web de configuración.
### Recordatorios
- Resumen diario por DM (si el usuario eligió “diario”):
- ⏰ Recordatorio diario — hoy 12/09
- 26) _Acta…_ — 📅 12/09 — Junta AMPA
- 31) _Pagar comedor_ — hoy — Casa
- 33) _…_ — 📅 15/09 — Casa
- Completar: /t x <id>
- Un solo DM con secciones por comunidad para evitar múltiples mensajes.
### No objetivos del MVP
- No asignar por defecto a “todo el grupo” (evita DMs masivos y responsabilidad difusa).
- No canal “Tareas” compartido por defecto (riesgo de ruido). Se considerará en el futuro solo si hay demanda y optin.
### Plan de implementación (iteraciones)
- Iteración A — UX base y silencios
- Alias de comandos y sinónimos en CommandService.
- Respuestas de todos los comandos únicamente por DM (incluido cuando se invocan en grupos).
- Mensajes compactos con plantillas.
- Soporte de “hoy/mañana”.
- Default sin dueño en grupos; asignar al creador en DMs.
- Nuevos comandos: tomar y soltar.
- Ayuda por DM.
- Iteración B — Listados y completar
- /t ver grupo, /t ver mis.
- /t x <id> con registro de quién completa.
- Tests para alias, hoy/mañana, ver y x.
- Iteración C — Recordatorios
- Preferencia reminder_freq (daily|off) por usuario via “configurar” por DM.
- Job diario que envía el resumen (solo “tus tareas” en MVP).
- Iteración D — (Opcional) Miembros de grupo
- Sincronizar miembros si se necesita incluir “sin responsable” por grupo en recordatorios.
### Cambios técnicos asociados (resumen)
- src/services/command.ts
- Mapeo de sinónimos a acciones canónicas.
- Parser de “hoy/mañana”.
- Subcomandos: ver grupo|mis, x, tomar, soltar.
- Render de mensajes compactos.
- src/server.ts
- Detección de contexto grupo vs DM; nunca responder en grupo (solo DM al autor).
- src/tasks/service.ts
- Permitir tareas sin asignaciones.
- Métodos: claimTask(user_id), unassignTask(user_id), completeTask(id, completed_by).
- src/services/response-queue.ts
- Envío de DMs para ayuda, confirmaciones, listados y recordatorios.
- (Futuro) Preferencias
- Tabla user_preferences(user_id PK, reminder_freq TEXT, updated_at).
- “configurar” por DM en MVP; más adelante, web con magic link (user_tokens).
### Testing sugerido
- Alias de comandos y “/t” sin parámetros → DM de ayuda.
- Crear en grupo sin menciones → sin responsable; no hay mensaje en el grupo; DM al autor.
- Crear en DM sin menciones → asignada al creador.
- “hoy/mañana” en fechas.
- ver grupo y ver mis → DM con paginación/resumen.
- completar, tomar y soltar → reglas y feedback por DM.

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

@ -1,4 +1,4 @@
import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime } from '../../../../../src/utils/datetime';
import { toIsoSqlUTC as coreToIsoSqlUTC, normalizeTime as coreNormalizeTime, ymdUTC, ymdInTZ as coreYmdInTZ } from '../../../../../src/utils/datetime';
/**
* Serializa una fecha en UTC al formato SQL ISO "YYYY-MM-DD HH:MM:SS[.SSS]".
@ -15,14 +15,10 @@ export function normalizeTime(input: string | null | undefined): string | null {
return coreNormalizeTime(input);
}
/**
* Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha).
*/
export function ymdUTC(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
export { ymdUTC };
export function ymdInTZ(d: Date, tz: string): string {
return coreYmdInTZ(d, tz);
}
/**

@ -1,4 +1,4 @@
import { mkdirSync, existsSync } from 'fs';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env';
@ -48,139 +48,156 @@ async function importSqliteDatabase(): Promise<any> {
return (mod as any).Database || (mod as any).default || mod;
}
/**
* Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
* inicializa el esquema (migraciones) y siembra datos de demo.
* Nota: usa bun:sqlite si está disponible; en SSR Node usa better-sqlite3.
*/
async function openDb(filename: string = 'tasks.db'): Promise<any> {
const absolutePath = resolveDbAbsolutePath(filename);
const firstCreate = !existsSync(absolutePath);
// ---------------------------------------------------------------------------
// DB open helpers
// ---------------------------------------------------------------------------
// Crear directorio padre si no existe
function ensureParentDir(absolutePath: string): void {
try {
mkdirSync(dirname(absolutePath), { recursive: true });
} catch (err: any) {
if (err?.code !== 'EEXIST') throw err;
}
}
async function createSqliteInstance(absolutePath: string): Promise<any> {
const DatabaseCtor = await importSqliteDatabase();
const instance = new DatabaseCtor(absolutePath);
applyDefaultPragmas(instance);
return instance;
}
// Auto-inicialización de esquema en desarrollo si falta y seed opcional
if (isDev()) {
// ¿Existe la tabla principal?
let hasTasksTable = false;
try {
instance.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
hasTasksTable = true;
} catch {}
function hasTable(instance: any, table: string): boolean {
try {
instance.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
return true;
} catch {
return false;
}
}
// Si no existe el esquema, aplicar inicialización/migraciones
if (!hasTasksTable) {
const isBun = typeof (globalThis as any).Bun !== 'undefined';
if (isBun) {
// En Bun podemos reutilizar initializeDatabase del repo principal
try {
const dbModule = await import('../../../../../src/db');
if (typeof (dbModule as any).initializeDatabase === 'function') {
(dbModule as any).initializeDatabase(instance);
hasTasksTable = true;
console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).');
}
} catch (e) {
console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e);
}
} else {
// En SSR Node: aplicar migraciones directamente con compat para .query
try {
const mod = await import('../../../../../src/db/migrations/index');
const list = (mod as any).migrations as any[];
const compat: any = instance;
if (typeof compat.query !== 'function') {
compat.query = (sql: string) => ({
all: () => compat.prepare(sql).all(),
get: () => compat.prepare(sql).get()
});
}
try { compat.exec?.(`PRAGMA foreign_keys = ON;`); } catch {}
for (const m of list) {
try {
await (m.up as any)(compat);
} catch (e) {
console.warn('[web/db] Error aplicando migración en dev (Node):', (m as any)?.name ?? '(sin nombre)', e);
}
}
// Verificar de nuevo
try {
compat.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
hasTasksTable = true;
console.info('[web/db] DEV: esquema inicializado (migraciones aplicadas en Node).');
} catch {}
} catch (e) {
console.warn('[web/db] No se pudieron aplicar migraciones en dev (Node):', e);
}
}
async function runDevMigrationsBun(instance: any): Promise<void> {
try {
const dbModule = await import('../../../../../src/db');
if (typeof (dbModule as any).initializeDatabase === 'function') {
(dbModule as any).initializeDatabase(instance);
console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).');
}
} catch (e) {
console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e);
}
}
// Seed de datos de demo si la tabla está vacía (por defecto habilitado en dev)
try {
let count = 0;
async function runDevMigrationsNode(instance: any): Promise<void> {
try {
const mod = await import('../../../../../src/db/migrations/index');
const list = (mod as any).migrations as any[];
const compat: any = instance;
if (typeof compat.query !== 'function') {
compat.query = (sql: string) => ({
all: () => compat.prepare(sql).all(),
get: () => compat.prepare(sql).get(),
});
}
try { compat.exec?.('PRAGMA foreign_keys = ON;'); } catch {}
for (const m of list) {
try {
const row = instance.prepare(`SELECT COUNT(1) AS c FROM tasks`).get() as any;
count = Number(row?.c ?? 0);
} catch {
// Si aún no existe la tabla, no seedear
count = 0;
await (m.up as any)(compat);
} catch (e) {
console.warn('[web/db] Error aplicando migración en dev (Node):', (m as any)?.name ?? '(sin nombre)', e);
}
}
try {
compat.prepare('SELECT 1 FROM tasks LIMIT 1').get();
console.info('[web/db] DEV: esquema inicializado (migraciones aplicadas en Node).');
} catch {}
} catch (e) {
console.warn('[web/db] No se pudieron aplicar migraciones en dev (Node):', e);
}
}
async function initializeDevSchema(instance: any): Promise<void> {
if (hasTable(instance, 'tasks')) return;
const isBun = typeof (globalThis as any).Bun !== 'undefined';
if (isBun) {
await runDevMigrationsBun(instance);
} else {
await runDevMigrationsNode(instance);
}
}
const shouldSeed = (typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true);
if (count === 0 && shouldSeed) {
console.info('[web/db] DEV: tabla tasks vacía; iniciando seed de demo...');
try {
const seed = await import('./dev-seed');
if (typeof (seed as any).seedDev === 'function') {
await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
console.info('[web/db] DEV: seed de demo completado.');
} else {
console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.');
}
} catch (e) {
console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e);
}
async function seedDevData(instance: any): Promise<void> {
try {
let count = 0;
try {
const row = instance.prepare('SELECT COUNT(1) AS c FROM tasks').get() as any;
count = Number(row?.c ?? 0);
} catch {
return; // table doesn't exist yet — skip seeding
}
const shouldSeed = typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true;
if (count !== 0 || !shouldSeed) {
console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`);
return;
}
console.info('[web/db] DEV: tabla tasks vacía; iniciando seed de demo...');
try {
const seed = await import('./dev-seed');
if (typeof (seed as any).seedDev === 'function') {
await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
console.info('[web/db] DEV: seed de demo completado.');
} else {
console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`);
console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.');
}
} catch (e) {
console.warn('[web/db] DEV: error al evaluar seed de demo:', e);
console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e);
}
} catch (e) {
console.warn('[web/db] DEV: error al evaluar seed de demo:', e);
}
}
// ---------------------------------------------------------------------------
// DB open
// ---------------------------------------------------------------------------
/**
* Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
* inicializa el esquema (migraciones) y siembra datos de demo.
* Nota: usa bun:sqlite si está disponible; en SSR Node usa better-sqlite3.
*/
async function openDb(filename: string = 'tasks.db'): Promise<any> {
const absolutePath = resolveDbAbsolutePath(filename);
ensureParentDir(absolutePath);
const instance = await createSqliteInstance(absolutePath);
if (isDev()) {
await initializeDevSchema(instance);
await seedDevData(instance);
}
return instance;
}
let _db: any | null = null;
let _dbPath: string | null = null;
/**
* Devuelve una única instancia compartida (lazy) de la BD.
* Si cambia el path, cierra la anterior y abre una nueva conexión.
*/
export async function getDb(filename: string = 'tasks.db'): Promise<any> {
if (_db) return _db;
_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 {
const resolved = resolveDbAbsolutePath(filename);
if (_db && _dbPath === resolved) return _db;
// Path changed or first call — close previous and reconnect
try {
if (_db && typeof _db.close === 'function') {
_db.close();
}
if (_db && typeof _db.close === 'function') _db.close();
} catch {}
_db = null;
_db = await openDb(filename);
_dbPath = resolved;
return _db;
}

@ -12,12 +12,11 @@ try {
export { resolveDbAbsolutePath } from '../../../../../src/env/db-path';
export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim();
export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim();
const SESSION_IDLE_TTL_MIN = Number(env.SESSION_IDLE_TTL_MIN || 120);
export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * 60 * 1000;
export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
export const isProd = () => NODE_ENV === 'production';
export const isDev = () => NODE_ENV === 'development';
@ -37,8 +36,6 @@ export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN)
// Uncomplete window (minutos; por defecto 1440 = 24h)
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));

@ -0,0 +1,92 @@
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
type TaskRow = { id: number; description: string; due_date: string; group_name: string | null };
/**
* Validate the ICS token, returning the DB row or an error Response.
*/
export async function validateIcsToken(
db: any,
token: string,
expectedType: string
): Promise<{ row: any } | Response> {
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== expectedType) return new Response('Not Found', { status: 404 });
return { row };
}
/**
* Build the ICS feed response: rate limit, query tasks, map events, build calendar,
* handle ETag / 304, update last_used_at, return 200 with ETag.
*/
export async function buildIcsFeed(
db: any,
tokenHash: string,
row: any,
request: Request,
title: string,
tasks: TaskRow[]
): Promise<Response> {
// Rate limit
const rl = checkIcsRateLimit(tokenHash);
if (!rl.ok) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': String(rl.retryAfterSec || 60) }
});
}
// Map to events
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T' as const
}));
const { body, etag } = await buildIcsCalendar(title, events);
// 304 if ETag matches
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
}
/** Compute the YMD date range for ICS queries. */
export function icsDateRange(): { startYmd: string; endYmd: string } {
const today = new Date();
return {
startYmd: ymdUTC(today),
endYmd: ymdUTC(addMonthsUTC(today, icsHorizonMonths))
};
}

@ -0,0 +1,56 @@
import { normalizeTime } from '$lib/server/datetime';
/**
* Resolves the reminder time to save based on frequency and raw input.
* Returns { timeToSave } on success or { error } on validation failure.
* Callers must handle the error case with their own Response/fail mechanism.
*/
export function resolveReminderTime(
db: any,
userId: string,
freqRaw: string,
timeRaw: string | null
): { timeToSave: string } | { error: string } {
if (freqRaw === 'off') {
// Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
if (timeRaw && timeRaw.length > 0) {
const norm = normalizeTime(timeRaw);
if (!norm) return { error: 'hora inválida' };
return { timeToSave: norm };
}
const row = db
.prepare(
`SELECT reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
return { timeToSave: row?.time ? String(row.time) : '08:30' };
}
// daily/weekly/weekdays: si no se especifica hora, usar '08:30'
if (!timeRaw || timeRaw.length === 0) {
return { timeToSave: '08:30' };
}
const norm = normalizeTime(timeRaw);
if (!norm) return { error: 'hora inválida' };
return { timeToSave: norm };
}
/** SQL for upserting user preferences (preserves last_reminded_on). */
export function upsertPreference(
db: any,
userId: string,
freqRaw: string,
timeToSave: string
): void {
db.prepare(
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = excluded.reminder_time,
updated_at = excluded.updated_at`
).run(userId, freqRaw, timeToSave, userId);
}

@ -0,0 +1,264 @@
import { getDb } from '$lib/server/db';
/**
* Validate session and parse JSON body for POST endpoints.
* Returns { userId, payload } on success, or a Response on failure.
* Callers should check `instanceof Response` before destructuring.
*/
export async function requireAuthAndJson(event: {
locals: { userId?: string | null };
request: { json(): Promise<any> };
}): Promise<{ userId: string; payload: any } | Response> {
const userId = event.locals.userId ?? null;
if (!userId) return new Response('Unauthorized', { status: 401 });
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
return { userId, payload };
}
/**
* Shared auth + task loading logic used by task detail, claim, and unassign routes.
*
* Validates the user, parses the task ID from params, opens the DB, loads the task,
* and checks that it exists and is not completed. Returns the context on success
* or a Response on failure callers should check `instanceof Response` first.
*/
export async function loadAndCheckTask(event: {
locals: { userId?: string | null };
params: { id?: string };
}): Promise<{ db: any; task: any; userId: string } | Response> {
const ctx = await _loadTask(event);
if (ctx instanceof Response) return ctx;
// Additional check: reject completed tasks
const { task } = ctx;
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
return ctx;
}
/**
* Shared group gating check: verifies the group is allowed and the user
* is an active member. Returns a 403 Response on failure, or true to
* continue. Callers should `if (res instanceof Response) return res;`.
*/
export function checkGroupAccess(
db: any,
groupId: string | null,
userId: string
): Response | true {
if (!groupId) return true;
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
return true;
}
/**
* Auth + task load + full gating (group + personal assignment).
* Returns context or a Response on failure. Does NOT check completed status
* callers must handle that themselves (complete vs uncomplete have opposite
* semantics).
*/
/**
* Load a task, check auth, and verify group access.
* Returns { db, task, userId } or a Response on failure.
* Does NOT check personal assignment (suitable for claim/unassign routes).
*/
export async function loadTaskAndCheckGroup(event: {
locals: { userId?: string | null };
params: { id?: string };
}): Promise<{ db: any; task: any; userId: string } | Response> {
const ctx = await loadAndCheckTask(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
// Gating: grupo permitido + usuario miembro activo
const groupId: string | null = task.group_id ? String(task.group_id) : null;
const gating = checkGroupAccess(db, groupId, userId);
if (gating instanceof Response) return gating;
return { db, task, userId };
}
/**
* Fetch allowed groups for a user where the user is an active member.
*
* @param excludeCommunityArchived - when true, also filters out
* community groups (is_community=0) and archived groups (archived=0).
* Defaults to false (includes all active allowed groups).
*/
export function fetchAllowedUserGroups(
db: any,
userId: string,
opts?: { excludeCommunityArchived?: boolean }
): Array<{ id: string; name: string | null }> {
const extraWhere = opts?.excludeCommunityArchived
? ' AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0'
: '';
return db
.prepare(
`SELECT g.id, g.name
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE COALESCE(g.active, 1) = 1${extraWhere}
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
)
.all(userId) as Array<{ id: string; name: string | null }>;
}
/**
* Low-level: auth + taskId parsing + DB + task load + not-found check.
* Does NOT reject completed tasks that's up to the caller.
*/
async function _loadTask(event: {
locals: { userId?: string | null };
params: { id?: string };
}): Promise<{ db: any; task: any; userId: string } | Response> {
// Auth
const userId = event.locals.userId ?? null;
if (!userId) return new Response('Unauthorized', { status: 401 });
// Parse task ID
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) return new Response('Bad Request', { status: 400 });
// DB
const db = await getDb();
// Load
const task = db
.prepare(
`SELECT id, description, due_date, group_id, created_by,
COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
return { db, task, userId };
}
export async function loadTaskAndGating(event: {
locals: { userId?: string | null };
params: { id?: string };
}): Promise<{ db: any; task: any; userId: string; groupId: string | null } | Response> {
const ctx = await _loadTask(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
// Gating: grupo allowed + miembro activo; si no tiene grupo, debe estar asignado
const groupId: string | null = task.group_id ? String(task.group_id) : null;
const gating = checkGroupAccess(db, groupId, userId);
if (gating instanceof Response) return gating;
if (!groupId) {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(task.id, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
}
}
return { db, task, userId, groupId };
}
/** Convert a DB row to the standard API task shape. */
export function formatTask(row: any): Record<string, any> {
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
completed: 'completed' in (row || {}) ? Number(row.completed || 0) : undefined,
completed_at: 'completed_at' in (row || {}) ? (row.completed_at ? String(row.completed_at) : null) : undefined
};
}
/** Map a DB row to a task list item (id, desc, date, group, code, assignees). */
export function mapTaskRow(r: any): Record<string, any> {
return {
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
};
}
/**
* Populate item.assignees by batch-loading task_assignments.
* Optionally computes can_unassign for the given userId (pass null to skip).
*/
export function loadAssignees(db: any, items: any[], userId: string | null): void {
if (items.length === 0) return;
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
if (userId != null) {
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid: string) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
}
/** Build a 200 JSON response { status, task }. */
export function respondTask(status: string, task: Record<string, any>): Response {
return new Response(JSON.stringify({ status, task }), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}

@ -2,7 +2,7 @@ import { writable } from 'svelte/store';
export type ToastType = 'info' | 'success' | 'error';
export type ToastItem = {
type ToastItem = {
id: string;
type: ToastType;
message: string;
@ -15,7 +15,7 @@ function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function show(message: string, type: ToastType = 'info', timeout = 2500): string {
function show(message: string, type: ToastType = 'info', timeout = 2500): string {
const id = uid();
toasts.update((list) => [...list, { id, type, message, timeout }]);
if (timeout > 0) {
@ -32,10 +32,6 @@ export function error(message: string, timeout = 3500): string {
return show(message, 'error', timeout);
}
export function info(message: string, timeout = 2500): string {
return show(message, 'info', timeout);
}
export function dismiss(id: string): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}

@ -1,35 +0,0 @@
<script lang="ts">
export let tone: 'default' | 'warning' | 'danger' | 'success' = 'default';
</script>
<span class={`badge ${tone}`}><slot /></span>
<style>
.badge {
display: inline-flex;
align-items: center;
line-height: 1;
font-size: 12px;
padding: 4px 6px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
gap: 6px;
}
.badge.warning {
background: rgba(217, 119, 6, 0.12);
border-color: rgba(217, 119, 6, 0.35);
color: var(--color-warning);
}
.badge.danger {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.35);
color: var(--color-danger);
}
.badge.success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.35);
color: var(--color-success);
}
</style>

@ -1,24 +0,0 @@
<script lang="ts">
export let width: string = '100%';
export let height: string = '12px';
export let radius: string = '6px';
</script>
<div class="skeleton" style={`width:${width};height:${height};border-radius:${radius};`} />
<style>
.skeleton {
background: linear-gradient(90deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12), rgba(0,0,0,0.06));
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-color-scheme: dark) {
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.16), rgba(255,255,255,0.08));
}
}
</style>

@ -1,11 +0,0 @@
<span class="sr-only"><slot /></span>
<style>
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
</style>

@ -1,108 +0,0 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import Badge from '$lib/ui/atoms/Badge.svelte';
import { success, error as toastError } from '$lib/stores/toasts';
export type Counts = { open: number; unassigned: number };
export type TaskPreview = { id: number; description: string; due_date: string | null; display_code: number | null };
export let id: string;
export let name: string | null = null;
export let counts: Counts = { open: 0, unassigned: 0 };
export let previews: TaskPreview[] = [];
let busyTaskId: number | null = null;
async function claim(taskId: number) {
if (busyTaskId) return;
busyTaskId = taskId;
try {
const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' });
if (res.ok) {
success('Tarea reclamada');
// Actualizar estado local sin recargar
previews = previews.filter((t) => t.id !== taskId);
counts = { ...counts, unassigned: Math.max(0, (counts?.unassigned ?? 0) - 1) };
} else {
const txt = await res.text();
toastError(txt || 'No se pudo reclamar');
}
} catch {
toastError('Error de red');
} finally {
busyTaskId = null;
}
}
</script>
<Card>
<div class="header">
<strong class="name">{name ?? id}</strong>
<div class="badges">
<Badge>abiertas: {counts.open}</Badge>
<Badge tone="warning">sin responsable: {counts.unassigned}</Badge>
</div>
</div>
{#if previews?.length}
<div class="previews">
<em class="title">Sin responsable:</em>
<ul class="list">
{#each previews as t}
<li class="row">
<div class="info">
<span>#{t.display_code ?? t.id}{t.description}</span>
{#if t.due_date}<small class="muted"> (vence: {t.due_date})</small>{/if}
</div>
<div class="actions">
<button class="btn" on:click|preventDefault={() => claim(t.id)} disabled={busyTaskId === t.id}>Reclamar</button>
</div>
</li>
{/each}
</ul>
</div>
{/if}
</Card>
<style>
.header {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: space-between;
}
.name { font-size: 1rem; }
.badges { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }
.previews { margin-top: var(--space-3); }
.title { color: var(--color-text); }
.list { margin: 6px 0 0 18px; padding: 0; }
.list li { margin: 4px 0; }
.muted { color: var(--color-text-muted); }
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.info {
display: inline-flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.actions {
display: inline-flex;
gap: 6px;
}
.btn {
padding: 3px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
</style>

@ -1,16 +0,0 @@
<div class="error-banner" role="alert">
<slot />
</div>
<style>
.error-banner {
padding: 10px 12px;
border: 1px solid var(--color-danger);
background: rgba(220,38,38,0.08);
color: var(--color-text);
border-radius: var(--radius-md);
}
@media (prefers-color-scheme: dark) {
.error-banner { background: rgba(248,113,113,0.12); }
}
</style>

@ -1,27 +0,0 @@
<script lang="ts">
export let size: number = 16;
export let className: string = '';
export let ariaLabel: string | undefined;
export let title: string | undefined;
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
role={ariaLabel ? 'img' : undefined}
aria-label={ariaLabel}
aria-hidden={ariaLabel ? undefined : 'true'}
>
{#if title}<title>{title}</title>{/if}
<path d="M6 2h12" />
<path d="M6 22h12" />
<path d="M8 4l8 8" />
<path d="M8 20l8-8" />
</svg>

@ -1,32 +0,0 @@
<script lang="ts">
export let type: string = 'text';
export let name: string | undefined;
export let value: string | number | undefined = undefined;
export let placeholder: string = '';
export let disabled: boolean = false;
</script>
<input
class="textfield"
{type}
{name}
bind:value
{placeholder}
{disabled}
/>
<style>
.textfield {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
}
.textfield:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>

@ -1,33 +1,24 @@
export function todayYmdUTC(): string {
const d = new Date();
function fmtYmd(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function todayYmdUTC(): string {
return fmtYmd(new Date());
}
export function compareYmd(a: string, b: string): number {
// returns -1 if a<b, 0 if equal, 1 if a>b
if (a === b) return 0;
return a < b ? -1 : 1;
}
export function addDaysYmd(ymd: string, days: number): string {
function addDaysYmd(ymd: string, days: number): string {
const d = new Date(`${ymd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'overdue' | 'soon' {
if (!ymd) return 'none';
const today = todayYmdUTC();
if (compareYmd(ymd, today) < 0) return 'overdue';
const soonCut = addDaysYmd(today, soonDays);
if (compareYmd(ymd, soonCut) <= 0) return 'soon';
return 'none';
return fmtYmd(d);
}
export function ymdToDmy(ymd: string): string {

@ -1,5 +1,6 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadAssignees, checkGroupAccess, mapTaskRow } from '$lib/server/task-helpers';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
@ -25,18 +26,8 @@ export const GET: RequestHandler = async (event) => {
const db = await getDb();
// Gating: grupo permitido + usuario es miembro activo
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
const accessCheck = checkGroupAccess(db, groupId, userId);
if (accessCheck instanceof Response) return accessCheck;
const orderParts: string[] = [];
if (unassignedFirst) {
@ -73,38 +64,11 @@ export const GET: RequestHandler = async (event) => {
const rows = db.prepare(sql).all(...params) as any[];
let items = rows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados
if (items.length > 0 && !onlyUnassigned) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
let items = rows.map(mapTaskRow);
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
}
// Cargar asignados (skip when onlyUnassigned)
if (!onlyUnassigned) {
loadAssignees(db, items, null);
}
return new Response(JSON.stringify({ items }), {

@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { findActiveToken, createCalendarTokenUrl, buildCalendarIcsUrl, rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
import { fetchAllowedUserGroups } from '$lib/server/task-helpers';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
@ -11,19 +12,8 @@ export const GET: RequestHandler = async (event) => {
const db = await getDb();
// Listar solo grupos permitidos donde el usuario está activo (mismo gating que /api/me/groups)
const groups = db
.prepare(
`SELECT g.id, g.name
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
)
.all(userId) as Array<{ id: string; name: string | null }>;
// Listar solo grupos permitidos donde el usuario está activo (excluye comunidad y archivados)
const groups = fetchAllowedUserGroups(db, userId, { excludeCommunityArchived: true });
// Personal
const personalExisting = await findActiveToken('personal', userId, null);

@ -1,20 +1,12 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
import { requireAuthAndJson } from '$lib/server/task-helpers';
export const POST: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
const ctx = await requireAuthAndJson(event);
if (ctx instanceof Response) return ctx;
const { userId, payload } = ctx;
const type = String(payload?.type || '').trim().toLowerCase();
const groupId = payload?.groupId ? String(payload.groupId).trim() : null;

@ -1,5 +1,6 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { fetchAllowedUserGroups } from '$lib/server/task-helpers';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
@ -11,18 +12,7 @@ export const GET: RequestHandler = async (event) => {
const db = await getDb();
// Listar solo grupos permitidos donde el usuario está activo
const groups = db
.prepare(
`SELECT g.id, g.name
FROM groups g
INNER JOIN group_members gm
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
INNER JOIN allowed_groups ag
ON ag.group_id = g.id AND ag.status = 'allowed'
WHERE COALESCE(g.active, 1) = 1
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
)
.all(userId) as any[];
const groups = fetchAllowedUserGroups(db, userId);
// Preparar statements para contadores
const countOpenStmt = db.prepare(

@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { normalizeTime } from '$lib/server/datetime';
import { requireAuthAndJson } from '$lib/server/task-helpers';
import { resolveReminderTime, upsertPreference } from '$lib/server/preferences-helpers';
export const GET: RequestHandler = async (event) => {
// Requiere sesión
@ -31,18 +32,10 @@ export const GET: RequestHandler = async (event) => {
};
export const POST: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const ctx = await requireAuthAndJson(event);
if (ctx instanceof Response) return ctx;
const { userId, payload } = ctx;
let payload: unknown = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
const body = payload && typeof payload === 'object' ? (payload as { freq?: unknown; time?: unknown }) : null;
const freqRaw = String(body?.freq ?? '').trim().toLowerCase();
@ -59,55 +52,16 @@ export const POST: RequestHandler = async (event) => {
const db = await getDb();
let timeToSave: string | null = null;
if (freqRaw === 'off') {
// Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
if (timeRaw && timeRaw.length > 0) {
const norm = normalizeTime(timeRaw);
if (!norm) {
return new Response(JSON.stringify({ error: 'hora inválida' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
timeToSave = norm;
} else {
const row = db
.prepare(
`SELECT reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
timeToSave = row?.time ? String(row.time) : '08:30';
}
} else {
// daily/weekly/weekdays: si no se especifica hora, usar '08:30'
if (!timeRaw || timeRaw.length === 0) {
timeToSave = '08:30';
} else {
const norm = normalizeTime(timeRaw);
if (!norm) {
return new Response(JSON.stringify({ error: 'hora inválida' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
timeToSave = norm;
}
const resolved = resolveReminderTime(db, userId, freqRaw, timeRaw);
if ('error' in resolved) {
return new Response(JSON.stringify({ error: resolved.error }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const { timeToSave } = resolved;
// Upsert preferencia (mantener last_reminded_on intacto)
db.prepare(
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = excluded.reminder_time,
updated_at = excluded.updated_at`
).run(userId, freqRaw, timeToSave, userId);
upsertPreference(db, userId, freqRaw, timeToSave);
const responseBody = { freq: freqRaw, time: timeToSave };

@ -1,10 +1,49 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadAssignees, mapTaskRow } from '$lib/server/task-helpers';
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
/** Subquery compartido: filtra a tareas visibles para el usuario (grupo permitido + miembro activo). */
const GROUP_GATING_SQL =
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`;
/** Añade filtro de búsqueda por descripción si procede. */
function addSearchFilter(whereParts: string[], params: any[], search: string): void {
if (!search) return;
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
}
/** Ejecuta COUNT con los whereParts + params dados y devuelve el total. */
function countTasks(db: any, whereParts: string[], params: any[]): number {
const row = db
.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}`
)
.get(...params) as any;
return Number(row?.cnt || 0);
}
/** Construye la respuesta JSON paginada estándar. */
function jsonPage(items: any[], page: number, limit: number, total: number, offset: number): Response {
return new Response(JSON.stringify({
items,
page,
limit,
total,
hasMore: offset + items.length < total
}), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
export const GET: RequestHandler = async (event) => {
// Requiere sesión
const userId = event.locals.userId ?? null;
@ -44,25 +83,11 @@ export const GET: RequestHandler = async (event) => {
`a.user_id = ?`,
`(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`,
`t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
GROUP_GATING_SQL
];
const params: any[] = [userId, userId];
if (search) {
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
}
// Total
const totalRow = db
.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}`
)
.get(...params) as any;
const total = Number(totalRow?.cnt || 0);
addSearchFilter(whereParts, params, search);
const total = countTasks(db, whereParts, params);
// Items (order by completed_at DESC)
const itemsRows = db
@ -87,47 +112,8 @@ export const GET: RequestHandler = async (event) => {
assignees: [] as string[]
}));
// Cargar asignados
if (items.length > 0) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
const body = {
items,
page,
limit,
total,
hasMore: offset + items.length < total
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
loadAssignees(db, items, userId);
return jsonPage(items, page, limit, total, offset);
}
// OPEN (comportamiento existente)
@ -136,33 +122,18 @@ export const GET: RequestHandler = async (event) => {
`a.user_id = ?`,
`COALESCE(t.completed, 0) = 0`,
`t.completed_at IS NULL`,
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
GROUP_GATING_SQL
];
const params: any[] = [userId];
// Añadir userId para el chequeo de membresía en el filtro de gating
params.push(userId);
const params: any[] = [userId, userId];
if (search) {
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
}
addSearchFilter(whereParts, params, search);
if (dueCutoff) {
whereParts.push(`t.due_date IS NOT NULL AND t.due_date <= ?`);
params.push(dueCutoff);
}
// Total
const totalRow = db
.prepare(
`SELECT COUNT(*) AS cnt
FROM tasks t
INNER JOIN task_assignments a ON a.task_id = t.id
WHERE ${whereParts.join(' AND ')}`
)
.get(...params) as any;
const total = Number(totalRow?.cnt || 0);
const total = countTasks(db, whereParts, params);
// Items
const orderBy =
@ -182,54 +153,8 @@ export const GET: RequestHandler = async (event) => {
)
.all(...params, limit, offset) as any[];
const items = itemsRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
// Cargar asignados de todas las tareas recuperadas (si hay)
if (items.length > 0) {
const ids = items.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of items) {
it.assignees = map.get(it.id) || [];
const personal = it.group_id == null;
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
const mine = (it.assignees || []).some((uid) => uid === userId);
(it as any).can_unassign = !(personal && cnt === 1 && mine);
}
}
const body = {
items,
page,
limit,
total,
hasMore: offset + items.length < total
};
const items = itemsRows.map(mapTaskRow);
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
loadAssignees(db, items, userId);
return jsonPage(items, page, limit, total, offset);
};

@ -1,5 +1,14 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadAssignees, mapTaskRow } from '$lib/server/task-helpers';
/** Map a DB row to an overview item (includes group_name). */
function mapOverviewRow(r: any) {
return {
...mapTaskRow(r),
group_name: r.group_name != null ? String(r.group_name) : null
};
}
export const GET: RequestHandler = async (event) => {
// Requiere sesión
@ -40,39 +49,10 @@ export const GET: RequestHandler = async (event) => {
)
.all(userId, userId) as any[];
const assigned = assignedRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
group_name: r.group_name != null ? String(r.group_name) : null, // personales => null
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[]
}));
const assigned = assignedRows.map(mapOverviewRow);
// Cargar asignados completos para "assigned"
if (assigned.length > 0) {
const ids = assigned.map((it) => it.id);
const placeholders = ids.map(() => '?').join(',');
const assignRows = db
.prepare(
`SELECT task_id, user_id
FROM task_assignments
WHERE task_id IN (${placeholders})
ORDER BY assigned_at ASC`
)
.all(...ids) as any[];
const map = new Map<number, string[]>();
for (const row of assignRows) {
const tid = Number(row.task_id);
const uid = String(row.user_id);
if (!map.has(tid)) map.set(tid, []);
map.get(tid)!.push(uid);
}
for (const it of assigned) {
it.assignees = map.get(it.id) || [];
}
}
loadAssignees(db, assigned, null);
// Orden para "unassigned"
const unassignedOrder =
@ -96,15 +76,7 @@ export const GET: RequestHandler = async (event) => {
)
.all(userId) as any[];
const unassigned = unassignedRows.map((r) => ({
id: Number(r.id),
description: String(r.description || ''),
due_date: r.due_date ? String(r.due_date) : null,
group_id: r.group_id ? String(r.group_id) : null,
group_name: r.group_name != null ? String(r.group_name) : null,
display_code: r.display_code != null ? Number(r.display_code) : null,
assignees: [] as string[] // por definición, vacío
}));
const unassigned = unassignedRows.map(mapOverviewRow);
return new Response(JSON.stringify({ assigned, unassigned }), {
status: 200,

@ -1,5 +1,5 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadAndCheckTask } from '$lib/server/task-helpers';
function isValidYmd(input: string): boolean {
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
@ -12,138 +12,86 @@ function isValidYmd(input: string): boolean {
return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
}
export const PATCH: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
let payload: any = null;
try {
payload = await event.request.json();
} catch {
return new Response('Bad Request', { status: 400 });
}
// Validar que al menos se envíe algún campo editable
const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date');
const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description');
if (!hasDueField && !hasDescField) {
return new Response('Bad Request', { status: 400 });
}
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
// due_date (opcional)
const due_date_raw = payload?.due_date;
if (hasDueField && due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
return new Response('Bad Request', { status: 400 });
}
const due_date =
!hasDueField || due_date_raw == null || String(due_date_raw).trim() === ''
? null
: String(due_date_raw).trim();
if (hasDueField && due_date !== null && !isValidYmd(due_date)) {
return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const badRequest = () => new Response('Bad Request', { status: 400 });
const forbidden = () => new Response('Forbidden', { status: 403 });
const json400 = (error: string) =>
new Response(JSON.stringify({ error }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
// description (opcional)
let description: string | undefined = undefined;
if (hasDescField) {
const descRaw = payload?.description;
if (descRaw !== null && descRaw !== undefined && typeof descRaw !== 'string') {
return new Response('Bad Request', { status: 400 });
}
if (descRaw == null) {
// No permitimos null en description (columna NOT NULL)
return new Response(JSON.stringify({ error: 'invalid_description' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const normalized = String(descRaw).replace(/\s+/g, ' ').trim();
if (normalized.length < 1 || normalized.length > 1000) {
return new Response(JSON.stringify({ error: 'invalid_description' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
description = normalized;
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
const db = await getDb();
function validateDueDate(raw: unknown): string | null | Response {
if (raw !== null && raw !== undefined && typeof raw !== 'string') return badRequest();
const trimmed = raw == null ? null : String(raw).trim();
if (!trimmed) return null;
return isValidYmd(trimmed) ? trimmed : json400('invalid_due_date');
}
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
function validateDescription(raw: unknown): string | Response {
if (raw !== null && raw !== undefined && typeof raw !== 'string') return badRequest();
if (raw == null) return json400('invalid_description');
const normalized = String(raw).replace(/\s+/g, ' ').trim();
return (normalized.length >= 1 && normalized.length <= 1000)
? normalized
: json400('invalid_description');
}
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// ---------------------------------------------------------------------------
// Authorization
// ---------------------------------------------------------------------------
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
function checkTaskEditAuthorization(db: any, task: any, userId: string): void | Response {
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
.get(groupId, userId);
const gstatus = db
.prepare(
`SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`
)
.prepare(`SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`)
.get(groupId);
if (!allowed || !active || !gstatus) {
return new Response('Forbidden', { status: 403 });
}
if (!allowed || !active || !gstatus) return forbidden();
} else {
// Tarea sin grupo: permitir si el usuario está asignado o es el creador
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
.get(task.id, userId);
const isCreator = String(task.created_by || '') === String(userId);
if (!isAssigned && !isCreator) {
return new Response('Forbidden', { status: 403 });
}
if (!isAssigned && !isCreator) return forbidden();
}
}
// Aplicar actualización
if (hasDescField && hasDueField) {
// ---------------------------------------------------------------------------
// DB persistence
// ---------------------------------------------------------------------------
function applyTaskUpdate(
db: any,
taskId: number,
hasDesc: boolean,
hasDue: boolean,
description: string | undefined,
due_date: string | null,
): void {
if (hasDesc && hasDue) {
db.prepare(`UPDATE tasks SET description = ?, due_date = ? WHERE id = ?`).run(description!, due_date, taskId);
} else if (hasDescField) {
} else if (hasDesc) {
db.prepare(`UPDATE tasks SET description = ? WHERE id = ?`).run(description!, taskId);
} else if (hasDueField) {
} else if (hasDue) {
db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
}
}
function buildUpdatedResponse(db: any, taskId: number): Response {
const updated = db
.prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`)
.get(taskId) as any;
@ -154,12 +102,46 @@ export const PATCH: RequestHandler = async (event) => {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null
}
display_code: updated.display_code != null ? Number(updated.display_code) : null,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
});
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export const PATCH: RequestHandler = async (event) => {
const ctx = await loadAndCheckTask(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
let payload: any = null;
try { payload = await event.request.json(); } catch { return badRequest(); }
const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date');
const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description');
if (!hasDueField && !hasDescField) return badRequest();
const due_date = hasDueField ? validateDueDate(payload?.due_date) : null;
if (due_date instanceof Response) return due_date;
let description: string | undefined;
if (hasDescField) {
const result = validateDescription(payload?.description);
if (result instanceof Response) return result;
description = result;
}
const auth = checkTaskEditAuthorization(db, task, userId);
if (auth instanceof Response) return auth;
applyTaskUpdate(db, task.id, hasDescField, hasDueField, description, due_date as string | null);
return buildUpdatedResponse(db, task.id);
};

@ -1,58 +1,10 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadTaskAndCheckGroup } from '$lib/server/task-helpers';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
const ctx = await loadTaskAndCheckGroup(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
// Asegurar existencia del usuario (best-effort)
try {
@ -74,7 +26,7 @@ export const POST: RequestHandler = async (event) => {
`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)`
)
.run(taskId, userId, userId) as any;
.run(task.id, userId, userId) as any;
const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';

@ -1,87 +1,92 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadTaskAndGating, formatTask, respondTask } from '$lib/server/task-helpers';
import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
// ---------------------------------------------------------------------------
// Reaction helper
// ---------------------------------------------------------------------------
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
function tryEnqueueCompletionReaction(db: any, taskId: number): void {
try {
// Look up origin (fallback for missing participant/from_me columns)
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;
}
const db = await getDb();
if (!origin?.chat_id || !origin?.message_id) return;
const chatId = String(origin.chat_id);
const task = db.prepare(`
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?
`).get(taskId) as any;
// Scope: por defecto solo reaccionar en grupos
if (REACTIONS_SCOPE !== 'all' && !chatId.endsWith('@g.us')) return;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// TTL
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);
if (!Number.isFinite(createdMs) || Date.now() - createdMs > ttlMs) return;
// Gating:
// - Si tiene group_id: grupo allowed y miembro activo
// - Si NO tiene group_id: debe estar asignada al usuario
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
// Gating 'enforce'
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;
if (!row) return;
}
} else {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
// Idempotencia 24h por metadata canónica exacta
const nowIso = toIsoSqlUTC(new Date());
const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000));
const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) };
if (origin.from_me === 1 || origin.from_me === true) meta.fromMe = true;
if (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 {}
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export const POST: RequestHandler = async (event) => {
const ctx = await loadTaskAndGating(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId, groupId } = ctx;
// Idempotencia: ya está completada
if (Number(task.completed) !== 0 || task.completed_at) {
const body = {
status: 'already',
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null,
completed: 1,
completed_at: task.completed_at ? String(task.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
return respondTask('already', {
...formatTask(task),
completed: 1,
completed_at: task.completed_at ? String(task.completed_at) : null
});
}
// Transacción: auto-asignar si no hay responsables y completar
const taskId = task.id;
const tx = db.transaction(() => {
const cntRow = db
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any;
const cnt = Number(cntRow?.cnt || 0);
if (cnt === 0) {
db.prepare(`
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)
`).run(taskId, userId, userId);
if (Number(cntRow?.cnt || 0) === 0) {
db.prepare(`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`)
.run(taskId, userId, userId);
}
db.prepare(`
UPDATE tasks
@ -103,89 +108,9 @@ 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 = toIsoSqlUTC(new Date());
const cutoff = toIsoSqlUTC(new Date(Date.now() - 24 * 60 * 60 * 1000));
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: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null,
completed: Number(updated.completed || 0),
completed_at: updated.completed_at ? String(updated.completed_at) : null
}
};
if (statusStr === 'updated' && REACTIONS_ENABLED) {
tryEnqueueCompletionReaction(db, taskId);
}
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
return respondTask(statusStr, formatTask(updated));
};

@ -1,58 +1,12 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadTaskAndCheckGroup } from '$lib/server/task-helpers';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
const ctx = await loadTaskAndCheckGroup(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
// Cargar tarea y validar abierta
const task = db
.prepare(
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?`
)
.get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
if (Number(task.completed) !== 0 || task.completed_at) {
return new Response(JSON.stringify({ status: 'completed' }), {
status: 400,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
}
// Bloqueo de tareas personales: si es la última asignación del propio usuario, denegar
const stats = db
@ -62,7 +16,7 @@ export const POST: RequestHandler = async (event) => {
FROM task_assignments
WHERE task_id = ?
`)
.get(userId, taskId) as any;
.get(userId, task.id) as any;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
@ -76,11 +30,11 @@ export const POST: RequestHandler = async (event) => {
// Eliminar asignación (idempotente)
const delRes = db
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
.run(taskId, userId) as any;
.run(task.id, userId) as any;
const cntRow = db
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as any;
.get(task.id) as any;
const remaining = Number(cntRow?.cnt || 0);
const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';

@ -1,78 +1,24 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { loadTaskAndGating, formatTask, respondTask } from '$lib/server/task-helpers';
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
import { toIsoSqlUTC } from '$lib/server/datetime';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
if (!userId) {
return new Response('Unauthorized', { status: 401 });
}
const idStr = event.params.id || '';
const taskId = parseInt(idStr, 10);
if (!Number.isFinite(taskId) || taskId <= 0) {
return new Response('Bad Request', { status: 400 });
}
const db = await getDb();
const task = db.prepare(`
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
FROM tasks
WHERE id = ?
`).get(taskId) as any;
if (!task) {
return new Response(JSON.stringify({ status: 'not_found' }), {
status: 404,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
}
const ctx = await loadTaskAndGating(event);
if (ctx instanceof Response) return ctx;
const { db, task, userId } = ctx;
// Si ya está sin completar, es idempotente
// Idempotencia: ya está sin completar
if (Number(task.completed) === 0) {
const body = {
status: 'already',
task: {
id: Number(task.id),
description: String(task.description || ''),
due_date: task.due_date ? String(task.due_date) : null,
display_code: task.display_code != null ? Number(task.display_code) : null,
completed: 0,
completed_at: null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
return respondTask('already', {
...formatTask(task),
completed: 0,
completed_at: null
});
}
// Gating:
// - Si tiene group_id: grupo allowed y miembro activo
// - Si NO tiene group_id: debe estar asignada al usuario
const groupId: string | null = task.group_id ? String(task.group_id) : null;
if (groupId) {
const allowed = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
.get(groupId);
const active = db
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
.get(groupId, userId);
if (!allowed || !active) {
return new Response('Forbidden', { status: 403 });
}
} else {
const isAssigned = db
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
.get(taskId, userId);
if (!isAssigned) {
return new Response('Forbidden', { status: 403 });
}
}
// Validar ventana: completed_at dentro de UNCOMPLETE_WINDOW_MIN
const taskId = task.id;
if (!task.completed_at) {
return new Response('Forbidden', { status: 403 });
}
@ -95,20 +41,5 @@ export const POST: RequestHandler = async (event) => {
WHERE id = ?
`).get(taskId) as any;
const body = {
status: 'updated',
task: {
id: Number(updated.id),
description: String(updated.description || ''),
due_date: updated.due_date ? String(updated.due_date) : null,
display_code: updated.display_code != null ? Number(updated.display_code) : null,
completed: Number(updated.completed || 0),
completed_at: updated.completed_at ? String(updated.completed_at) : null
}
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
});
return respondTask('updated', formatTask(updated));
};

@ -1,18 +1,8 @@
import type { PageServerLoad, Actions } from './$types';
import { getDb } from '$lib/server/db';
import { redirect, fail } from '@sveltejs/kit';
import { normalizeTime } from '$lib/server/datetime';
function ymdInTZ(d: Date, tz: string): string {
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')}`;
}
import { normalizeTime, ymdInTZ } from '$lib/server/datetime';
import { resolveReminderTime, upsertPreference } from '$lib/server/preferences-helpers';
function hmInTZ(d: Date, tz: string): string {
const parts = new Intl.DateTimeFormat('en-GB', {
@ -115,42 +105,12 @@ export const actions: Actions = {
const db = await getDb();
let timeToSave: string | null = null;
if (freqRaw === 'off') {
if (timeRaw && timeRaw.length > 0) {
const norm = normalizeTime(timeRaw);
if (!norm) return fail(400, { error: 'hora inválida' });
timeToSave = norm;
} else {
const row = db
.prepare(
`SELECT reminder_time AS time
FROM user_preferences
WHERE user_id = ?
LIMIT 1`
)
.get(userId) as any;
timeToSave = row?.time ? String(row.time) : '08:30';
}
} else {
if (!timeRaw || timeRaw.length === 0) {
timeToSave = '08:30';
} else {
const norm = normalizeTime(timeRaw);
if (!norm) return fail(400, { error: 'hora inválida' });
timeToSave = norm;
}
}
db.prepare(
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(user_id) DO UPDATE SET
reminder_freq = excluded.reminder_freq,
reminder_time = excluded.reminder_time,
updated_at = excluded.updated_at`
).run(userId, freqRaw, timeToSave, userId);
const resolved = resolveReminderTime(db, userId, freqRaw, timeRaw);
if ('error' in resolved) return fail(400, { error: resolved.error });
const { timeToSave } = resolved;
upsertPreference(db, userId, freqRaw, timeToSave);
return { success: true, pref: { freq: freqRaw, time: timeToSave } };
}

@ -1,39 +1,18 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 });
const rl = checkIcsRateLimit(tokenHash);
if (!rl.ok) {
return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } });
}
const res = await validateIcsToken(db, params.token || '', 'aggregate');
if (res instanceof Response) return res;
const { row } = res;
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
const tokenHash = await sha256Hex(params.token || '');
const { startYmd, endYmd } = icsDateRange();
// Sin responsable en todos los grupos allowed donde el usuario esté activo
const tasks = db
.prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name
@ -47,32 +26,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.user_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Agregado', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
.all(row.user_id, startYmd, endYmd) as any[];
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas Agregado', tasks);
};

@ -1,30 +1,17 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 });
const res = await validateIcsToken(db, params.token || '', 'group');
if (res instanceof Response) return res;
const { row } = res;
if (!row.group_id) return new Response('Not Found', { status: 404 });
// Validar estado del grupo (activo y no archivado); en caso contrario, tratar como feed caducado
// Validar estado del grupo
const gRow = db
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`)
.get(row.group_id) as any;
@ -32,14 +19,8 @@ export const GET: RequestHandler = async ({ params, request }) => {
return new Response('Gone', { status: 410 });
}
const rl = checkIcsRateLimit(tokenHash);
if (!rl.ok) {
return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } });
}
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
const tokenHash = await sha256Hex(params.token || '');
const { startYmd, endYmd } = icsDateRange();
const tasks = db
.prepare(
@ -53,32 +34,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.group_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Grupo', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
.all(row.group_id, startYmd, endYmd) as any[];
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas Grupo', tasks);
};

@ -1,39 +1,18 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { sha256Hex } from '$lib/server/crypto';
import { icsHorizonMonths } from '$lib/server/env';
import { buildIcsCalendar, checkIcsRateLimit } from '$lib/server/ics';
import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '$lib/server/datetime';
import { validateIcsToken, buildIcsFeed, icsDateRange } from '$lib/server/ics-helpers';
export const GET: RequestHandler = async ({ params, request }) => {
const db = await getDb();
const token = params.token || '';
if (!token) return new Response('Not Found', { status: 404 });
const tokenHash = await sha256Hex(token);
const row = db
.prepare(
`SELECT id, type, user_id, group_id, revoked_at
FROM calendar_tokens
WHERE token_hash = ?
LIMIT 1`
)
.get(tokenHash) as any;
if (!row) return new Response('Not Found', { status: 404 });
if (row.revoked_at) return new Response('Gone', { status: 410 });
if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 });
const rl = checkIcsRateLimit(tokenHash);
if (!rl.ok) {
return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': String(rl.retryAfterSec || 60) } });
}
const res = await validateIcsToken(db, params.token || '', 'personal');
if (res instanceof Response) return res;
const { row } = res;
const today = new Date();
const startYmd = ymdUTC(today);
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
const tokenHash = await sha256Hex(params.token || '');
const { startYmd, endYmd } = icsDateRange();
// "Mis tareas": asignadas al usuario; incluir privadas (group_id IS NULL) y de grupos donde esté activo y allowed.
const tasks = db
.prepare(
`SELECT t.id, t.description, t.due_date, g.name AS group_name
@ -48,32 +27,7 @@ export const GET: RequestHandler = async ({ params, request }) => {
AND (t.group_id IS NULL OR (gm.user_id IS NOT NULL AND ag.group_id IS NOT NULL AND COALESCE(g.active,1)=1 AND COALESCE(g.is_community,0)=0 AND COALESCE(g.archived,0)=0))
ORDER BY t.due_date ASC, t.id ASC`
)
.all(row.user_id, startYmd, endYmd, row.user_id) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
const events = tasks.map((t) => ({
id: t.id,
description: t.description,
due_date: t.due_date,
group_name: t.group_name || null,
prefix: 'T'
}));
const { body, etag } = await buildIcsCalendar('Wtask.org Tareas Personal', events);
// 304 si ETag coincide
const inm = request.headers.get('if-none-match');
if (inm && inm === etag) {
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
}
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSqlUTC(), row.id);
.all(row.user_id, startYmd, endYmd, row.user_id) as any[];
return new Response(body, {
status: 200,
headers: {
'content-type': 'text/calendar; charset=utf-8',
'cache-control': 'public, max-age=300',
ETag: etag
}
});
return buildIcsFeed(db, tokenHash, row, request, 'Wtask.org Tareas Personal', tasks);
};

@ -3,13 +3,13 @@
Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado a Evolution API.
1) Comando base y ayuda
- En un grupo activo: enviar “/t” o “/t ayuda”.
- En un grupo activo: enviar “t” o “t ayuda”.
- Esperado: no aparece nada en el grupo; recibes un DM con la guía rápida.
- En DM al bot: enviar “/t”.
- En DM al bot: enviar “t”.
- Esperado: recibes el mismo DM de ayuda.
2) Crear tarea en grupo (sin menciones)
- Enviar en el grupo: “/t n Comprar leche mañana”.
- Enviar en el grupo: “t n Comprar leche mañana”.
- Esperado:
- Se crea la tarea con due_date = YYYY-MM-DD (mañana).
- No se asigna a nadie (sin dueño).
@ -20,7 +20,7 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado
👥 sin dueño (<Nombre del grupo>)
3) Crear tarea en DM (sin menciones)
- Enviar al bot por DM: “/t n Pagar comedor hoy”.
- Enviar al bot por DM: “t n Pagar comedor hoy”.
- Esperado:
- Se crea la tarea con due_date = YYYY-MM-DD (hoy).
- Tarea asignada a ti (creador).
@ -28,7 +28,7 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado
- No se envía nada a ningún grupo.
4) Crear tarea con menciones en grupo
- Enviar: “/t n Acta de la reunión 2025-09-12 @34611122233”.
- Enviar: “t n Acta de la reunión 2025-09-12 @34611122233”.
- Esperado:
- Se crea la tarea con due_date 2025-09-12.
- Se asigna a 34611122233 (normalizado).
@ -41,10 +41,10 @@ Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado
🔔 <id> — 📅 2025-09-12
“*Acta de la reunión*”
Grupo: <Nombre del grupo>
Completar: /t x <id>
Completar: t x <id>
5) Prefijos aceptados
- Repetir 24 usando “/tarea n ...” (debe comportarse igual que “/t ...”).
- Repetir 24 usando “tarea n ...” (debe comportarse igual que “t ...”).
Notas
- En el log del servidor verás “✅ Sent message to with this as payload: ...” por cada DM encolado y enviado por Evolution API.

@ -8,7 +8,7 @@ Por favor, añade a este chat los siguientes archivos (exactos y completos) para
- src/services/response-queue.ts
Resumen de los cambios planificados (breve):
- Alias y sinónimos: agregar soporte para `/t` además de `/tarea`, y mapear subcomandos (n/nueva/crear/+), (ver/listar/ls), (x/hecho/completar), (tomar/claim), (soltar/unassign), (ayuda/help/?).
- Alias y sinónimos: agregar soporte para `t` además de `tarea`, y mapear subcomandos (n/nueva/crear/+), (ver/listar/ls), (x/hecho/completar), (tomar/claim), (soltar/unassign), (ayuda/help/?).
- Silencio en grupos: no publicar nunca en el grupo; todas las respuestas se enviarán por DM al autor. No mostraremos el mensaje “Te envié la info por DM”.
- Crear tarea: en grupos sin menciones → tarea “sin dueño”; en DM sin menciones → asignada al creador. Añadir soporte de fechas “hoy” y “mañana”.
- Nuevos comandos: `tomar <id>` y `soltar <id>` (se implementan en TaskService con métodos claim/unassign).

@ -1,7 +1,7 @@
# Guía de uso — Task WhatsApp Chatbot
Principios
- Prefijo de comandos: “/t” o “/tarea”.
- Prefijo de comandos: “t” o “tarea”.
- Respuestas “solo DM”: el bot no publica en grupos; siempre envía un mensaje directo al autor (salvo resumen opcional al crear si se activa).
- Fechas: puedes escribir “hoy” o “mañana” y también YYYY-MM-DD. La zona horaria se configura con la variable de entorno TZ (por defecto Europe/Madrid).
- Límite de uso: rate limit por usuario (15/min por defecto); si lo superas, verás un aviso (acotado a 1/min).
@ -10,9 +10,9 @@ Comandos y alias
- Crear
- Aliases: n, nueva, crear, +
- Ejemplos:
- /t nueva Acta de la reunión mañana
- /t n Carteles fiesta 2025-09-12 @600123456
- /t + Preparar dossier @600111111 @600222222
- t nueva Acta de la reunión mañana
- t n Carteles fiesta 2025-09-12 @600123456
- t + Preparar dossier @600111111 @600222222
- Reglas:
- En grupo: si no mencionas a nadie → “sin responsable”.
- En DM: si no mencionas a nadie → se asigna al creador.
@ -24,33 +24,33 @@ Comandos y alias
- sin — pendientes sin responsable (según contexto).
- todos — visión general (según permisos futuros).
- Ejemplos:
- /t ver grupo
- /t ver mis
- t ver grupo
- t ver mis
- Completar
- Aliases: x, hecho, completar, done
- Ejemplos:
- /t x 26
- /t hecho 31
- t x 26
- t hecho 31
- Notas: registra quién completa; no restringido solo a asignados (por fluidez).
- Tomar
- Aliases: tomar, claim
- Ejemplo: /t tomar 26
- Ejemplo: t tomar 26
- Idempotente: si ya eres asignado, lo indica sin error.
- Soltar
- Aliases: soltar, unassign
- Ejemplo: /t soltar 26
- Ejemplo: t soltar 26
- Idempotente: si no estabas asignado, lo indica sin error. La tarea puede quedar “sin responsable” si no quedan asignados.
- Configurar recordatorios
- Aliases: configurar, config
- Opciones: daily | weekly | off
- Ejemplos:
- /t configurar daily
- /t configurar weekly
- /t configurar off
- t configurar daily
- t configurar weekly
- t configurar off
- Notas: resumen diario/weekly por DM; weekly los lunes a la hora configurada (por defecto 08:30 si aplica); se evita duplicar en el mismo día y no se envía si no hay tareas.
- Ayuda
- Aliases: ayuda, help, ?
- Ejemplos: /t, /t ayuda
- Ejemplos: t, t ayuda
Gramática y formato
- Menciones
@ -67,20 +67,20 @@ Gramática y formato
Ejemplos prácticos
- Crear en grupo sin menciones (queda sin responsable):
- /t nueva Revisión presupuesto mañana
- t nueva Revisión presupuesto mañana
- Crear en DM (se asigna a ti):
- /t nueva Preparar documento hoy
- t nueva Preparar documento hoy
- Crear con varios asignados:
- /t nueva Carteles @600111111 @600222222 2025-10-10
- t nueva Carteles @600111111 @600222222 2025-10-10
- Ver tus tareas:
- /t ver mis
- t ver mis
- Completar:
- /t x 42
- t x 42
- Tomar y soltar:
- /t tomar 42
- /t soltar 42
- t tomar 42
- t soltar 42
- Configurar recordatorios:
- /t configurar weekly
- t configurar weekly
Limitaciones y notas
- El bot no publica en grupos por diseño.

@ -12,10 +12,10 @@ Notas generales
---
## /t nueva (crear)
## t nueva (crear)
Alias: `n`, `nueva`, `crear`, `+`
Sintaxis: `/t n <descripción> [fecha] [@menciones...]`
Sintaxis: `t n <descripción> [fecha] [@menciones...]`
Parámetros
- descripción: texto libre.
@ -34,17 +34,17 @@ Grupo asociado
- Solo se asigna `group_id` si el grupo está activo. Si GROUP_GATING_MODE='enforce' y el grupo no está permitido, se crea “sin grupo”.
Ejemplos
- `/t n Preparar informe 2025-11-05 @600123456`
- `/t + Comprar pan mañana`
- `/t crear Llamar a proveedores @ana @juan`
- `/t n Presentación 25-02-02` (→ 2025-02-02)
- `t n Preparar informe 2025-11-05 @600123456`
- `t + Comprar pan mañana`
- `t crear Llamar a proveedores @ana @juan`
- `t n Presentación 25-02-02` (→ 2025-02-02)
---
## /t ver (listar)
## t ver (listar)
Alias: `ver`, `mostrar`, `listar`, `ls`
Sintaxis: `/t ver [grupo|mis|todos|sin]` (el alcance es opcional)
Sintaxis: `t ver [grupo|mis|todos|sin]` (el alcance es opcional)
Alcances
- `grupo`: lista pendientes del grupo actual (solo desde grupo activo).
@ -62,15 +62,15 @@ Límites
- Máx. 10 elementos por sección; si hay más, se añade “... y N más”.
Ejemplos
- En grupo: `/t ver` (equivale a `grupo`), `/t ver sin`
- Por DM: `/t ver mis`, `/t ver todos`
- En grupo: `t ver` (equivale a `grupo`), `t ver sin`
- Por DM: `t ver mis`, `t ver todos`
---
## /t x (completar)
## t x (completar)
Alias: `x`, `hecho`, `completar`, `done`
Sintaxis: `/t x <id|id,id,...|id id ...>`
Sintaxis: `t x <id|id,id,...|id id ...>`
Soporta múltiples IDs separados por espacios y/o comas. Máx. 10 IDs.
Resolución de ID
@ -80,46 +80,46 @@ Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y el snapshot del grupo es fresco, debes ser miembro activo para completar.
Ejemplos
- `/t x 26`
- `/t x 14 19 24`
- `/t x 14,19,24`
- `t x 26`
- `t x 14 19 24`
- `t x 14,19,24`
---
## /t tomar (asumir)
## t tomar (asumir)
Alias: `tomar`, `claim`, `asumir`, `asumo`
Sintaxis: `/t tomar <id|id,id,...|id id ...>`
Sintaxis: `t tomar <id|id,id,...|id id ...>`
Múltiples IDs; máx. 10.
Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para tomar tareas del grupo.
Ejemplos
- `/t tomar 12`
- `/t tomar 12 19 50`
- `/t tomar 12,19,50`
- `t tomar 12`
- `t tomar 12 19 50`
- `t tomar 12,19,50`
---
## /t soltar (unassign)
## t soltar (unassign)
Alias: `soltar`, `unassign`, `dejar`, `liberar`, `renunciar`
Sintaxis: `/t soltar <id>`
Sintaxis: `t soltar <id>`
Un solo ID.
Gating de membresía (opcional)
- Si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresco, debes ser miembro activo para soltar tareas del grupo.
Ejemplos
- `/t soltar 26`
- `t soltar 26`
---
## /t configurar (recordatorios)
## t configurar (recordatorios)
Alias: `config`, `configurar`
Sintaxis: `/t configurar diario|l-v|semanal|off [HH:MM]`
Sintaxis: `t configurar diario|l-v|semanal|off [HH:MM]`
Valores admitidos y alias
- `diario`/`diaria` → recordatorio diario (se guarda como `daily`).
@ -135,17 +135,17 @@ Nota de localización
- Internamente se almacenan claves en inglés (`daily`, `weekdays`, `weekly`, `off`), pero el copy al usuario es en español. Pendiente de revisión futura para evitar fugas como “weekly” en mensajes.
Ejemplos
- `/t configurar diaria 09:00`
- `/t configurar l-v 08:30`
- `/t configurar semanal` (→ lunes 08:30)
- `/t configurar off`
- `t configurar diaria 09:00`
- `t configurar l-v 08:30`
- `t configurar semanal` (→ lunes 08:30)
- `t configurar off`
---
## /t ayuda
## t ayuda
Alias: `ayuda`, `help`, `?`
Sintaxis: `/t ayuda` | `/t ayuda avanzada`
Sintaxis: `t ayuda` | `t ayuda avanzada`
Comportamiento actual
- Ayuda rápida con comandos básicos, límites y ejemplos cortos.
@ -156,22 +156,22 @@ Nota
---
## /t web
## t web
Sintaxis: `/t web` (solo por DM)
Sintaxis: `t web` (solo por DM)
Descripción
- Genera un token de acceso oneshot válido 10 minutos, invalida tokens previos y devuelve una URL de login basada en `WEB_BASE_URL`.
Ejemplo
- `Acceso web: https://…/login?token=...`
“Válido durante 10 minutos. Si caduca, vuelve a enviar `/t web`.”
“Válido durante 10 minutos. Si caduca, vuelve a enviar `t web`.”
---
## Comandos desconocidos
Ante comandos no reconocidos, el bot responde por DM con un mensaje que incluye el encabezado “❓ Comando no reconocido”, la sugerencia “Prueba `/t ayuda`” y la ayuda rápida inline.
Ante comandos no reconocidos, el bot responde por DM con un mensaje que incluye el encabezado “❓ Comando no reconocido”, la sugerencia “Prueba `t ayuda`” y la ayuda rápida inline.
## Notas adicionales

@ -9,18 +9,18 @@
"{bot}": "Número del bot sin prefijo"
},
"cta": [
" Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`"
" 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`",
"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]`"
" 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.",
@ -33,11 +33,11 @@
"Acción {rawAction} no implementada aún"
],
"info": [
" Este comando se usa por privado. Envíame `/t web` por DM.",
" 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`.",
" Para ver tareas sin responsable, escribe por privado `t todas` o usa `t web`.",
"✅ Recordatorios: {label}"
],
"states": [
@ -52,6 +52,6 @@
"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\"."
"Acceso web: {url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar \"t web\"."
]
}

@ -6,7 +6,7 @@ Objetivo
Pasos
1) Parseo en CommandService
- Ubicación: src/services/command.ts
- Añade lógica para detectar el patrón (p.ej., "/t listar", "/t info 0012").
- Añade lógica para detectar el patrón (p.ej., "t listar", "t info 0012").
- Normaliza IDs con utils/whatsapp.normalizeWhatsAppId cuando corresponda.
- Registra métricas si procede (Metrics.inc).

@ -29,7 +29,7 @@ Variables de entorno (principales)
- DATA_DIR: directorio base para la base de datos SQLite (por defecto ./data).
- DB_PATH: ruta absoluta o relativa al archivo SQLite; si se define, tiene prioridad sobre DATA_DIR. Ej.: DB_PATH='./data/tasks.db'
- MIGRATIONS_LOG_LEVEL: 'silent' para silenciar logs del migrador (en test se silencian automáticamente).
- WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para /t web. Ej.: WEB_BASE_URL='https://wtask.org'
- WEB_BASE_URL: base pública de la interfaz web para construir enlaces absolutos (p. ej., /login?token=...). Obligatoria para t web. Ej.: WEB_BASE_URL='https://wtask.org'
- DEV_AUTOSEED_DB: 'true'/'false' para sembrar automáticamente la BD en desarrollo cuando está vacía (apps/web). Ej.: DEV_AUTOSEED_DB='true'
- DEV_DEFAULT_USER: ID de usuario por defecto en desarrollo (bypass y semilla). Idealmente numérico (solo dígitos). Ej.: DEV_DEFAULT_USER='34600123456'

@ -17,38 +17,38 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
## Inventario de comandos actual (derivado de src/services/command.ts)
- Crear
- Comandos: `/t n`, `/t nueva`, `/t crear`, `/t +`
- Comandos: `t n`, `t nueva`, `t crear`, `t +`
- Soporta: fecha explícita `YYYY-MM-DD`, `YY-MM-DD` (expande a `20YY`), tokens `hoy`/`mañana`
- Asignación:
- En DM: por defecto asignada al creador si no hay menciones
- En grupo: por defecto “sin responsable” si no hay menciones
- Menciones: detecta `@tokens` y JIDs crudos; filtra no plausibles; emite DM “JIT onboarding” si no se pudo resolver
- Ver
- Comando base: `/t ver` (alias: `ver`, `mostrar`, `listar`, `ls`)
- Comando base: `t ver` (alias: `ver`, `mostrar`, `listar`, `ls`)
- Alcances: `grupo` (si se escribe desde grupo activo), `mis` (DM), `todos` (mis + sin responsable de grupos donde soy miembro activo), `sin` (solo sin responsable del grupo actual)
- Límite: 10 ítems; “… y N más” cuando excede
- Indicadores:
- Fecha en formato `DD/MM`
- Aviso de vencida (⚠️) cuando `due_date < hoy` (calculado por TZ configurada)
- Completar
- Comandos: `/t x`, `/t hecho`, `/t completar`, `/t done`
- Comandos: `t x`, `t hecho`, `t completar`, `t done`
- Acepta múltiples IDs (separados por espacios y/o comas); máx. 10
- Resolución de ID: primero por `display_code` de tareas activas; si no, por PK
- Gating opcional: si `GROUP_MEMBERS_ENFORCE=true` y snapshot fresca, requiere ser miembro activo
- Tomar
- Comandos: `/t tomar`, `/t claim`, `/t asumir`, `/t asumo`
- Comandos: `t tomar`, `t claim`, `t asumir`, `t asumo`
- Múltiples IDs; máx. 10; gating de membresía igual que “completar”
- Soltar
- Comandos: `/t soltar`, `/t unassign`, `/t dejar`, `/t liberar`, `/t renunciar`
- Comandos: `t soltar`, `t unassign`, `t dejar`, `t liberar`, `t renunciar`
- Un solo ID
- Configurar recordatorios
- Comandos: `/t configurar diario|l-v|semanal|off [HH:MM]`
- Comandos: `t configurar diario|l-v|semanal|off [HH:MM]`
- Mapea alias a `daily`, `weekdays`, `weekly`, `off`; hora opcional con normalización
- Ayuda
- Comandos: `/t ayuda`, `/t help`, `/t ?`, y variante “ayuda avanzada”
- Comandos: `t ayuda`, `t help`, `t ?`, y variante “ayuda avanzada”
- Actualmente genera mensajes en línea (no centralizados)
- Web
- Comando: `/t web`
- Comando: `t web`
- Genera token one-shot, invalida tokens previos, devuelve URL de login basada en `WEB_BASE_URL`
- Notas de formato ya en uso
- IDs se muestran con 4 dígitos (backticks)
@ -96,7 +96,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- Contenido sugerido (resumen):
- Ayuda rápida:
- Secciones: “COMANDOS BÁSICOS”, “LISTADOS”, “ACCESO WEB”
- Bullets con: crear (`/t n ...`), ver (`/t ver mis|grupo|todos|sin`), completar/tomar/soltar, configurar recordatorios, y `/t web`
- Bullets con: crear (`t n ...`), ver (`t ver mis|grupo|todos|sin`), completar/tomar/soltar, configurar recordatorios, y `t web`
- Nota: _El bot responde por DM, incluso si escribes desde un grupo._
- Ayuda extendida:
- Además: formatos de fecha (`YYYY-MM-DD`, `YY-MM-DD`→`20YY-MM-DD`, `hoy|mañana`), límites (máx. 10 IDs), reglas de asignación por contexto, gating de grupos, detalles de “ver todos”.
@ -107,7 +107,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- Tests:
- Nuevo: `tests/unit/services/help-content.test.ts` (asserts por substrings clave, no igualdad exacta)
- Criterios de aceptación:
- `getQuickHelp()` incluye `/t web` y comandos básicos.
- `getQuickHelp()` incluye `t web` y comandos básicos.
- `getFullHelp()` cubre scopes de “ver”, formatos de fecha y límites.
### Fase 3 — Comportamiento ante comandos desconocidos (completado)
@ -116,7 +116,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- Cambios en `src/services/command.ts`:
- Reemplazar la respuesta “Acción X no implementada aún” por:
- Encabezado tipo: `❓ Comando no reconocido`
- Sugerencia: “Prueba `/t ayuda`”
- Sugerencia: “Prueba `t ayuda`”
- Adjuntar `getQuickHelp(baseUrl)` en el mismo mensaje
- Mantener logging/telemetría si aplica (ej. `Metrics.inc('commands_unknown_total')` opcional)
- Archivos a tocar:
@ -124,14 +124,14 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- `src/services/messages/help.ts` (uso desde aquí)
- Tests:
- Nuevo: `tests/unit/services/command.unknown-help.test.ts`
- Input: `/t qué tareas tengo hoy?`
- Expect: mensaje contenga indicador de comando desconocido, `/t ayuda`, y fragmentos de quick help (p.ej., `/t ver mis`, `/t web`)
- Input: `t qué tareas tengo hoy?`
- Expect: mensaje contenga indicador de comando desconocido, `t ayuda`, y fragmentos de quick help (p.ej., `t ver mis`, `t web`)
- Criterios de aceptación:
- DM siempre; mensaje claro y accionable.
### Fase 4 — Unificar el comando /t ayuda (completado)
### Fase 4 — Unificar el comando t ayuda (completado)
- Objetivo: que `/t ayuda` y “ayuda avanzada” usen el módulo centralizado.
- Objetivo: que `t ayuda` y “ayuda avanzada” usen el módulo centralizado.
- Cambios en `src/services/command.ts`:
- Si `ayuda` con “avanzada” → `getFullHelp(baseUrl)`
- Si `ayuda` sin “avanzada” → `getQuickHelp(baseUrl)` + CTA a “ayuda avanzada”
@ -143,8 +143,8 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- `src/services/command.ts` (acción `ayuda`)
- Tests:
- Nuevo: `tests/unit/services/command.help.test.ts`
- “/t ayuda” incluye `/t web`
- “/t ayuda avanzada” incluye scopes de “ver” y formatos de fecha
- “t ayuda” incluye `t web`
- “t ayuda avanzada” incluye scopes de “ver” y formatos de fecha
- Criterios de aceptación:
- Ayuda centralizada y consistente en ambos modos.
@ -153,7 +153,7 @@ Objetivo: hacer la ayuda consistente, útil ante comandos desconocidos, visible
- Objetivo: habilitar rollback rápido si hiciera falta.
- Cambios:
- Soportar `FEATURE_HELP_V2` (por defecto `true`). Si `false`, usar el comportamiento actual (fallback).
- Fuente para `baseUrl`: `process.env.WEB_BASE_URL` (ya empleada por `/t web`); pasarla opcionalmente a `help.ts`.
- Fuente para `baseUrl`: `process.env.WEB_BASE_URL` (ya empleada por `t web`); pasarla opcionalmente a `help.ts`.
- Archivos a tocar:
- `src/services/command.ts` (condicionar branches de ayuda/fallback con el flag)
- `src/services/messages/help.ts` (aceptar `baseUrl?`)
@ -217,8 +217,8 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el
## Criterios de aceptación globales
- Un comando desconocido devuelve un mensaje útil con ayuda rápida (incluye `/t ayuda` y referencia a `/t web`).
- `/t ayuda` usa contenido centralizado; “ayuda avanzada” despliega la versión extendida.
- Un comando desconocido devuelve un mensaje útil con ayuda rápida (incluye `t ayuda` y referencia a `t web`).
- `t ayuda` usa contenido centralizado; “ayuda avanzada” despliega la versión extendida.
- Estilo consistente: secciones en negrita y mayúsculas; comandos/IDs en monoespaciado; listas con “- ”; notas en cursiva.
- Documentación (inventario y guía de estilo) creada.
- Tests nuevos cubriendo formateadores y flujos de ayuda.
@ -230,7 +230,7 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el
- Riesgo: rotura de tests por cambios de copy.
- Mitigación: asserts por substrings; helper `stripFormatting`; cambios incrementales.
- Riesgo: ambigüedad de URLs/web.
- Mitigación: mostrar CTA a `/t web` en la ayuda; opcionalmente mostrar `WEB_BASE_URL` como referencia informativa sin token.
- Mitigación: mostrar CTA a `t web` en la ayuda; opcionalmente mostrar `WEB_BASE_URL` como referencia informativa sin token.
- Riesgo: sobrecarga de mensajes.
- Mitigación: quick vs full help; mantener mensajes cortos y con bullets.
@ -241,10 +241,10 @@ Cuando ejecutemos las fases de código/tests, si estos archivos no están en el
1) Fase 0 (docs) — crear `docs/commands-inventory.md` y `docs/whatsapp-style-guide.md`.
2) Fase 1 (helpers) — añadir `code`, `section`, `bullets` a `src/utils/formatting.ts` + tests.
3) Fase 2 (help.ts) — centralizar ayuda + tests de contenido.
4) Fase 3-4 (wire-up) — usar help.ts en `/t ayuda` y en comando desconocido.
4) Fase 3-4 (wire-up) — usar help.ts en `t ayuda` y en comando desconocido.
5) Fase 5-6 — flag `FEATURE_HELP_V2` y estandarización incremental de copys.
Incluye validación manual: probar `/t ayuda`, `/t ayuda avanzada`, un comando desconocido y `/t web`.
Incluye validación manual: probar `t ayuda`, `t ayuda avanzada`, un comando desconocido y `t web`.
---

@ -36,7 +36,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie
- Tareas de mis grupos: solo grupos permitidos y en los que el usuario está activo; sección destacada de “sin responsable” sin límite y con botón “Reclamar”; fichas ordenadas por cantidad de “sin responsable”.
- Edición de tareas desde la web: reclamar/soltar asignación y editar fecha de vencimiento (YYYY-MM-DD).
- Preferencias de recordatorios: ver y modificar frecuencia (daily/weekly/weekdays/off) y hora. Visualización de próximo recordatorio según TZ.
- Autenticación: comando /t web que devuelve URL con token. Canje en /login y cookie de sesión.
- Autenticación: comando t web que devuelve URL con token. Canje en /login y cookie de sesión.
- Integraciones:
- ICS personal (solo “mis tareas” con due_date).
- ICS por usuario+grupo (solo sin responsable), autogenerados (sin clic de creación).
@ -57,7 +57,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie
## 4) Autenticación y sesiones
- Emisión de token (bot):
- En /t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario.
- En t web por DM: crear token aleatorio, guardar hash (no el token en claro), TTL 10 min, uso único, rate-limit por usuario.
- Devolver URL del tipo: https://app.example.com/login?token=XYZ
- Canje (web):
- GET /login muestra una página intermedia sin auto-submit; requiere interacción mínima. Un script establece una cookie efímera login_intent y habilita el botón.
@ -65,7 +65,7 @@ Este documento define el plan para añadir una interfaz web al sistema, mantenie
- Crea sesión en DB (web_sessions) y emite cookie de sesión (solo cookie de sesión, sin persistencia en disco).
- Redirige a /app (sin token en la URL).
- Expiración:
- Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token /t web.
- Idle timeout: 2 horas de inactividad. Si excede, pedir un nuevo token t web.
- Seguridad:
- Cookies: HttpOnly, SameSite=Lax, Secure (en prod), path acotado.
- Rate limit en /login para evitar bruteforce de tokens.
@ -149,7 +149,7 @@ Notas:
- Rotar/revocar: botones por feed. Avisar que rotar invalida suscripción previa.
- Interacciones:
- Filtros rápidos, búsqueda, paginación liviana.
- Estado de sesión (2h de inactividad): al expirar, mostrar mensaje con instrucción de enviar /t web.
- Estado de sesión (2h de inactividad): al expirar, mostrar mensaje con instrucción de enviar t web.
## 10) Seguridad
@ -172,7 +172,7 @@ Notas:
- web_sessions_active, web_api_requests_total{route=…}, ics_requests_total{type=…}
- ics_tokens_revoked_total, ics_tokens_created_total
- Rate limiting:
- Emisión de token /t web (en el bot) y /login (web).
- Emisión de token t web (en el bot) y /login (web).
- ICS por token/IP (p. ej., 4 req/min).
- Caching ICS:
- ETag/Last-Modified y Cache-Control: public, max-age=300 (suave), para que los clientes no abusen.
@ -206,7 +206,7 @@ Etapa 0 — Preparación
Etapa 1 — Autenticación
- Migraciones: web_tokens, web_sessions. — HECHO
- Bot: emisión de token de 10 min (hash, rate limit) en /t web. — HECHO
- Bot: emisión de token de 10 min (hash, rate limit) en t web. — HECHO
- Web: endpoint /login (GET intermedio + POST canje), cookie de sesión, redirect limpio; hooks de sesión con idle timeout 2h; gate de JS; CSRF checkOrigin desactivado por proxy interno. — HECHO
- Páginas de error/expiración.
@ -245,7 +245,7 @@ Implementado: suite web con bun:test y build programático (helpers en tests/web
- Autorización de endpoints (gating, membresía).
- Generación ICS y filtros (due_date, horizonte).
- Integración:
- Flujo end-to-end: /t web → /login → /app.
- Flujo end-to-end: t web → /login → /app.
- Listado de feeds y autogeneración B.
- Regresión:
- Aislar que schedulers solo corran en el bot.
@ -259,7 +259,7 @@ Implementado: suite web con bun:test y build programático (helpers en tests/web
- Concurrencia SQLite:
- WAL + busy_timeout ya configurados; operaciones ICE (lectura) mayoritarias en web.
- Fricción de login:
- /t web es rápido; expiración 10 min adecuada; mensajes claros si expira.
- t web es rápido; expiración 10 min adecuada; mensajes claros si expira.
## 16) Variables de entorno (propuestas, apps/web)
@ -339,7 +339,7 @@ Objetivo
- Toast/Snackbar (store global; auto-dismiss; role="status").
- ConfirmDialog (portal sencillo con focus trap básico).
- Skeleton (rectángulos/filas).
- EmptyState y ErrorBanner.
- EmptyState.
- Datos
- TaskItem (fila) con: [id], descripción, fecha (badge), grupo, asignación (solo lectura).
- GroupCard con nombre, contadores open/unassigned.
@ -362,7 +362,7 @@ Objetivo
18.6) IA y flujos por pantalla
- /login
- Objetivo: canjear token con gate de interacción mínima.
- Contenido: mensaje, botón “Continuar”, estado token inválido/expirado con instrucciones /t web.
- Contenido: mensaje, botón “Continuar”, estado token inválido/expirado con instrucciones t web.
- Accesibilidad: botón enfocable, mensajes claros.
- /app (Mis tareas)
- Controles: búsqueda texto, chips “Abiertas”, “Pronto (≤3 días)”, selector “Vencen antes de…” (3/7/14 días).
@ -413,7 +413,7 @@ Objetivo
- Estructura sugerida en apps/web/src
- lib/ui/atoms: Button.svelte, IconButton.svelte, Badge.svelte, Skeleton.svelte, VisuallyHidden.svelte
- lib/ui/inputs: TextField.svelte, TimeField.svelte, SegmentedControl.svelte, Switch.svelte, Select.svelte
- lib/ui/feedback: Toast.svelte (+ store), ConfirmDialog.svelte, EmptyState.svelte, ErrorBanner.svelte
- lib/ui/feedback: Toast.svelte (+ store), ConfirmDialog.svelte, EmptyState.svelte
- lib/ui/layout: AppShell.svelte, Card.svelte, Pagination.svelte
- lib/ui/data: TaskItem.svelte, GroupCard.svelte, FeedCard.svelte
- lib/stores: toasts.ts, session.ts (mínimo, p. ej. userId)

@ -40,14 +40,14 @@ Archivos a consultar
- 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”).
- 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”.
- Decisión documentada: “t ver” en DM => “todas”.
- Alias permitidos y comunicados en help.
- Confirmación de “cero mensajes en grupo”.
@ -57,7 +57,7 @@ Criterios de aceptación
Objetivos
- Añadir y mapear alias “info” → “ayuda”; “mias” → “ver mis”; “todas” → “ver todas”.
- Cambiar default de “/t ver” (en DM) a “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
@ -67,11 +67,11 @@ Archivos a modificar
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.
- 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.
- 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).
@ -81,8 +81,8 @@ Impacto en tests
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.
- 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.
@ -100,7 +100,7 @@ Overview de cambios
- 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):
- 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,
@ -116,13 +116,13 @@ Overview de cambios
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
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”
· Tus tareas: t mias · Todas: t todas
· Recordatorios: t configurar diario | lv | semanal | off
· Web: t web”
Impacto en tests
- Tests unitarios para:
@ -150,13 +150,13 @@ Archivos a modificar
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”).
- “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”
- “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.
@ -207,10 +207,10 @@ Objetivos
Áreas de test
- Alias:
- “/t info” → ayuda
- “/t mias” → listado de asignadas
- “/t todas” → listado combinado (DM)
- “/t ver” (DM) → “todas”
- “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:
@ -234,8 +234,8 @@ Checklist de despliegue
- 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”).
- 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.
---
@ -246,21 +246,21 @@ 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 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”.
- 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”.
- 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
@ -285,20 +285,20 @@ Ideas a evaluar después
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
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”
· 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”
- “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”
- “Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web”
---

@ -1,6 +1,6 @@
# 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:
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…): ⚠️
@ -115,7 +115,7 @@ Fase 1 — Infra y reacción final por comando
- Idempotencia: consulta previa antes de insertar.
- src/services/command.ts
- Ampliar `CommandContext` con `messageId: string`.
- En la rama `/t nueva`, tras crear la tarea:
- 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
@ -136,7 +136,7 @@ Fase 2 — Reacción tardía (✅) al completar
## 6) Flujo E2E (grupo permitido)
1) Usuario envía mensaje con `/t nueva …` en un grupo.
1) Usuario envía mensaje con `t nueva …` en un grupo.
2) WebhookServer:
- Obtiene `remoteJid`, `messageId`.
- Construye `CommandContext` con `sender`, `groupId`, `message`, `mentions`, `messageId`.
@ -179,12 +179,12 @@ Fase 2 — Reacción tardía (✅) al completar
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 ⚠️.
- 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)`.
- 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.
@ -194,7 +194,7 @@ Unitarias:
- Manejo de 4xx/5xx conforme a política de reintentos.
Integración simulada:
- Flujo feliz: `/t nueva` → 🤖; `completeTask` → ✅.
- Flujo feliz: `t nueva` → 🤖; `completeTask` → ✅.
- Error: comando desconocido o “Uso:” → ⚠️.
- Grupo bloqueado en enforce → no reacción.

@ -63,7 +63,7 @@ Este documento define el plan para implementar una sincronización robusta de mi
- Logs con contexto: group_id, user_id, evento/tipo, resultado.
## Uso en la aplicación (consumidores)
- “/t ver todo” y recordatorios:
- “t ver todo” y recordatorios:
- Incluir “sin responsable” únicamente de grupos donde el usuario sea miembro activo.
- Fallback: si aún no hay snapshot de membresías, usar heurística (grupos con tareas del usuario).
- Validación (opcional por fase):
@ -93,7 +93,7 @@ Etapa 2 — Integración con Evolution API — COMPLETADA
- Reintentos/backoff en errores de red/5xx.
Etapa 3 — Consumidores (comandos y recordatorios) — COMPLETADA
- “/t ver todo” y RemindersService usan membership real.
- “t ver todo” y RemindersService usan membership real.
- Fallback heurístico si no hay snapshot aún.
- Validaciones opcionales de pertenencia.
- Tests:
@ -110,7 +110,7 @@ Etapa 4 — Observabilidad y mantenimiento — COMPLETADA
## Criterios de aceptación
- Tras una full sync, group_members refleja fielmente miembros activos por grupo.
- Webhooks de alta/baja actualizan el estado en <1s y son idempotentes.
- “/t ver todo” y recordatorios respetan la membresía y no rompen UX preexistente.
- “t ver todo” y recordatorios respetan la membresía y no rompen UX preexistente.
- 100% de tests existentes siguen pasando; nuevos tests cubren sync/reconciliación/handlers.
## Métricas y trazas sugeridas
@ -136,6 +136,6 @@ Total: 23.5 días netos.
- src/db/migrations/index.ts (migración up-only: tablas e índices).
- src/services/webhook-manager.ts (registro y handlers de eventos).
- src/services/group-sync.ts (full sync y reconciliación).
- src/services/command.ts (consumo en “/t ver todo”; validaciones opcionales).
- src/services/command.ts (consumo en “t ver todo”; validaciones opcionales).
- src/services/reminders.ts y src/tasks/service.ts (consultas usando membresía).
- tests/unit/services/* (sync, reconciliación, webhooks, consumidores).

@ -97,7 +97,7 @@ Etapa 6 — Handler “nueva” y Onboarding
Etapa 7 — Limpieza
- Reducir `CommandService` a:
- parseo de trigger (/t), registro de `last_command_at` y gating global inicial.
- 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.

@ -9,7 +9,7 @@ Principios
- Comandos e IDs en monoespaciado (backticks).
- Listas con “- ” por línea.
- Notas en cursiva.
- Brevedad y accionabilidad: priorizar ejemplos cortos y CTAs (“Prueba `/t ayuda`”, “Envía `/t web`”).
- Brevedad y accionabilidad: priorizar ejemplos cortos y CTAs (“Prueba `t ayuda`”, “Envía `t web`”).
- Estabilidad para tests: evitar asserts por igualdad exacta; preferir substrings semánticos.
Componentes de formato
@ -17,7 +17,7 @@ Componentes de formato
- Patrón: `*${TÍTULO EN MAYÚSCULAS}*`
- Ej.: `*COMANDOS BÁSICOS*`
- Comandos:
- Siempre en backticks: `` `/t ver mis` ``
- Siempre en backticks: `` `t ver mis` ``
- IDs:
- Mostrar con 4 dígitos entre backticks: `` `0026` `` (usar `codeId()`).
- Fechas:
@ -46,7 +46,7 @@ Patrones comunes
- Sufijo “... y N más” si aplica
- Ayuda rápida:
- Secciones: “COMANDOS BÁSICOS”, “LISTADOS”, “ACCESO WEB”
- Bullets con ejemplos: `` `/t n ...` ``, `` `/t ver mis|grupo|todos|sin` ``, `` `/t x 26` ``, `` `/t tomar 12` ``, `` `/t configurar ...` ``, `` `/t web` ``
- Bullets con ejemplos: `` `t n ...` ``, `` `t ver mis|grupo|todos|sin` ``, `` `t x 26` ``, `` `t tomar 12` ``, `` `t configurar ...` ``, `` `t web` ``
Localización
- Todo copy en español. Evitar fugas de claves internas en inglés (ej. “weekly”).
@ -61,7 +61,7 @@ Buenas prácticas
- Evitar párrafos largos; preferir 13 líneas por bloque.
- Los mensajes de 'Uso:' llevan el prefijo .
- Incluir uso cuando falten argumentos:
- Ej.: ` Uso: \`/t tomar 26\` o múltiples: \`/t tomar 12 19 50\` o \`/t tomar 12,19,50\` (máx. 10)`
- Ej.: ` Uso: \`t tomar 26\` o múltiples: \`t tomar 12 19 50\` o \`t tomar 12,19,50\` (máx. 10)`
- Mensajes de error claros y accionables: “No puedes tomar esta tarea… Pide acceso a un admin si crees que es un error.”
- En listados, omitir líneas en blanco finales.
@ -74,11 +74,11 @@ Ejemplos
Ayuda rápida
```
*COMANDOS BÁSICOS*
- `/t n Descripción 2025-11-05 @Ana`
- `/t ver` (en grupo) · `/t ver mis` (DM) · `/t ver todos`
- `/t x 26` · `/t tomar 12`
- `/t configurar diario|l-v|semanal|off [HH:MM]`
- `/t web`
- `t n Descripción 2025-11-05 @Ana`
- `t ver` (en grupo) · `t ver mis` (DM) · `t ver todos`
- `t x 26` · `t tomar 12`
- `t configurar diario|l-v|semanal|off [HH:MM]`
- `t web`
_El bot responde por DM, incluso si escribes desde un grupo._
```

@ -1,20 +1,21 @@
export type EvolutionResult = { ok: boolean; status?: number; error?: string };
export function buildHeaders(): HeadersInit {
function buildHeaders(): HeadersInit {
return {
apikey: process.env.EVOLUTION_API_KEY || '',
'Content-Type': 'application/json'
};
}
export async function sendText(payload: { number: string; text: string; mentioned?: string[] }): Promise<EvolutionResult> {
/** Shared helper: POST to the Evolution API endpoint, returning a standard result. */
async function evolutionPost(endpoint: string, payload: unknown): Promise<EvolutionResult> {
const baseUrl = process.env.EVOLUTION_API_URL;
const instance = process.env.EVOLUTION_API_INSTANCE;
if (!baseUrl || !instance) {
const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE';
return { ok: false, error: msg };
}
const url = `${baseUrl}/message/sendText/${instance}`;
const url = `${baseUrl}/message/${endpoint}/${instance}`;
try {
const res = await fetch(url, {
method: 'POST',
@ -33,31 +34,13 @@ export async function sendText(payload: { number: string; text: string; mentione
}
}
export async function sendText(payload: { number: string; text: string; mentioned?: string[] }): Promise<EvolutionResult> {
return evolutionPost('sendText', payload);
}
export async function sendReaction(payload: {
key: { remoteJid: string; id: string; fromMe: boolean; participant?: string };
reaction: string;
}): Promise<EvolutionResult> {
const baseUrl = process.env.EVOLUTION_API_URL;
const instance = process.env.EVOLUTION_API_INSTANCE;
if (!baseUrl || !instance) {
const msg = 'Missing EVOLUTION_API_URL or EVOLUTION_API_INSTANCE';
return { ok: false, error: msg };
}
const url = `${baseUrl}/message/sendReaction/${instance}`;
try {
const res = await fetch(url, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify(payload)
});
if (!res.ok) {
const body = await res.text().catch(() => '');
const errTxt = body?.slice(0, 200) || `HTTP ${res.status}`;
return { ok: false, status: res.status, error: errTxt };
}
return { ok: true, status: res.status };
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
return { ok: false, error: errMsg };
}
return evolutionPost('sendReaction', payload);
}

@ -21,7 +21,7 @@ function applyDefaultPragmas(instance: Database): void {
}
// Function to get a database instance. Defaults to 'tmp/tasks.db' in dev and '/app/data/tasks.db' in prod (overridable via DB_PATH/DATA_DIR)
export function getDb(filename: string = 'tasks.db'): Database {
function getDb(filename: string = 'tasks.db'): Database {
const absolutePath = resolveDbAbsolutePath(filename);
// Asegurar directorio padre

@ -1,15 +1,5 @@
import type { Database } from 'bun:sqlite';
/**
* Error específico cuando se intenta acceder a la DB sin haberla configurado.
*/
export class DbNotConfiguredError extends Error {
constructor(message: string = 'Database has not been configured. Call setDb(db) before using getDb().') {
super(message);
this.name = 'DbNotConfiguredError';
}
}
let currentDb: Database | null = null;
/**
@ -25,7 +15,7 @@ export function setDb(db: Database): void {
*/
export function getDb(): Database {
if (currentDb) return currentDb;
throw new DbNotConfiguredError('Database has not been configured. Call setDb(db) before using getDb().');
throw new Error('Database has not been configured. Call setDb(db) before using getDb().');
}
/**
@ -35,13 +25,6 @@ export function resetDb(): void {
currentDb = null;
}
/**
* Alias de resetDb() por ergonomía en tests.
*/
export function clearDb(): void {
currentDb = null;
}
/**
* Ejecuta una función con la DB actual (sync o async) y devuelve su resultado.
*/

@ -78,6 +78,78 @@ function backupDatabaseIfNeeded(db: Database): string | null {
}
}
// ---------------------------------------------------------------------------
// Migration runner helpers
// ---------------------------------------------------------------------------
function validateMigrationsChecksums(
applied: Map<number, { name: string; checksum: string; applied_at: string }>,
): void {
const strict = (process.env.MIGRATOR_CHECKSUM_STRICT ?? 'true').toLowerCase() !== 'false';
for (const [version, info] of applied) {
const codeMig = migrations.find(m => m.version === version);
if (!codeMig || codeMig.checksum === info.checksum) continue;
const msg = `❌ Checksum mismatch en migración v${version}: aplicado=${info.checksum}, código=${codeMig.checksum}`;
console.error(msg);
try { logEvent('error', 'checksum_mismatch', { version, applied_checksum: info.checksum, code_checksum: codeMig.checksum }); } catch {}
if (strict) throw new Error(msg);
}
}
function logStartupSummary(
db: Database,
applied: Map<number, any>,
pending: Migration[],
): void {
const jmRow = db.query(`PRAGMA journal_mode`).get() as Record<string, unknown> | undefined;
const journalMode = jmRow ? String((jmRow['journal_mode'] ?? jmRow['value'] ?? jmRow['mode'] ?? 'unknown')) : 'unknown';
const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0;
if (!MIGRATIONS_QUIET) console.log(` Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`);
try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {}
}
function maybeApplyBaseline(db: Database): void {
const v1 = migrations.find(m => m.version === 1)!;
db.transaction(() => {
insertMigrationRow(db, v1);
})();
if (!MIGRATIONS_QUIET) console.log(' Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)');
try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {}
}
function applyMigration(db: Database, mig: Migration): void {
if (!MIGRATIONS_QUIET) console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`);
try {
try { logEvent('info', 'apply_start', { version: mig.version, name: mig.name, checksum: mig.checksum }); } catch {}
const t0 = Date.now();
db.transaction(() => {
const res = mig.up(db);
if (res instanceof Promise) {
throw new Error('Las migraciones up no deben ser asíncronas en este migrador');
}
insertMigrationRow(db, mig);
})();
const ms = Date.now() - t0;
if (!MIGRATIONS_QUIET) console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`);
try { logEvent('info', 'apply_success', { version: mig.version, name: mig.name, checksum: mig.checksum, duration_ms: ms }); } catch {}
} catch (e) {
console.error(`❌ Error aplicando migración v${mig.version}:`, e);
try { logEvent('error', 'apply_error', { version: mig.version, name: mig.name, checksum: mig.checksum, error: String(e) }); } catch {}
throw e;
}
}
function storeBackupIfNeeded(db: Database, withBackup: boolean): void {
if (!withBackup) return;
const backupPath = backupDatabaseIfNeeded(db);
try { logEvent('info', 'backup', { path: backupPath }); } catch {}
}
// ---------------------------------------------------------------------------
// Migrator object
// ---------------------------------------------------------------------------
export const Migrator = {
ensureMigrationsTable,
getAppliedVersions,
@ -88,37 +160,16 @@ export const Migrator = {
ensureMigrationsTable(db);
const applied = getAppliedVersions(db);
const pending = migrations.filter(m => !applied.has(m.version)).sort((a, b) => a.version - b.version);
// Validación de checksum (estricta por defecto, configurable)
const strict = (process.env.MIGRATOR_CHECKSUM_STRICT ?? 'true').toLowerCase() !== 'false';
for (const [version, info] of applied) {
const codeMig = migrations.find(m => m.version === version);
if (codeMig && codeMig.checksum !== info.checksum) {
const msg = `❌ Checksum mismatch en migración v${version}: aplicado=${info.checksum}, código=${codeMig.checksum}`;
console.error(msg);
try { logEvent('error', 'checksum_mismatch', { version, applied_checksum: info.checksum, code_checksum: codeMig.checksum }); } catch {}
if (strict) throw new Error(msg);
}
}
let pending = migrations
.filter(m => !applied.has(m.version))
.sort((a, b) => a.version - b.version);
// Resumen inicial
const jmRow = db.query(`PRAGMA journal_mode`).get() as Record<string, unknown> | undefined;
const journalMode = jmRow ? String((jmRow['journal_mode'] ?? jmRow['value'] ?? jmRow['mode'] ?? 'unknown')) : 'unknown';
const currentVersion = applied.size ? Math.max(...Array.from(applied.keys())) : 0;
if (!MIGRATIONS_QUIET) console.log(` Migrador — journal_mode=${journalMode}, versión_actual=${currentVersion}, pendientes=${pending.length}`);
try { logEvent('info', 'startup_summary', { journal_mode: journalMode, current_version: currentVersion, pending: pending.length }); } catch {}
validateMigrationsChecksums(applied);
logStartupSummary(db, applied, pending);
if (applied.size === 0 && allowBaseline && detectExistingSchema(db)) {
// Baseline a v1 si ya existe el esquema pero no hay registro
const v1 = migrations.find(m => m.version === 1)!;
db.transaction(() => {
insertMigrationRow(db, v1);
})();
if (!MIGRATIONS_QUIET) console.log(' Baseline aplicado: schema_migrations marcada en v1 (sin ejecutar up)');
try { logEvent('info', 'baseline_applied', { version: 1 }); } catch {}
// Recalcular pendientes
pending.splice(0, pending.length, ...migrations.filter(m => m.version > 1));
maybeApplyBaseline(db);
pending = migrations.filter(m => m.version > 1);
}
if (pending.length === 0) {
@ -127,33 +178,10 @@ export const Migrator = {
return;
}
if (withBackup) {
const backupPath = backupDatabaseIfNeeded(db);
try { logEvent('info', 'backup', { path: backupPath }); } catch {}
}
storeBackupIfNeeded(db, withBackup);
for (const mig of pending) {
if (!MIGRATIONS_QUIET) console.log(`➡️ Aplicando migración v${mig.version} - ${mig.name}`);
try {
try { logEvent('info', 'apply_start', { version: mig.version, name: mig.name, checksum: mig.checksum }); } catch {}
const t0 = Date.now();
db.transaction(() => {
// Ejecutar up
const res = mig.up(db);
if (res instanceof Promise) {
throw new Error('Las migraciones up no deben ser asíncronas en este migrador');
}
// Registrar
insertMigrationRow(db, mig);
})();
const ms = Date.now() - t0;
if (!MIGRATIONS_QUIET) console.log(`✅ Migración v${mig.version} aplicada (${ms} ms)`);
try { logEvent('info', 'apply_success', { version: mig.version, name: mig.name, checksum: mig.checksum, duration_ms: ms }); } catch {}
} catch (e) {
console.error(`❌ Error aplicando migración v${mig.version}:`, e);
try { logEvent('error', 'apply_error', { version: mig.version, name: mig.name, checksum: mig.checksum, error: String(e) }); } catch {}
throw e;
}
applyMigration(db, mig);
}
}
},
};

@ -0,0 +1,7 @@
export const REQUIRED_ENV = [
'EVOLUTION_API_URL',
'EVOLUTION_API_KEY',
'EVOLUTION_API_INSTANCE',
'CHATBOT_PHONE_NUMBER',
'WEBHOOK_URL'
];

@ -70,25 +70,3 @@ export async function startServices(_db: Database): Promise<void> {
}
}
export function stopServices(): void {
try {
WebhookManager.stopAutoEnsure();
} catch {}
try {
ResponseQueue.stopCleanupScheduler();
} catch {}
try {
// No existe un "stop" público de workers; paramos el lazo
(ResponseQueue as any).stop?.();
} catch {}
try {
RemindersService.stop();
} catch {}
try {
GroupSyncService.stopGroupsScheduler();
GroupSyncService.stopMembersScheduler();
} catch {}
try {
MaintenanceService.stop();
} catch {}
}

@ -2,15 +2,11 @@ import type { Database } from 'bun:sqlite';
import { Metrics } from '../services/metrics';
import { GroupSyncService } from '../services/group-sync';
export async function handleMetricsRequest(request: Request, db: Database): Promise<Response> {
if (request.method !== 'GET') {
return new Response('🚫 Method not allowed', { status: 405 });
}
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
// ---------------------------------------------------------------------------
// Metric collectors
// ---------------------------------------------------------------------------
// Gauges de allowed_groups por estado (best-effort)
function collectAllowedGroupMetrics(db: Database): void {
try {
const rows = db
.prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`)
@ -27,10 +23,10 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom
Metrics.set('allowed_groups_total_allowed', allowed);
Metrics.set('allowed_groups_total_blocked', blocked);
} catch {}
}
// Métricas de grupos y usuarios (gauges derivadas desde BD)
function collectGroupAndUserMetrics(db: Database): void {
try {
// Grupos: totales, activos y archivados (siempre excluyendo comunidades)
const groupRow = db
.prepare(`
SELECT
@ -54,16 +50,11 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom
.get() as any;
if (groupRow) {
const groupsTotal = Number(groupRow.total ?? 0);
const groupsActive = Number(groupRow.active ?? 0);
const groupsArchived = Number(groupRow.archived ?? 0);
Metrics.set('groups_total', groupsTotal);
Metrics.set('groups_active_total', groupsActive);
Metrics.set('groups_archived_total', groupsArchived);
Metrics.set('groups_total', Number(groupRow.total ?? 0));
Metrics.set('groups_active_total', Number(groupRow.active ?? 0));
Metrics.set('groups_archived_total', Number(groupRow.archived ?? 0));
}
// Miembros de grupos: solo miembros activos en grupos activos, no comunidad, no archivados
const gmRow = db
.prepare(`
SELECT COUNT(*) AS total
@ -77,22 +68,17 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom
.get() as any;
if (gmRow) {
const gmTotal = Number(gmRow.total ?? 0);
Metrics.set('group_members_total', gmTotal);
Metrics.set('group_members_total', Number(gmRow.total ?? 0));
}
// Usuarios totales
const usersRow = db
.prepare(`SELECT COUNT(*) AS total FROM users;`)
.get() as any;
const usersRow = db.prepare(`SELECT COUNT(*) AS total FROM users;`).get() as any;
if (usersRow) {
const usersTotal = Number(usersRow.total ?? 0);
Metrics.set('users_total', usersTotal);
Metrics.set('users_total', Number(usersRow.total ?? 0));
}
} catch {}
}
// Métricas de tareas (gauges derivadas desde BD)
function collectTaskMetrics(db: Database): void {
try {
const row = db
.prepare(`
@ -114,19 +100,15 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom
.get() as any;
if (row) {
const total = Number(row.total ?? 0);
const completed = Number(row.completed ?? 0);
const active = Number(row.active ?? 0);
const overdue = Number(row.overdue ?? 0);
Metrics.set('tasks_created_total', total);
Metrics.set('tasks_completed_total', completed);
Metrics.set('tasks_active', active);
Metrics.set('tasks_overdue', overdue);
Metrics.set('tasks_created_total', Number(row.total ?? 0));
Metrics.set('tasks_completed_total', Number(row.completed ?? 0));
Metrics.set('tasks_active', Number(row.active ?? 0));
Metrics.set('tasks_overdue', Number(row.overdue ?? 0));
}
} catch {}
}
// Métricas de cola de respuestas (gauges derivadas desde BD)
function collectResponseQueueMetrics(db: Database): void {
try {
const row = db
.prepare(`
@ -160,13 +142,32 @@ export async function handleMetricsRequest(request: Request, db: Database): Prom
Metrics.set('response_queue_oldest_age_seconds', ageSeconds);
}
} catch {}
}
// Exponer métrica con el tiempo restante hasta el próximo group sync (o -1 si scheduler inactivo)
function collectGroupSyncMetric(): void {
try {
const secs = GroupSyncService.getSecondsUntilNextGroupSync();
const val = (secs == null || !Number.isFinite(secs)) ? -1 : secs;
Metrics.set('group_sync_seconds_until_next', val);
Metrics.set('group_sync_seconds_until_next', (secs == null || !Number.isFinite(secs)) ? -1 : secs);
} catch {}
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export async function handleMetricsRequest(request: Request, db: Database): Promise<Response> {
if (request.method !== 'GET') {
return new Response('🚫 Method not allowed', { status: 405 });
}
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
collectAllowedGroupMetrics(db);
collectGroupAndUserMetrics(db);
collectTaskMetrics(db);
collectResponseQueueMetrics(db);
collectGroupSyncMetric();
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
const body = Metrics.render(format as any);

@ -11,7 +11,17 @@ import { TaskService } from '../tasks/service';
import { RateLimiter } from '../services/rate-limit';
import { Metrics } from '../services/metrics';
function getMessageText(message: any): string {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isTest = () => process.env.NODE_ENV === 'test';
const log = (msg: string, ...args: unknown[]) => { if (!isTest()) console.log(msg, ...args); };
const logErr = (msg: string, ...args: unknown[]) => { if (!isTest()) console.error(msg, ...args); };
const logWarn = (msg: string, ...args: unknown[]) => { if (!isTest()) console.warn(msg, ...args); };
const logDebug = (msg: string, ...args: unknown[]) => { if (!isTest()) console.debug(msg, ...args); };
export function getMessageText(message: any): string {
if (!message || typeof message !== 'object') return '';
const text =
message.conversation ||
@ -22,294 +32,392 @@ function getMessageText(message: any): string {
return typeof text === 'string' ? text.trim() : '';
}
export async function handleMessageUpsert(data: any, db: Database): Promise<void> {
if (!data?.key?.remoteJid || !data.message) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Invalid message format - missing required fields');
console.log(data);
}
return;
}
function isEnvTrue(key: string): boolean {
return ['true', '1', 'yes', 'on'].includes(String(process.env[key] || 'false').toLowerCase());
}
const messageText = getMessageText(data.message);
if (!messageText) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Empty or unsupported message content');
}
return;
function resolveRateLimitPerMin(): number {
const v = Number(process.env.RATE_LIMIT_PER_MIN);
return Number.isFinite(v) && v > 0 ? v : 15;
}
// ---------------------------------------------------------------------------
// Message validation
// ---------------------------------------------------------------------------
/** Returns false if the message payload is missing required fields. */
function isValidMessageShape(data: any): boolean {
if (!data?.key?.remoteJid || !data.message) {
log('⚠️ Invalid message format - missing required fields', data);
return false;
}
return true;
}
// Determine sender depending on context (group vs DM) and ignore non-user messages
const remoteJid = data.key.remoteJid;
const participant = data.key.participant;
const fromMe = !!data.key.fromMe;
// ---------------------------------------------------------------------------
// Sender resolution
// ---------------------------------------------------------------------------
// Ignore broadcasts/status
/** Returns true when the message should be discarded (broadcast or self). */
function shouldSkipBroadcastOrSelf(data: any, remoteJid: string): boolean {
if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring broadcast/status message');
}
return;
log(' Ignoring broadcast/status message');
return true;
}
// Ignore our own messages
if (fromMe) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring message sent by the bot (fromMe=true)');
}
return;
if (data.key.fromMe) {
log(' Ignoring message sent by the bot (fromMe=true)');
return true;
}
return false;
}
// Compute sender JID based on chat type (prefer participantAlt when available due to Baileys change)
const senderRaw = isGroupId(remoteJid)
? (data.key.participantAlt || participant)
: remoteJid;
// Aprender mapping alias→número cuando vienen ambos y difieren (participant vs participantAlt)
/** Prefers participantAlt (Baileys change) in groups, falls back to remoteJid. */
function resolveSenderJid(data: any, remoteJid: string): string {
if (isGroupId(remoteJid)) {
const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null;
const p = typeof participant === 'string' ? participant : null;
if (pAlt && p) {
try {
const nAlt = normalizeWhatsAppId(pAlt);
const n = normalizeWhatsAppId(p);
if (process.env.NODE_ENV !== 'test') {
console.log('[A0] message.key participants', {
participant: p,
participantAlt: pAlt,
normalized_participant: n,
normalized_participantAlt: nAlt,
alias_upsert: !!(nAlt && n && nAlt !== n)
});
}
if (nAlt && n && nAlt !== n) {
IdentityService.upsertAlias(p, pAlt, 'message.key');
}
} catch {}
}
return data.key.participantAlt || data.key.participant || remoteJid;
}
return remoteJid;
}
// Normalize sender ID for consistency and validation
const normalizedSenderId = normalizeWhatsAppId(senderRaw);
if (!normalizedSenderId) {
if (process.env.NODE_ENV !== 'test') {
console.debug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe });
/** Learns participant ↔ participantAlt alias when both differ. */
function learnAliasFromKey(data: any, remoteJid: string): void {
if (!isGroupId(remoteJid)) return;
const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null;
const p = typeof data.key.participant === 'string' ? data.key.participant : null;
if (!pAlt || !p) return;
try {
const nAlt = normalizeWhatsAppId(pAlt);
const n = normalizeWhatsAppId(p);
if (nAlt && n && nAlt !== n) {
log('[A0] message.key participants', {
participant: p,
participantAlt: pAlt,
normalized_participant: n,
normalized_participantAlt: nAlt,
alias_upsert: true,
});
IdentityService.upsertAlias(p, pAlt, 'message.key');
}
return;
} catch { /* best-effort */ }
}
/** Returns the normalized WhatsApp ID, or null if invalid. */
function normalizeSender(senderRaw: string, remoteJid: string, participant: string, fromMe: boolean): string | null {
const id = normalizeWhatsAppId(senderRaw);
if (!id) {
logDebug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe });
return null;
}
return id;
}
// Avoid processing messages from the bot number
if (process.env.CHATBOT_PHONE_NUMBER && normalizedSenderId === process.env.CHATBOT_PHONE_NUMBER) {
if (process.env.NODE_ENV !== 'test') {
console.log(' Ignoring message from the bot number');
}
return;
/** True if the sender matches the configured bot phone number. */
function isOwnBotNumber(normalizedId: string): boolean {
if (process.env.CHATBOT_PHONE_NUMBER && normalizedId === process.env.CHATBOT_PHONE_NUMBER) {
log(' Ignoring message from the bot number');
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// User / activation
// ---------------------------------------------------------------------------
// Ensure user exists in database (swallow DB errors to keep webhook 200)
let userId: string | null = null;
/** Ensures the sender exists in the database. Returns the userId or null on failure. */
function ensureUserOrBail(senderRaw: string, db: Database): string | null {
try {
userId = ensureUserExists(senderRaw, db);
const userId = ensureUserExists(senderRaw, db);
if (!userId) log('⚠️ Failed to ensure user exists, ignoring message');
return userId || null;
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Error ensuring user exists, ignoring message:', e);
logErr('⚠️ Error ensuring user exists, ignoring message:', e);
return null;
}
}
/** Handles the "activar" DM flow. Returns true if the message was consumed. */
async function handleActivationDM(
remoteJid: string,
senderId: string,
text: string,
): Promise<boolean> {
if (isGroupId(remoteJid) || text !== 'activar') return false;
const base = (process.env.WEB_BASE_URL || '').trim();
const msg = base
? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía 't web' y abre el enlace."
: "Listo, ya puedes reclamar/ser responsable.";
try { await ResponseQueue.add([{ recipient: senderId, message: msg }]); } catch {}
return true;
}
// ---------------------------------------------------------------------------
// Group gating
// ---------------------------------------------------------------------------
/**
* Discover mode: when an unknown group receives a message, register it as
* pending and optionally notify admins.
*
* Returns true if the message was consumed (not an admin command bail out).
*/
async function handleGroupDiscovery(
db: Database,
groupId: string,
senderId: string,
isAdminCmd: boolean,
): Promise<boolean> {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode !== 'discover') return false;
try {
const exists = db.prepare('SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1').get(groupId);
if (exists) return false; // already known — continue normally
} catch {
// Table may not exist yet — fall through to discovery
}
// Register unknown group
return await registerDiscoveredGroup(groupId, senderId, isAdminCmd);
}
/**
* Enforce mode: silently drop messages from groups that haven't been
* approved by an admin (unless the message itself is an /admin command).
*
* Returns true if the message was blocked.
*/
function handleGroupEnforcement(groupId: string, isAdminCmd: boolean): boolean {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode !== 'enforce') return false;
try {
if (!AllowedGroups.isAllowed(groupId) && !isAdminCmd) {
try { Metrics.inc('messages_blocked_group_total'); } catch {}
return true;
}
return;
} catch {
// If the check fails, be permissive
}
if (!userId) {
if (process.env.NODE_ENV !== 'test') {
console.log('⚠️ Failed to ensure user exists, ignoring message');
return false;
}
/** Shared discovery registration & admin notification. */
async function registerDiscoveredGroup(
groupId: string,
senderId: string,
isAdminCmd: boolean,
): Promise<boolean> {
try { await GroupSyncService.ensureGroupLabelAndName(groupId); } catch {}
try { AllowedGroups.upsertPending(groupId, GroupSyncService.activeGroupsCache.get(groupId) || null, senderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
if (isEnvTrue('NOTIFY_ADMINS_ON_DISCOVERY') && !isAdminCmd) {
const admins = AdminService.getAdmins();
if (admins.length > 0) {
const msg = `🔎 Nuevo grupo detectado: ${groupId}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${groupId}.`;
try { await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); } catch {}
}
return;
}
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
// Admin commands pass through; everything else stops here
return !isAdminCmd;
}
// A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM)
if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') {
const base = (process.env.WEB_BASE_URL || '').trim();
const msg = base
? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace."
: "Listo, ya puedes reclamar/ser responsable.";
try {
await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]);
} catch {}
return;
// ---------------------------------------------------------------------------
// Admin commands
// ---------------------------------------------------------------------------
/** Routes /admin commands. Returns true if the message was an admin command. */
async function handleAdminCommand(
senderId: string,
groupId: string,
messageText: string,
): Promise<boolean> {
if (!messageText.trim().startsWith('/admin')) return false;
const responses = await AdminService.handle({
sender: senderId,
groupId,
message: messageText,
});
if (responses.length > 0) {
await ResponseQueue.add(responses);
}
return true;
}
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (gatingMode === 'discover') {
try {
const exists = db
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`)
.get(remoteJid);
if (!exists) {
try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {}
try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
try {
const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true';
if (notify && !isAdminCmd) {
const admins = AdminService.getAdmins();
if (admins.length > 0) {
const info = remoteJid;
const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`;
await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg })));
}
}
} catch {}
if (!isAdminCmd) return;
}
} catch {
// Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente
try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {}
try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
try {
const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true';
if (notify && !isAdminCmd) {
const admins = AdminService.getAdmins();
if (admins.length > 0) {
const info = remoteJid;
const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`;
await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg })));
}
}
} catch {}
if (!isAdminCmd) return;
}
}
// ---------------------------------------------------------------------------
// Group cache
// ---------------------------------------------------------------------------
/**
* Ensures the group is present in the active-groups cache (lazy registration).
* In test mode, inactive groups cause an early bail; in production they are
* registered on the fly.
*
* Returns true when the message should be dropped (tests only).
*/
function ensureGroupActive(db: Database, groupId: string, senderId: string): boolean {
if (!isGroupId(groupId) || GroupSyncService.isGroupActive(groupId)) return false;
if (isTest()) return true; // tests: bail on inactive groups
log(' Group not active in cache — ensuring group (no immediate members sync)');
try {
GroupSyncService.ensureGroupExists(groupId);
try { GroupSyncService.upsertMemberSeen(groupId, senderId); } catch {}
} catch (e) {
logErr('⚠️ Failed to ensure group on-the-fly:', e);
}
return false;
}
// Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos
if (isGroupId(remoteJid)) {
const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (gatingMode2 === 'enforce') {
try {
const allowed = AllowedGroups.isAllowed(remoteJid);
if (!allowed && !isAdminCmd) {
try { Metrics.inc('messages_blocked_group_total'); } catch {}
return;
}
} catch {
// Si falla el check por cualquier motivo, ser conservadores y permitir
}
}
// ---------------------------------------------------------------------------
// Task command routing (t, tarea)
// ---------------------------------------------------------------------------
/** Extracts mentioned JIDs across all known message contexts. */
function extractMentions(data: any): string[] {
return data.message?.contextInfo?.mentionedJid
|| data.message?.extendedTextMessage?.contextInfo?.mentionedJid
|| data.message?.imageMessage?.contextInfo?.mentionedJid
|| data.message?.videoMessage?.contextInfo?.mentionedJid
|| [];
}
/** Rate-limits the sender. Returns false (and optionally notifies) if over the limit. */
async function applyRateLimit(senderId: string): Promise<boolean> {
if (isTest()) return true;
if (RateLimiter.checkAndConsume(senderId)) return true;
if (RateLimiter.shouldNotify(senderId)) {
await ResponseQueue.add([{
recipient: senderId,
message: `Has superado el límite de ${resolveRateLimitPerMin()} comandos por minuto. Inténtalo de nuevo en un momento.`,
}]);
}
return false;
}
// Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo
if (messageTextTrimmed.startsWith('/admin')) {
const adminResponses = await AdminService.handle({
sender: normalizedSenderId,
groupId: remoteJid,
message: messageText
});
if (adminResponses.length > 0) {
await ResponseQueue.add(adminResponses);
}
return;
/**
* Adds a success/error emoji reaction to the command message when reactions
* are enabled and in scope.
*/
async function maybeReactToCommand(data: any, outcome: { ok: boolean }, messageId: string): Promise<void> {
if (!isEnvTrue('REACTIONS_ENABLED')) return;
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
const isGroup = isGroupId(data.key.remoteJid);
if (scope !== 'all' && !isGroup) return;
// Respect enforce gating for reactions
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce' && isGroup) {
try { if (!AllowedGroups.isAllowed(data.key.remoteJid)) return; } catch {}
}
// Check/ensure group exists (allow DMs always)
if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) {
// En tests, mantener comportamiento anterior: ignorar mensajes de grupos inactivos
if (process.env.NODE_ENV === 'test') {
return;
}
if (process.env.NODE_ENV !== 'test') {
console.log(' Group not active in cache — ensuring group (no immediate members sync)');
}
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,
});
}
/** Routes t and tarea commands (with trailing space) through the command service. */
async function handleTaskCommand(
data: any,
db: Database,
senderId: string,
messageText: string,
): Promise<void> {
if (!await applyRateLimit(senderId)) return;
const mentions = extractMentions(data);
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({
sender: senderId,
groupId: data.key.remoteJid,
message: messageText,
mentions,
messageId: messageId || undefined,
participant: participantForKey || undefined,
fromMe: !!data?.key?.fromMe,
});
if (outcome.responses.length > 0) {
await ResponseQueue.add(outcome.responses);
}
if (messageId) {
try {
GroupSyncService.ensureGroupExists(data.key.remoteJid);
try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {}
await maybeReactToCommand(data, outcome, messageId);
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.error('⚠️ Failed to ensure group on-the-fly:', e);
}
logWarn('⚠️ Reaction enqueue failed:', e);
}
}
}
// Forward to command service only if it's a text-ish message and starts with /t or /tarea
if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) {
// Rate limiting básico por usuario (desactivado en tests)
if (process.env.NODE_ENV !== 'test') {
const allowed = RateLimiter.checkAndConsume(normalizedSenderId);
if (!allowed) {
// Notificar como máximo una vez por minuto
if (RateLimiter.shouldNotify(normalizedSenderId)) {
await ResponseQueue.add([{
recipient: normalizedSenderId,
message: `Has superado el límite de ${((() => { const v = Number(process.env.RATE_LIMIT_PER_MIN); return Number.isFinite(v) && v > 0 ? v : 15; })())} comandos por minuto. Inténtalo de nuevo en un momento.`
}]);
}
return;
}
}
// Extraer menciones desde el mensaje (varios formatos)
const mentions = data.message?.contextInfo?.mentionedJid
|| data.message?.extendedTextMessage?.contextInfo?.mentionedJid
|| data.message?.imageMessage?.contextInfo?.mentionedJid
|| data.message?.videoMessage?.contextInfo?.mentionedJid
|| [];
// Asegurar que CommandService y TaskService usen la misma DB (tests/producción)
// 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({
sender: normalizedSenderId,
groupId: data.key.remoteJid,
message: messageText,
mentions,
messageId: messageId || undefined,
participant: participantForKey || undefined,
fromMe: !!data?.key?.fromMe
});
const responses = outcome.responses;
// Encolar respuestas si las hay
if (responses.length > 0) {
await ResponseQueue.add(responses);
}
/** True when the message should be forwarded to the command service. */
function isTaskCommand(text: string): boolean {
return text.startsWith('tarea ') || text.startsWith('t ');
}
// 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'
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);
}
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleMessageUpsert(data: any, db: Database): Promise<void> {
// 1. Validate message shape
if (!isValidMessageShape(data)) return;
const messageText = getMessageText(data.message);
if (!messageText) { log('⚠️ Empty or unsupported message content'); return; }
const remoteJid: string = data.key.remoteJid;
// 2. Broadcast / self-message guard
if (shouldSkipBroadcastOrSelf(data, remoteJid)) return;
// 3. Resolve & normalize sender
const senderRaw = resolveSenderJid(data, remoteJid);
learnAliasFromKey(data, remoteJid);
const normalizedSenderId = normalizeSender(senderRaw, remoteJid, data.key.participant, !!data.key.fromMe);
if (!normalizedSenderId) return;
if (isOwnBotNumber(normalizedSenderId)) return;
// 4. Ensure user exists
if (!ensureUserOrBail(senderRaw, db)) return;
const text = messageText.trim();
const isAdminCmd = text.startsWith('/admin');
// 5. Activation DM
if (await handleActivationDM(remoteJid, normalizedSenderId, text)) return;
// 6. Group gating (discover → enforce)
if (await handleGroupDiscovery(db, remoteJid, normalizedSenderId, isAdminCmd)) return;
if (handleGroupEnforcement(remoteJid, isAdminCmd)) return;
// 7. Admin commands (before any other group logic)
if (await handleAdminCommand(normalizedSenderId, remoteJid, messageText)) return;
// 8. Ensure group is active (lazy registration in prod, bail in tests)
if (ensureGroupActive(db, remoteJid, normalizedSenderId)) return;
// 9. Task commands (t, tarea)
if (isTaskCommand(text)) {
await handleTaskCommand(data, db, normalizedSenderId, messageText);
}
}

@ -8,17 +8,10 @@ import { AllowedGroups } from './services/allowed-groups';
import { db } from './db';
import { handleMetricsRequest } from './http/metrics';
import { handleHealthRequest } from './http/health';
import { REQUIRED_ENV } from './env/required';
import { startServices } from './http/bootstrap';
import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler';
export const REQUIRED_ENV = [
'EVOLUTION_API_URL',
'EVOLUTION_API_KEY',
'EVOLUTION_API_INSTANCE',
'CHATBOT_PHONE_NUMBER',
'WEBHOOK_URL'
];
type WebhookPayload = {
event: string;
instance: string;
@ -36,19 +29,61 @@ export class WebhookServer {
return `${proto}://${host}`;
}
private static getMessageText(message: any): string {
if (!message || typeof message !== 'object') return '';
const text =
message.conversation ||
message?.extendedTextMessage?.text ||
message?.imageMessage?.caption ||
message?.videoMessage?.caption ||
'';
return typeof text === 'string' ? text.trim() : '';
private static shouldVerifyInstance(): boolean {
return process.env.NODE_ENV !== 'test' || !!process.env.TEST_VERIFY_INSTANCE;
}
private static async handleGroupsUpsert(): Promise<void> {
await GroupSyncService.syncGroups();
GroupSyncService.refreshActiveGroupsCache();
const changed = GroupSyncService.getLastChangedActive();
if (changed.length > 0) {
await GroupSyncService.syncMembersForGroups(changed);
} else {
await GroupSyncService.syncMembersForActiveGroups();
}
}
/** Route a parsed webhook payload to the appropriate handler. */
private static async routeWebhookEvent(data: any, evt: string): Promise<void> {
const evtNorm = evt.toLowerCase().replace(/_/g, '.');
try {
Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`);
} catch {}
switch (evtNorm) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', {
groupId: data?.key?.remoteJid,
message: data?.message?.conversation,
rawEvent: evt
});
}
await WebhookServer.handleMessageUpsert(data);
break;
case 'contacts.update':
case 'chats.update':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling contacts/chats update event:', { rawEvent: evt });
}
ContactsService.updateFromWebhook(data);
break;
case 'groups.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling groups upsert event:', { rawEvent: evt });
}
try {
await WebhookServer.handleGroupsUpsert();
} catch (e) {
console.error('❌ Error handling groups.upsert:', e);
}
break;
}
}
static async handleRequest(request: Request): Promise<Response> {
// Health check endpoint y métricas
const url = new URL(request.url);
if (url.pathname.endsWith('/metrics')) {
return await handleMetricsRequest(request, WebhookServer.dbInstance);
@ -61,88 +96,27 @@ export class WebhookServer {
console.log(' Incoming webhook request:');
}
// 1. Method validation
if (request.method !== 'POST') {
return new Response('🚫 Method not allowed', { status: 405 });
}
// 2. Content-Type validation
const contentType = request.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return new Response('🚫 Invalid content type', { status: 400 });
}
try {
// 3. Parse and validate payload
const payload = (await request.json()) as WebhookPayload;
if (!payload.event || !payload.instance) {
return new Response('🚫 Invalid payload', { status: 400 });
}
// 4. Verify instance matches (skip in test environment unless TEST_VERIFY_INSTANCE is set)
if (
(process.env.NODE_ENV !== 'test' || process.env.TEST_VERIFY_INSTANCE) &&
payload.instance !== process.env.EVOLUTION_API_INSTANCE
) {
if (WebhookServer.shouldVerifyInstance() && payload.instance !== process.env.EVOLUTION_API_INSTANCE) {
return new Response('🚫 Invalid instance', { status: 403 });
}
// 5. Route events
// console.log(' Webhook event received:', {
// event: payload.event,
// instance: payload.instance,
// data: payload.data ? '[...]' : null
// });
// Normalize event name to handle different casing/format (e.g., MESSAGES_UPSERT)
const evt = String(payload.event);
const evtNorm = evt.toLowerCase().replace(/_/g, '.');
// Contabilizar evento de webhook por tipo
try {
Metrics.inc(`webhook_events_total_${evtNorm.replace(/\./g, '_')}`);
} catch {}
switch (evtNorm) {
case 'messages.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling message upsert:', {
groupId: payload.data?.key?.remoteJid,
message: payload.data?.message?.conversation,
rawEvent: evt
});
}
await WebhookServer.handleMessageUpsert(payload.data);
break;
case 'contacts.update':
case 'chats.update':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling contacts/chats update event:', {
rawEvent: evt
});
}
ContactsService.updateFromWebhook(payload.data);
break;
case 'groups.upsert':
if (process.env.NODE_ENV !== 'test') {
console.log(' Handling groups upsert event:', { rawEvent: evt });
}
try {
const res = await GroupSyncService.syncGroups();
GroupSyncService.refreshActiveGroupsCache();
const changed = GroupSyncService.getLastChangedActive();
if (changed.length > 0) {
await GroupSyncService.syncMembersForGroups(changed);
} else {
await GroupSyncService.syncMembersForActiveGroups();
}
} catch (e) {
console.error('❌ Error handling groups.upsert:', e);
}
break;
// Other events will be added later
}
await WebhookServer.routeWebhookEvent(payload.data, String(payload.event));
return new Response('OK', { status: 200 });
} catch (error) {

@ -8,15 +8,270 @@ import { codeId, formatDDMM } from '../utils/formatting';
import { getDb } from '../db/locator';
type AdminContext = {
sender: string; // normalized user id (digits only)
groupId: string; // raw JID (group or DM)
message: string; // raw message text
sender: string;
groupId: string;
message: string;
};
type AdminResponse = { recipient: string; message: string };
export class AdminService {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Shared "must be used in a group" guard. */
function requireIsGroup(groupId: string, sender: string): AdminResponse[] | null {
if (isGroupId(groupId)) return null;
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
/** Parses a group_id argument from rest after one of the given prefixes. */
function parseGroupArg(rest: string, ...prefixes: string[]): string | null {
for (const p of prefixes) {
if (rest.startsWith(p)) return rest.slice(p.length).trim();
}
return null;
}
/** Checks the parsed group arg is valid, returning an error response if not. */
function validateGroupArg(arg: string | null, sender: string): AdminResponse[] | null {
if (!arg) return null;
if (!isGroupId(arg)) return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
return null;
}
/** Sets a group's status and returns a response. */
function setGroupStatusAndRespond(
groupId: string,
status: 'allowed' | 'blocked',
metricKey: string,
label: string,
sender: string,
): AdminResponse[] {
const changed = AllowedGroups.setStatus(groupId, status);
try { if (changed) Metrics.inc(metricKey); } catch {}
return [{ recipient: sender, message: `✅ Grupo ${label}: ${groupId}` }];
}
// ---------------------------------------------------------------------------
// Database operations
// ---------------------------------------------------------------------------
function archiveGroupInDb(db: Database, groupId: string): void {
db.transaction(() => {
db.prepare(`
UPDATE groups SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(groupId);
db.prepare(`
UPDATE calendar_tokens SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(groupId);
db.prepare(`
UPDATE group_members SET is_active = 0 WHERE group_id = ? AND is_active = 1
`).run(groupId);
})();
}
function deleteGroupInDb(db: Database, groupId: string): void {
db.transaction(() => {
db.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(groupId);
db.prepare(`DELETE FROM groups WHERE id = ?`).run(groupId);
try { db.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(groupId); } catch {}
})();
}
// ---------------------------------------------------------------------------
// Command handlers
// ---------------------------------------------------------------------------
function handlePending(sender: string): AdminResponse[] {
const rows = AllowedGroups.listByStatus('pending');
if (!rows || rows.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n');
return [{ recipient: sender, message: `Grupos pendientes (${rows.length}):\n${list}` }];
}
function handleEnableHere(ctx: AdminContext, sender: string): AdminResponse[] {
const err = requireIsGroup(ctx.groupId, sender);
if (err) return err;
return setGroupStatusAndRespond(ctx.groupId, 'allowed', 'admin_actions_total_allow', 'habilitado', sender);
}
function handleDisableHere(ctx: AdminContext, sender: string): AdminResponse[] {
const err = requireIsGroup(ctx.groupId, sender);
if (err) return err;
return setGroupStatusAndRespond(ctx.groupId, 'blocked', 'admin_actions_total_block', 'deshabilitado', sender);
}
function handleArchiveHere(ctx: AdminContext, sender: string, db: Database): AdminResponse[] {
const err = requireIsGroup(ctx.groupId, sender);
if (err) return err;
archiveGroupInDb(db, ctx.groupId);
try { AllowedGroups.setStatus(ctx.groupId, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${ctx.groupId}` }];
}
function handleArchiveGroup(rest: string, sender: string, db: Database): AdminResponse[] {
const arg = parseGroupArg(rest, 'archivar-grupo ', 'archive-group ');
const err = validateGroupArg(arg, sender);
if (err) return err;
archiveGroupInDb(db, arg!);
try { AllowedGroups.setStatus(arg!, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${arg}` }];
}
function handleDeleteHere(ctx: AdminContext, sender: string, db: Database): AdminResponse[] {
const err = requireIsGroup(ctx.groupId, sender);
if (err) return err;
deleteGroupInDb(db, ctx.groupId);
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }];
}
function handleDeleteGroup(rest: string, sender: string, db: Database): AdminResponse[] {
const arg = parseGroupArg(rest, 'borrar-grupo ', 'delete-group ');
const err = validateGroupArg(arg, sender);
if (err) return err;
deleteGroupInDb(db, arg!);
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }];
}
function handleAllowAll(sender: string): AdminResponse[] {
const pendings = AllowedGroups.listByStatus('pending');
if (!pendings || pendings.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
let changed = 0;
for (const r of pendings) {
if (AllowedGroups.setStatus(r.group_id, 'allowed', r.label ?? null)) changed++;
try { Metrics.inc('admin_actions_total_allow'); } catch {}
}
return [{ recipient: sender, message: `✅ Grupos habilitados: ${changed}` }];
}
function handleSetGroupArg(
rest: string,
prefixes: string[],
sender: string,
status: 'allowed' | 'blocked',
metricKey: string,
label: string,
): AdminResponse[] {
const arg = parseGroupArg(rest, ...prefixes);
if (!arg) return [];
const err = validateGroupArg(arg, sender);
if (err) return err;
return setGroupStatusAndRespond(arg, status, metricKey, label, sender);
}
function handleAllowGroup(rest: string, sender: string): AdminResponse[] {
// Must not catch the "allow all" variants
if (rest === 'allow all' || rest === 'allow-all') return [];
return handleSetGroupArg(rest, ['allow-group ', 'allow '], sender, 'allowed', 'admin_actions_total_allow', 'habilitado');
}
function handleBlockGroup(rest: string, sender: string): AdminResponse[] {
return handleSetGroupArg(rest, ['block-group ', 'block '], sender, 'blocked', 'admin_actions_total_block', 'bloqueado');
}
async function handleSyncGroups(sender: string): Promise<AdminResponse[]> {
try {
const r = await GroupSyncService.syncGroups(true);
return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }];
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return [{ recipient: sender, message: `❌ Error al ejecutar sync de grupos: ${msg}` }];
}
}
function handleListAll(rest: string, sender: string): AdminResponse[] {
const DEFAULT_LIMIT = 50;
const maybeNum = parseInt(rest.split(/\s+/).pop() || '', 10);
const limit = (Number.isFinite(maybeNum) && maybeNum > 0) ? Math.min(maybeNum, 500) : DEFAULT_LIMIT;
const tasks = TaskService.listAllActive(limit);
const total = TaskService.countAllActive();
if (!tasks || tasks.length === 0) {
return [{ recipient: sender, message: '✅ No hay tareas activas.' }];
}
const lines = tasks.map(t => {
const ddmm = formatDDMM(t.due_date);
const groupLabel = t.group_name || t.group_id || 'DM';
const parts: string[] = [
`${codeId(t.id, t.display_code)}`,
String(t.description || '').trim(),
];
if (ddmm) parts.push(`vence ${ddmm}`);
if (groupLabel) parts.push(`[${groupLabel}]`);
return `- ${parts.join(' · ')}`;
});
const header = total > limit
? `Tus tareas — Tareas activas (${total}) — mostrando ${tasks.length} primeras:`
: `Tus tareas — Tareas activas (${total}):`;
const footer = ` Para ver tareas sin responsable de un grupo, pide el listado desde ese grupo.`;
try { Metrics.inc('admin_actions_total_list'); } catch {}
return [{ recipient: sender, message: `${header}\n${lines.join('\n')}\n\n${footer}` }];
}
function handleHelp(sender: string): AdminResponse[] {
return [{
recipient: sender,
message: [
'Comandos de administración:',
'- /admin pendientes (alias: pending, pend)',
'- /admin habilitar-aquí (alias: enable)',
'- /admin deshabilitar-aquí (alias: disable)',
'- /admin allow all (alias: habilitar-todos, enable all)',
'- /admin allow-group <group_id@g.us> (alias: allow)',
'- /admin block-group <group_id@g.us> (alias: block)',
'- /admin sync-grupos (alias: group-sync, syncgroups)',
'- /admin ver todos (alias: listar, list all)',
].join('\n'),
}];
}
// ---------------------------------------------------------------------------
// Command router
// ---------------------------------------------------------------------------
/** Exact-match commands. */
const EXACT_COMMANDS: Record<string, AdminResponse[] | ((ctx: AdminContext, sender: string, db: Database) => AdminResponse[] | Promise<AdminResponse[]>)> = {
pendientes: (_, s) => handlePending(s),
pending: (_, s) => handlePending(s),
pend: (_, s) => handlePending(s),
'habilitar-aquí': (c, s) => handleEnableHere(c, s),
'habilitar-aqui': (c, s) => handleEnableHere(c, s),
enable: (c, s) => handleEnableHere(c, s),
'deshabilitar-aquí': (c, s) => handleDisableHere(c, s),
'deshabilitar-aqui': (c, s) => handleDisableHere(c, s),
disable: (c, s) => handleDisableHere(c, s),
'archivar-aquí': (c, s, d) => handleArchiveHere(c, s, d),
'archivar-aqui': (c, s, d) => handleArchiveHere(c, s, d),
'archive here': (c, s, d) => handleArchiveHere(c, s, d),
'archive-aquí': (c, s, d) => handleArchiveHere(c, s, d),
'archive-aqui': (c, s, d) => handleArchiveHere(c, s, d),
'borrar-aquí': (c, s, d) => handleDeleteHere(c, s, d),
'borrar-aqui': (c, s, d) => handleDeleteHere(c, s, d),
'delete here': (c, s, d) => handleDeleteHere(c, s, d),
'delete-here': (c, s, d) => handleDeleteHere(c, s, d),
'allow all': (_, s) => handleAllowAll(s),
'allow-all': (_, s) => handleAllowAll(s),
'habilitar-todos': (_, s) => handleAllowAll(s),
'permitir todos': (_, s) => handleAllowAll(s),
'enable all': (_, s) => handleAllowAll(s),
'sync-grupos': async (_, s) => handleSyncGroups(s),
'group-sync': async (_, s) => handleSyncGroups(s),
syncgroups: async (_, s) => handleSyncGroups(s),
};
export class AdminService {
private static admins(): Set<string> {
const raw = String(process.env.ADMIN_USERS || '');
@ -38,258 +293,62 @@ export class AdminService {
return this.admins().has(n);
}
private static help(): string {
return [
'Comandos de administración:',
'- /admin pendientes (alias: pending, pend)',
'- /admin habilitar-aquí (alias: enable)',
'- /admin deshabilitar-aquí (alias: disable)',
'- /admin allow all (alias: habilitar-todos, enable all)',
'- /admin allow-group <group_id@g.us> (alias: allow)',
'- /admin block-group <group_id@g.us> (alias: block)',
'- /admin sync-grupos (alias: group-sync, syncgroups)',
'- /admin ver todos (alias: listar, list all)',
].join('\n');
private static checkAdminAccess(sender: string): AdminResponse[] | null {
if (!this.isAdmin(sender)) {
return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }];
}
return null;
}
/** Routes an /admin command to the appropriate handler. */
static async handle(ctx: AdminContext): Promise<AdminResponse[]> {
const sender = normalizeWhatsAppId(ctx.sender);
if (!sender) return [];
if (!this.isAdmin(sender)) {
return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }];
}
const accessDenied = this.checkAdminAccess(sender);
if (accessDenied) return accessDenied;
const instanceDb = getDb() as Database;
// Asegurar acceso a la misma DB para AllowedGroups
const raw = String(ctx.message || '').trim().toLowerCase();
if (!raw.startsWith('/admin')) return [];
const raw = String(ctx.message || '').trim();
const lower = raw.toLowerCase();
if (!lower.startsWith('/admin')) {
return [];
}
const rest = raw.slice('/admin'.length).trim();
const db = getDb() as Database;
const rest = lower.slice('/admin'.length).trim();
// /admin pendientes
if (rest === 'pendientes' || rest === 'pending' || rest === 'pend') {
const rows = AllowedGroups.listByStatus('pending');
if (!rows || rows.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n');
return [{
recipient: sender,
message: `Grupos pendientes (${rows.length}):\n${list}`
}];
// 1. Try exact-routed commands first
const exactHandler = EXACT_COMMANDS[rest];
if (typeof exactHandler === 'function') {
return await exactHandler(ctx, sender, db);
}
// /admin habilitar-aquí
if (rest === 'habilitar-aquí' || rest === 'habilitar-aqui' || rest === 'enable') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
const changed = AllowedGroups.setStatus(ctx.groupId, 'allowed');
try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {}
return [{ recipient: sender, message: `✅ Grupo habilitado: ${ctx.groupId}` }];
if (exactHandler) {
return exactHandler;
}
// /admin deshabilitar-aquí
if (rest === 'deshabilitar-aquí' || rest === 'deshabilitar-aqui' || rest === 'disable') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
const changed = AllowedGroups.setStatus(ctx.groupId, 'blocked');
try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {}
return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }];
// 2. Prefix-routed commands (take a group_id argument)
if (rest.startsWith('allow-group ') || rest.startsWith('allow ')) {
const r = handleAllowGroup(rest, sender);
if (r.length > 0) return r;
}
// /admin archivar-aquí
if (rest === 'archivar-aquí' || rest === 'archivar-aqui' || rest === 'archive here' || rest === 'archive-aqui' || rest === 'archive-aquí') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
instanceDb.transaction(() => {
instanceDb.prepare(`
UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(ctx.groupId);
instanceDb.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(ctx.groupId);
instanceDb.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(ctx.groupId);
})();
try { AllowedGroups.setStatus(ctx.groupId, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${ctx.groupId}` }];
if (rest.startsWith('block-group ') || rest.startsWith('block ')) {
return handleBlockGroup(rest, sender);
}
// /admin archivar-grupo <jid>
if (rest.startsWith('archivar-grupo ') || rest.startsWith('archive-group ')) {
const arg = rest.startsWith('archivar-grupo ') ? rest.slice('archivar-grupo '.length).trim() : rest.slice('archive-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
instanceDb.transaction(() => {
instanceDb.prepare(`
UPDATE groups
SET active = 0, archived = 1, last_verified = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(arg);
instanceDb.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(arg);
instanceDb.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(arg);
})();
try { AllowedGroups.setStatus(arg, 'blocked'); } catch {}
return [{ recipient: sender, message: `📦 Grupo archivado: ${arg}` }];
}
// /admin borrar-aquí
if (rest === 'borrar-aquí' || rest === 'borrar-aqui' || rest === 'delete here' || rest === 'delete-here') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
instanceDb.transaction(() => {
instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(ctx.groupId);
instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(ctx.groupId);
try { instanceDb.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(ctx.groupId); } catch {}
})();
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${ctx.groupId}` }];
return handleArchiveGroup(rest, sender, db);
}
// /admin borrar-grupo <jid>
if (rest.startsWith('borrar-grupo ') || rest.startsWith('delete-group ')) {
const arg = rest.startsWith('borrar-grupo ') ? rest.slice('borrar-grupo '.length).trim() : rest.slice('delete-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
instanceDb.transaction(() => {
instanceDb.prepare(`DELETE FROM tasks WHERE group_id = ?`).run(arg);
instanceDb.prepare(`DELETE FROM groups WHERE id = ?`).run(arg);
try { instanceDb.prepare(`DELETE FROM allowed_groups WHERE group_id = ?`).run(arg); } catch {}
})();
return [{ recipient: sender, message: `🗑️ Grupo borrado y datos asociados eliminados: ${arg}` }];
}
// /admin allow all
if (
rest === 'allow all' ||
rest === 'allow-all' ||
rest === 'habilitar-todos' ||
rest === 'permitir todos' ||
rest === 'enable all'
) {
const pendings = AllowedGroups.listByStatus('pending');
if (!pendings || pendings.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
let changed = 0;
for (const r of pendings) {
const didChange = AllowedGroups.setStatus(r.group_id, 'allowed', r.label ?? null);
if (didChange) changed++;
try { Metrics.inc('admin_actions_total_allow'); } catch {}
}
return [{ recipient: sender, message: `✅ Grupos habilitados: ${changed}` }];
}
// /admin allow-group <jid>
if (rest.startsWith('allow-group ') || (rest.startsWith('allow ') && rest !== 'allow all' && rest !== 'allow-all')) {
const arg = (rest.startsWith('allow-group ') ? rest.slice('allow-group '.length) : rest.slice('allow '.length)).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
const changed = AllowedGroups.setStatus(arg, 'allowed');
try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {}
return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }];
}
// /admin block-group <jid>
if (rest.startsWith('block-group ') || rest.startsWith('block ')) {
const arg = (rest.startsWith('block-group ') ? rest.slice('block-group '.length) : rest.slice('block '.length)).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
const changed = AllowedGroups.setStatus(arg, 'blocked');
try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {}
return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }];
}
// /admin sync-grupos
if (rest === 'sync-grupos' || rest === 'group-sync' || rest === 'syncgroups') {
try {
const r = await GroupSyncService.syncGroups(true);
return [{ recipient: sender, message: `✅ Sync de grupos ejecutado: ${r.added} añadidos, ${r.updated} actualizados.` }];
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return [{ recipient: sender, message: `❌ Error al ejecutar sync de grupos: ${msg}` }];
}
return handleDeleteGroup(rest, sender, db);
}
// /admin ver todos [<limite>]
// 3. "ver todos" with optional limit
if (
rest === 'ver todos' ||
rest.startsWith('ver todos ') ||
rest === 'listar' ||
rest.startsWith('listar ') ||
rest === 'list all' ||
rest.startsWith('list all ') ||
rest === 'list-all' ||
rest.startsWith('list-all ')
rest === 'ver todos' || rest.startsWith('ver todos ') ||
rest === 'listar' || rest.startsWith('listar ') ||
rest === 'list all' || rest.startsWith('list all ') ||
rest === 'list-all' || rest.startsWith('list-all ')
) {
// Asegurar acceso a la misma DB para TaskService
const DEFAULT_LIMIT = 50;
let limit = DEFAULT_LIMIT;
const maybeNum = parseInt(rest.split(/\s+/).pop() || '', 10);
if (Number.isFinite(maybeNum) && maybeNum > 0) {
limit = Math.min(maybeNum, 500); // tope razonable
}
const tasks = TaskService.listAllActive(limit);
const total = TaskService.countAllActive();
if (!tasks || tasks.length === 0) {
return [{ recipient: sender, message: '✅ No hay tareas activas.' }];
}
const lines = tasks.map(t => {
const ddmm = formatDDMM(t.due_date);
const groupLabel = t.group_name || t.group_id || 'DM';
const parts: string[] = [
`${codeId(t.id, t.display_code)}`,
String(t.description || '').trim()
];
if (ddmm) parts.push(`vence ${ddmm}`);
if (groupLabel) parts.push(`[${groupLabel}]`);
return `- ${parts.join(' · ')}`;
});
const header = total > limit
? `Tus tareas — Tareas activas (${total}) — mostrando ${tasks.length} primeras:`
: `Tus tareas — Tareas activas (${total}):`;
const footer = ` Para ver tareas sin responsable de un grupo, pide el listado desde ese grupo.`;
try { Metrics.inc('admin_actions_total_list'); } catch {}
return [{
recipient: sender,
message: `${header}\n${lines.join('\n')}\n\n${footer}`
}];
return handleListAll(rest, sender);
}
// Ayuda por defecto
return [{ recipient: sender, message: this.help() }];
// 4. Default → help
return handleHelp(sender);
}
}

@ -39,11 +39,11 @@ export class CommandService {
static async handleWithOutcome(context: CommandContext): Promise<CommandOutcome> {
const msg = (context.message || '').trim();
const instanceDb = getDb() as Database;
if (!/^\/(tarea|t)\b/i.test(msg)) {
if (!/^(tarea|t) /i.test(msg)) {
return { responses: [], ok: true };
}
// Registrar interacción del usuario (last_command_at) para cualquier comando /t …
// Registrar interacción del usuario (last_command_at) para cualquier comando t …
try {
let usersTableExists = false;
try {

@ -1,7 +1,11 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM } from '../../../utils/formatting';
import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared';
import { codeId } from '../../../utils/formatting';
import {
parseMultipleIds, resolveAndValidate,
formatDue, handleBatch
} from '../shared';
import type { BatchOutcome } from '../shared';
type Ctx = {
sender: string;
@ -15,118 +19,101 @@ type Msg = {
mentions?: string[];
};
export async function handleCompletar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
// ---------------------------------------------------------------------------
// Single completion outcome
// ---------------------------------------------------------------------------
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
function completeOne(idInput: number, sender: string): BatchOutcome {
const rv = resolveAndValidate(idInput, sender);
// 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)'
}];
if ('error' in rv) {
const isNotFound = rv.error.includes('no encontrada');
return {
status: isNotFound ? 'notFound' : 'blocked',
line: rv.error,
};
}
// 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}`
}];
}
const { resolvedId } = rv;
const res = TaskService.completeTask(resolvedId, sender);
const due = formatDue(res.task);
const desc = res.task?.description || '(sin descripción)';
const dc = res.task?.display_code;
switch (res.status) {
case 'already':
return {
status: 'already',
line: ` ${codeId(resolvedId, dc)} ya estaba completada — ${desc}${due}`,
};
case 'updated':
return {
status: 'updated',
line: `${ICONS.complete} ${codeId(resolvedId, dc)} completada — ${desc}${due}`,
};
default:
return {
status: 'notFound',
line: `⚠️ ${codeId(resolvedId)} no encontrada.`,
};
}
}
// ---------------------------------------------------------------------------
// Single-ID mode
// ---------------------------------------------------------------------------
function handleSingleComplete(idInput: number, sender: string): Msg[] {
const rv = resolveAndValidate(idInput, sender);
if ('error' in rv) {
return [{ recipient: sender, message: rv.error }];
}
const { resolvedId } = rv;
const res = TaskService.completeTask(resolvedId, sender);
const due = formatDue(res.task);
if (res.status === 'not_found') {
return [{ recipient: sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }];
}
if (res.status === 'already') {
return [{
recipient: context.sender,
message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`
recipient: sender,
message: ` ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}`,
}];
}
// Modo múltiple
let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0;
const lines: string[] = [];
return [{
recipient: sender,
message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}`,
}];
}
if (truncated) {
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleCompletar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
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++;
}
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)',
}];
}
// 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(', ')}.`);
if (ids.length === 1 && ids[0] != null) {
return handleSingleComplete(ids[0], context.sender);
}
return [{
recipient: context.sender,
message: lines.join('\n')
}];
return handleBatch(
context.sender,
ids,
truncated,
completeOne,
{ updated: 'completadas', already: 'ya estaban', notFound: 'no encontradas', blocked: 'bloqueadas' },
'',
) as Msg[];
}

@ -41,7 +41,7 @@ export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] {
if (!m) {
return [{
recipient: context.sender,
message: ' Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
message: ' Uso: `t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}
const hh = Math.max(0, Math.min(23, parseInt(m[1], 10)));
@ -51,7 +51,7 @@ export function handleConfigurar(context: Ctx, deps: { db: Database }): Msg[] {
if (!freq) {
return [{
recipient: context.sender,
message: ' Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`'
message: ' Uso: `t configurar diario|l-v|semanal|off [HH:MM]`'
}];
}

@ -28,233 +28,291 @@ type Msg = {
mentions?: string[];
};
// ---------------------------------------------------------------------------
// Config & helpers
// ---------------------------------------------------------------------------
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
// 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 { }
function getFallbackDigitLimits(): { min: number; max: number } {
const min = parseInt((process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '8').trim(), 10);
const max = parseInt((process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '15').trim(), 10);
return {
min: Number.isFinite(min) && min > 0 ? min : 8,
max: Number.isFinite(max) && max > 0 ? max : 15,
};
}
// 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
]));
function isDigits(s: string): boolean { return /^\d+$/.test(s); }
const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext);
function checkPlausibility(s: string, limits: { min: number; max: number }, fromLid: boolean): { ok: boolean; reason?: FailReason } {
if (!s) return { ok: false, reason: 'invalid' };
if (fromLid) return { ok: false, reason: 'from_lid' };
if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
if (s.length < limits.min) return { ok: false, reason: 'too_short' };
if (s.length >= limits.max) return { ok: false, reason: 'too_long' };
return { ok: true };
}
// Asegurar creador
const createdBy = ensureUserExists(context.sender, deps.db);
if (!createdBy) {
throw new Error('No se pudo asegurar el usuario creador');
}
function recordOnboardingFailure(groupId: string, source: 'mentions' | 'tokens', reason: FailReason): void {
try {
const gid = isGroupId(groupId) ? groupId : 'dm';
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
} catch {}
}
// 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);
// ---------------------------------------------------------------------------
// Mention processing
// ---------------------------------------------------------------------------
// 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];
interface MentionResult {
ids: string[];
unresolved: string[];
}
/** Extracts a display string from a raw JID (strips @domain, @, +). */
function displayFromJid(raw: string): string {
return raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
}
function processContextMentions(mentions: string[], limits: { min: number; max: number }, groupId: string): MentionResult {
const ids: string[] = [];
const unresolved: string[] = [];
for (const j of new Set(mentions)) {
const norm = normalizeWhatsAppId(j);
if (!norm) {
const disp = displayFromJid(j);
if (disp) unresolved.push(disp);
recordOnboardingFailure(groupId, 'mentions', 'invalid');
continue;
}
const resolved = IdentityService.resolveAliasOrNull(norm);
if (resolved) { ids.push(resolved); continue; }
const dom = String(j).split('@')[1]?.toLowerCase() || '';
const fromLid = dom.includes('lid');
const p = checkPlausibility(norm, limits, fromLid);
if (p.ok) { ids.push(norm); continue; }
unresolved.push(norm);
recordOnboardingFailure(groupId, 'mentions', p.reason!);
}
// Definir group_id solo si el grupo está activo
const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
? context.groupId
: null;
return { ids, unresolved };
}
// 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,
}))
);
function processAtTokens(tokens: string[], limits: { min: number; max: number }, groupId: string): MentionResult {
const candidates = tokens
.filter(t => t.startsWith('@'))
.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
// 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);
}
const ids: string[] = [];
const unresolved: string[] = [];
for (const v of new Set(candidates)) {
// '@yo' → self-assignment marker, not an actual user
if (String(v).toLowerCase() === 'yo') continue;
const norm = normalizeWhatsAppId(v);
if (!norm) {
if (v) unresolved.push(v);
recordOnboardingFailure(groupId, 'tokens', 'invalid');
continue;
}
} catch { }
// Recuperar la tarea creada para obtener display_code asignado
const createdTask = TaskService.getTaskById(taskId);
const resolved = IdentityService.resolveAliasOrNull(norm);
if (resolved) { ids.push(resolved); continue; }
const p = checkPlausibility(norm, limits, false);
if (p.ok) { ids.push(norm); continue; }
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
unresolved.push(v);
recordOnboardingFailure(groupId, 'tokens', p.reason!);
}
// Resolver nombres útiles
const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;
return { ids, unresolved };
}
const assignedDisplayNames = await Promise.all(
assignmentUserIds.map(async uid => {
const name = await ContactsService.getDisplayName(uid);
return name || uid;
})
);
// ---------------------------------------------------------------------------
// Assignment resolution
// ---------------------------------------------------------------------------
const responses: Msg[] = [];
function resolveFinalAssignees(
candidates: string[],
selfAssign: boolean,
sender: string,
groupId: string,
db: Database,
): { ensured: string[]; userIds: string[] } {
const botNumber = process.env.CHATBOT_PHONE_NUMBER || '';
const source = Array.from(new Set([
...(selfAssign ? [sender] : []),
...candidates,
].filter(id => !botNumber || id !== botNumber)));
// 1) Ack al creador con formato compacto
const ensured = source
.map(id => ensureUserExists(id, db))
.filter((id): id is string => !!id);
// Default: in groups → no assignment; in DMs → assign to creator
const userIds = ensured.length > 0
? ensured
: (isGroupId(groupId) ? [] : [sender]);
return { ensured, userIds };
}
// ---------------------------------------------------------------------------
// Response building
// ---------------------------------------------------------------------------
function buildAcknowledgement(
taskId: number,
displayCode: number | null,
description: string,
dueDate: string | null,
assignmentUserIds: string[],
assignedDisplayNames: string[],
groupName: string | null,
createdBy: string,
): Msg {
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)'}`,
const lines = [
`${ICONS.create} ${codeId(taskId, displayCode)} ${description || '(sin descripción)'}`,
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
ownerPart
ownerPart,
].filter(Boolean);
responses.push({
return {
recipient: createdBy,
message: [ackLines.join('\n'), '', CTA_HELP].join('\n'),
...(mentionsForSending.length > 0 ? { mentions: mentionsForSending } : {})
});
message: [lines.join('\n'), '', CTA_HELP].join('\n'),
...(assignmentUserIds.length > 0 ? { mentions: assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`) } : {}),
};
}
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
function buildAssigneeNotices(
taskId: number,
displayCode: number | null,
description: string,
dueDate: string | null,
assignmentUserIds: string[],
createdBy: string,
groupName: string | null,
): Msg[] {
const notices: Msg[] = [];
for (const uid of assignmentUserIds) {
if (uid === createdBy) continue;
responses.push({
notices.push({
recipient: uid,
message: [
`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
`${description || '(sin descripción)'}`,
`${ICONS.assignNotice} ${codeId(taskId, displayCode)}`,
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}\``
`- Completar: \`t x ${displayCode}\``,
`- Soltar: \`t soltar ${displayCode}\``,
].filter(Boolean).join('\n') + '\n\n' + CTA_HELP,
mentions: [`${createdBy}@s.whatsapp.net`]
mentions: [`${createdBy}@s.whatsapp.net`],
});
}
return notices;
}
function recordTaskOrigin(db: Database, taskId: number, groupId: string, ctx: Ctx): void {
if (!isGroupId(groupId) || !ctx.messageId) return;
try {
const participant = typeof ctx.participant === 'string' ? ctx.participant : null;
const fromMe = typeof ctx.fromMe === 'boolean' ? (ctx.fromMe ? 1 : 0) : null;
try {
db.prepare(`
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
VALUES (?, ?, ?, ?, ?)
`).run(taskId, groupId, ctx.messageId, participant, fromMe);
} catch {
db.prepare(`
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
VALUES (?, ?, ?)
`).run(taskId, groupId, ctx.messageId);
}
} catch {}
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const limits = getFallbackDigitLimits();
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays));
// 1. Process mentions from context and @tokens from text
const mentionResult = processContextMentions(context.mentions || [], limits, context.groupId);
const tokenResult = processAtTokens(tokens.slice(2), limits, context.groupId);
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
const combinedCandidates = Array.from(new Set([
...mentionResult.ids,
...tokenResult.ids,
]));
const unresolvedDisplays = Array.from(new Set([
...mentionResult.unresolved,
...tokenResult.unresolved,
]));
// 2. Parse command
const { description, dueDate, selfAssign } = parseNueva(
(context.message || '').trim(),
mentionResult.ids,
);
// 3. Ensure creator
const createdBy = ensureUserExists(context.sender, deps.db);
if (!createdBy) throw new Error('No se pudo asegurar el usuario creador');
// 4. Resolve assignees
const { ensured: ensuredAssignees, userIds: assignmentUserIds } = resolveFinalAssignees(
combinedCandidates, selfAssign, context.sender, context.groupId, deps.db,
);
// 5. Determine group
const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
? context.groupId : null;
// 6. Create task
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 })),
);
// 7. Record origin
if (groupIdToUse) recordTaskOrigin(deps.db, taskId, groupIdToUse, context);
// 8. Fetch created task & display names
const createdTask = TaskService.getTaskById(taskId);
const groupName = groupIdToUse ? (GroupSyncService.activeGroupsCache.get(groupIdToUse) ?? null) : null;
const displayNames = await Promise.all(
assignmentUserIds.map(uid => ContactsService.getDisplayName(uid).then(n => n || uid)),
);
// 9. Build responses
const responses: Msg[] = [];
responses.push(buildAcknowledgement(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, displayNames, groupName, createdBy));
responses.push(...buildAssigneeNotices(taskId, createdTask?.display_code ?? null, description || '', dueDate, assignmentUserIds, createdBy, groupName));
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedDisplays));
// 10. Onboarding bundle
try {
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
maybeEnqueueOnboardingBundle(deps.db, {
gid,
createdBy,
assignmentUserIds,
taskId,
gid, createdBy, assignmentUserIds, taskId,
displayCode: createdTask?.display_code ?? null,
description: description || ''
description: description || '',
});
} catch {}

@ -1,7 +1,7 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM, italic } from '../../../utils/formatting';
import { resolveTaskIdFromInput, enforceMembership } from '../shared';
import { resolveAndValidate, formatDue } from '../shared';
type Ctx = {
sender: string;
@ -15,90 +15,68 @@ type Msg = {
mentions?: string[];
};
export async function handleSoltar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
// ---------------------------------------------------------------------------
// Message builder
// ---------------------------------------------------------------------------
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`'
}];
}
function unassignMessage(
res: ReturnType<typeof TaskService.unassignTask>,
resolvedId: number,
): string {
const label = codeId(resolvedId, res.task?.display_code);
const desc = res.task?.description || '(sin descripción)';
const due = formatDue(res.task);
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(idInput)} no encontrada.`
}];
}
switch (res.status) {
case 'forbidden_personal':
return '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla';
const task = TaskService.getTaskById(resolvedId);
if (!task) {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
}
case 'not_found':
return `⚠️ 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.'
}];
}
case 'completed':
return ` ${label} ya estaba completada — ${desc}${due}`;
const res = TaskService.unassignTask(resolvedId, context.sender);
const due = res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '';
case 'not_assigned':
return ` ${label} no la tenías asignada — ${desc}${due}`;
if (res.status === 'forbidden_personal') {
return [{
recipient: context.sender,
message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla'
}];
}
case 'unassigned': {
if (res.now_unassigned) {
return [
`${ICONS.unassigned} ${label}`,
desc,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task!.due_date)}` : '',
italic('queda sin responsable.'),
].filter(Boolean).join('\n');
}
return [
`${ICONS.unassign} ${label}`,
desc,
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task!.due_date)}` : '',
].filter(Boolean).join('\n');
}
if (res.status === 'not_found') {
return [{
recipient: context.sender,
message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.`
}];
default:
return '⚠️ Estado inesperado al soltar la tarea.';
}
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}`
}];
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export async function handleSoltar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const idInput = parseInt(tokens[2], 10);
if (!idInput || Number.isNaN(idInput)) {
return [{ recipient: context.sender, message: ' Uso: `t soltar 26`' }];
}
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 rv = resolveAndValidate(idInput, context.sender);
if ('error' in rv) {
return [{ recipient: context.sender, message: rv.error }];
}
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')
}];
const res = TaskService.unassignTask(rv.resolvedId, context.sender);
return [{ recipient: context.sender, message: unassignMessage(res, rv.resolvedId) }];
}

@ -1,7 +1,11 @@
import { TaskService } from '../../../tasks/service';
import { ICONS } from '../../../utils/icons';
import { codeId, formatDDMM, italic } from '../../../utils/formatting';
import { parseMultipleIds, resolveTaskIdFromInput, enforceMembership } from '../shared';
import {
parseMultipleIds, resolveAndValidate,
formatDue, handleBatch
} from '../shared';
import type { BatchOutcome } from '../shared';
type Ctx = {
sender: string;
@ -15,134 +19,117 @@ type Msg = {
mentions?: string[];
};
export async function handleTomar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
// ---------------------------------------------------------------------------
// Single claim outcome
// ---------------------------------------------------------------------------
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
function claimOne(idInput: number, sender: string): BatchOutcome {
const rv = resolveAndValidate(idInput, sender);
// 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)'
}];
if ('error' in rv) {
const isNotFound = rv.error.includes('no encontrada');
return {
status: isNotFound ? 'notFound' : 'blocked',
line: rv.error,
};
}
// 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);
const { resolvedId } = rv;
const res = TaskService.claimTask(resolvedId, sender);
const due = formatDue(res.task);
const desc = res.task?.description || '(sin descripción)';
const dc = res.task?.display_code;
switch (res.status) {
case 'claimed':
return {
status: 'claimed',
line: `${ICONS.take} ${codeId(resolvedId, dc)} tomada — ${desc}${due}`,
};
case 'already':
return {
status: 'already',
line: ` ${codeId(resolvedId, dc)} ya la tenías — ${desc}${due}`,
};
case 'completed':
return {
status: 'completed',
line: ` ${codeId(resolvedId, dc)} ya estaba completada — ${desc}${due}`,
};
default:
return {
status: 'notFound',
line: `⚠️ ${codeId(resolvedId)} no encontrada.`,
};
}
}
// ---------------------------------------------------------------------------
// Single-ID mode
// ---------------------------------------------------------------------------
function handleSingleClaim(idInput: number, sender: string): Msg[] {
const rv = resolveAndValidate(idInput, sender);
if ('error' in rv) {
return [{ recipient: sender, message: rv.error }];
}
const { resolvedId } = rv;
const res = TaskService.claimTask(resolvedId, sender);
const due = formatDue(res.task);
const dc = res.task?.display_code;
if (res.status === 'not_found') {
return [{ recipient: sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }];
}
if (res.status === 'completed') {
return [{
recipient: context.sender,
message: lines.join('\n')
recipient: sender,
message: ` ${codeId(resolvedId, dc)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`,
}];
}
if (res.status === 'already') {
return [{
recipient: sender,
message: ` ${codeId(resolvedId, dc)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`,
}];
}
// Modo múltiple
let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0;
const lines: string[] = [];
// Success
const lines = [
italic(`${ICONS.take} Has tomado ${codeId(resolvedId, dc)}`),
res.task?.description || '(sin descripción)',
res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '',
].filter(Boolean);
if (truncated) {
lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
}
return [{ recipient: sender, message: lines.join('\n') }];
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleTomar(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10);
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++;
}
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)',
}];
}
// 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(', ')}.`);
if (ids.length === 1 && ids[0] != null) {
return handleSingleClaim(ids[0], context.sender);
}
return [{
recipient: context.sender,
message: lines.join('\n')
}];
return handleBatch(
context.sender,
ids,
truncated,
claimOne,
{ claimed: 'tomadas', already: 'ya las tenías', completed: 'ya completadas', notFound: 'no encontradas', blocked: 'bloqueadas' },
'',
) as Msg[];
}

@ -2,8 +2,8 @@ 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';
import { codeId, bold, italic } from '../../../utils/formatting';
import { SCOPE_ALIASES, todayYMD, formatTaskLine } from '../shared';
type Ctx = {
sender: string;
@ -17,124 +17,17 @@ type Msg = {
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)}` : '';
const dc = (t as any)?.display_code as number | undefined;
return `- ${codeId(t.id, dc)} ${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._'));
}
const LIMIT = 10;
// 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)}` : '';
const dc = (t as any)?.display_code as number | undefined;
return `- ${codeId(t.id, dc)} ${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')
}];
}
// ---------------------------------------------------------------------------
// Shared: group tasks by group_id and render
// ---------------------------------------------------------------------------
// 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>();
async function groupAndRenderTasks(
items: any[],
todayYMD: string,
): Promise<string[]> {
const byGroup = new Map<string, any[]>();
for (const t of items) {
const key = t.group_id || '(sin grupo)';
const arr = byGroup.get(key) || [];
@ -142,7 +35,7 @@ export async function handleVer(context: Ctx): Promise<Msg[]> {
byGroup.set(key, arr);
}
const sections: string[] = [bold('Tus tareas')];
const sections: string[] = [];
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
@ -151,32 +44,144 @@ export async function handleVer(context: Ctx): Promise<Msg[]> {
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)
(t.assignees || []).map(async (uid: string) => (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)}` : '';
const dc = (t as any)?.display_code as number | undefined;
return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart}${owner}`;
return formatTaskLine(t, owner, todayYMD);
}));
sections.push(...rendered);
sections.push('');
}
// Quitar línea en blanco final si procede
// Remove trailing blank line
if (sections.length > 0 && sections[sections.length - 1] === '') {
sections.pop();
}
return sections;
}
// ---------------------------------------------------------------------------
// Build user's own tasks section (used by both "mis" and "todos")
// ---------------------------------------------------------------------------
async function buildUserTasksSection(sender: string, todayYMD: string): Promise<string[]> {
const sections: string[] = [bold('Tus tareas')];
const items = TaskService.listUserPending(sender, LIMIT);
if (items.length === 0) {
sections.push(italic('_No tienes tareas pendientes._'));
return sections;
}
sections.push(...await groupAndRenderTasks(items, todayYMD));
const total = TaskService.countUserPending(sender);
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
return sections;
}
// ---------------------------------------------------------------------------
// Unassigned tasks from membership (used by "todos")
// ---------------------------------------------------------------------------
return [{
recipient: context.sender,
message: sections.join('\n')
}];
async function buildUnassignedSections(sender: string, todayYMD: string): Promise<string[]> {
const sections: string[] = [];
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(sender);
if (memberGroups.length === 0) {
sections.push(' Para ver tareas sin responsable, escribe por privado `t todas` o usa `t web`.');
return sections;
}
const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT);
for (const gid of perGroup.keys()) {
const unassigned = perGroup.get(gid)!;
if (unassigned.length === 0) continue;
const groupName = GroupSyncService.activeGroupsCache.get(gid) || gid;
if (sections.length && sections[sections.length - 1] !== '') sections.push('');
sections.push(`${groupName} — Sin responsable`);
const rendered = unassigned.map(t =>
formatTaskLine(t, `${ICONS.unassigned}`, todayYMD),
);
sections.push(...rendered);
const total = TaskService.countGroupUnassigned(gid);
if (total > unassigned.length) {
sections.push(`… y ${total - unassigned.length} más`);
}
}
return sections;
}
// ---------------------------------------------------------------------------
// Scope: "todos" — user tasks + unassigned from member groups
// ---------------------------------------------------------------------------
async function handleVerTodos(sender: string): Promise<Msg[]> {
const today = todayYMD();
const sections = await buildUserTasksSection(sender, today);
// Add unassigned section
const unassignedSections = await buildUnassignedSections(sender, today);
sections.push(...unassignedSections);
return [{ recipient: sender, message: sections.join('\n') }];
}
// ---------------------------------------------------------------------------
// Scope: "mis" — user's own tasks only
// ---------------------------------------------------------------------------
async function handleVerMis(sender: string): Promise<Msg[]> {
const today = todayYMD();
const items = TaskService.listUserPending(sender, LIMIT);
if (items.length === 0) {
return [{ recipient: sender, message: italic('No tienes tareas pendientes.') }];
}
const sections: string[] = [bold('Tus tareas')];
sections.push(...await groupAndRenderTasks(items, today));
const total = TaskService.countUserPending(sender);
if (total > items.length) {
sections.push(`… y ${total - items.length} más`);
}
return [{ recipient: sender, message: sections.join('\n') }];
}
// ---------------------------------------------------------------------------
// Scope resolution
// ---------------------------------------------------------------------------
function resolveScope(tokens: string[]): string {
const rawAction = (tokens[1] || '').toLowerCase();
const scopeRaw = (tokens[2] || '').toLowerCase();
if (scopeRaw) return SCOPE_ALIASES[scopeRaw] || scopeRaw;
// Guess scope from action word
if (rawAction === 'mias' || rawAction === 'mías') return 'mis';
return 'todos';
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleVer(context: Ctx): Promise<Msg[]> {
const tokens = (context.message || '').trim().split(/\s+/);
const scope = resolveScope(tokens);
if (scope === 'todos') {
return handleVerTodos(context.sender);
}
return handleVerMis(context.sender);
}

@ -22,7 +22,7 @@ export async function handleWeb(context: Ctx, deps: { db: Database }): Promise<M
if (isGroupId(context.groupId)) {
return [{
recipient: context.sender,
message: ' Este comando se usa por privado. Envíame `/t web` por DM.'
message: ' Este comando se usa por privado. Envíame `t web` por DM.'
}];
}
@ -66,6 +66,6 @@ export async function handleWeb(context: Ctx, deps: { db: Database }): Promise<M
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".`
message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "t web".`
}];
}

@ -26,13 +26,13 @@ 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`'
'- 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');
}
@ -46,21 +46,21 @@ function getFullHelp(): string {
' · Tomar: `tomar`, `claim`',
' · Soltar: `soltar`, `unassign`',
'Preferencias:',
' · `/t configurar diario|l-v|semanal|off [HH:MM]`',
' · `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`',
' · `t web`',
'Atajos:',
' · `/t mias`',
' · `/t todas`'
' · `t mias`',
' · `t todas`'
].join('\n');
}
function buildUnknownHelp(): string {
const header = '❓ COMANDO NO RECONOCIDO';
const cta = 'Prueba `/t info`';
const cta = 'Prueba `t info`';
return [header, cta, '', getQuickHelp()].join('\n');
}
@ -80,94 +80,82 @@ export type RouteContext = {
fromMe?: boolean;
};
// ---------------------------------------------------------------------------
// Route helpers
// ---------------------------------------------------------------------------
function trackOnboarding(): void {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
}
function trackAlias(metric: string): void {
try { Metrics.inc('commands_alias_used_total', 1, { action: metric }); } catch {}
}
function trackUknown(): void {
try { Metrics.inc('commands_unknown_total'); } catch {}
}
// ---------------------------------------------------------------------------
// Route dispatcher
// ---------------------------------------------------------------------------
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)
// --- 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 {}
if (rawAction === 'info' || rawAction === '?') trackAlias('info');
trackOnboarding();
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
const message = isAdvanced
? getFullHelp()
: [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n');
return [{
recipient: context.sender,
message
}];
: [getQuickHelp(), '', 'Ayuda avanzada: `t ayuda avanzada`'].join('\n');
return [{ recipient: context.sender, message }];
}
// Requiere db inyectada para poder operar (CommandService la inyecta)
// --- resto de comandos requieren DB ---
const database = deps?.db;
if (!database) return null;
// --- nueva ---
if (action === 'nueva') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
trackOnboarding();
return await handleNueva(context as unknown as NuevaCtx, { db: database });
}
// --- ver ---
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 {}
if (rawAction === 'mias' || rawAction === 'mías') trackAlias('mias');
else if (rawAction === 'todas' || rawAction === 'todos') trackAlias('todas');
trackOnboarding();
// 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'
message: 'No respondo en grupos. Tus tareas: t mias · Todas: t todas · Info: t info · Web: t web',
}];
}
return await handleVer(context as unknown as VerCtx);
}
if (action === 'completar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleCompletar(context as unknown as CompletarCtx);
}
// --- completar / tomar / soltar ---
if (action === 'completar') { trackOnboarding(); return await handleCompletar(context as unknown as CompletarCtx); }
if (action === 'tomar') { trackOnboarding(); return await handleTomar(context as unknown as TomarCtx); }
if (action === 'soltar') { trackOnboarding(); return await handleSoltar(context as unknown as SoltarCtx); }
if (action === 'tomar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleTomar(context as unknown as TomarCtx);
}
// --- configurar / web ---
if (action === 'configurar') { trackOnboarding(); return handleConfigurar(context as unknown as ConfigurarCtx, { db: database }); }
if (action === 'web') { trackOnboarding(); return await handleWeb(context as unknown as WebCtx, { db: database }); }
if (action === 'soltar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleSoltar(context as unknown as SoltarCtx);
}
if (action === 'configurar') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return handleConfigurar(context as unknown as ConfigurarCtx, { db: database });
}
if (action === 'web') {
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
return await handleWeb(context as unknown as WebCtx, { db: database });
}
// Desconocido → ayuda rápida
try { Metrics.inc('commands_unknown_total'); } catch {}
return [{
recipient: context.sender,
message: buildUnknownHelp()
}];
// --- desconocido ---
trackUknown();
return [{ recipient: context.sender, message: buildUnknownHelp() }];
}

@ -5,6 +5,8 @@
import { TaskService } from '../../tasks/service';
import { GroupSyncService } from '../group-sync';
import { ICONS } from '../../utils/icons';
import { codeId, formatDDMM } from '../../utils/formatting';
export const ACTION_ALIASES: Record<string, string> = {
'n': 'nueva',
@ -51,12 +53,12 @@ export const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
'yo': 'mis'
};
export const CTA_HELP = ' Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`';
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 {
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,
@ -123,3 +125,89 @@ export function enforceMembership(sender: string, task: { group_id?: string | nu
return GroupSyncService.isUserActiveInGroup(sender, gid);
}
/** Formatea el sufijo de fecha de vencimiento para una respuesta de tarea. */
export function formatDue(task: { due_date?: string | null } | null | undefined): string {
return task?.due_date ? `${ICONS.date} ${formatDDMM(task.due_date)}` : '';
}
/** Construye el texto de resumen para procesamiento por lotes. */
export function buildSummary(counts: Record<string, number>, labels: Record<string, string>): string {
const parts: string[] = [];
for (const key of Object.keys(counts)) {
if (counts[key]) parts.push(`${labels[key]} ${counts[key]}`);
}
return parts.length ? `Resumen: ${parts.join(', ')}.` : '';
}
/** Construye el fragmento " — ⚠️ 📅 DD/MM" o vacío para una tarea según si está vencida. */
export function formatDatePart(due_date: string | null | undefined, todayYMD: string): string {
if (!due_date) return '';
const overdue = due_date < todayYMD;
return `${overdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(due_date)}`;
}
/** Renderiza una línea de tarea con su código, descripción, fecha y dueño. */
export function formatTaskLine(
t: { id: number; description?: string | null; due_date?: string | null; display_code?: number | null },
owner: string,
todayYMD: string
): string {
const dc = (t as any).display_code as number | undefined;
const datePart = formatDatePart(t.due_date, todayYMD);
return `- ${codeId(t.id, dc)} ${t.description || '(sin descripción)'}${datePart}${owner}`;
}
/** Outcome of a single action in a multi-ID batch. */
export interface BatchOutcome {
status: string;
line: string;
}
/**
* Generic multi-ID batch handler.
*
* Iterates over IDs, calls `action` for each, collects outcomes,
* counts statuses, builds a summary and returns a single Msg.
*/
export function handleBatch(
sender: string,
ids: number[],
truncated: boolean,
action: (idInput: number, sender: string) => BatchOutcome,
statusLabels: Record<string, string>,
usageMessage: string,
): { recipient: string; message: string }[] {
if (ids.length === 0) {
return [{ recipient: sender, message: usageMessage }];
}
const lines: string[] = [];
if (truncated) lines.push('⚠️ Se procesarán solo los primeros 10 IDs.');
const counts: Record<string, number> = {};
for (const idInput of ids) {
const outcome = action(idInput, sender);
lines.push(outcome.line);
counts[outcome.status] = (counts[outcome.status] || 0) + 1;
}
const summary = buildSummary(counts, statusLabels);
if (summary) { lines.push(''); lines.push(summary); }
return [{ recipient: sender, message: lines.join('\n') }];
}
/** Resuelve un ID de entrada, carga la tarea y aplica membresía. Retorna error o {resolvedId, task}. */
export function resolveAndValidate(
idInput: number,
sender: string
): { resolvedId: number; task: any } | { error: string } {
const resolvedId = resolveTaskIdFromInput(idInput);
if (!resolvedId) return { error: `⚠️ Tarea ${codeId(idInput)} no encontrada.` };
const task = TaskService.getTaskById(resolvedId);
if (!task) return { error: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` };
if (!enforceMembership(sender, task)) return { error: `🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).` };
return { resolvedId, task };
}

@ -1,4 +1,4 @@
import { normalizeWhatsAppId, isUserJid } from '../utils/whatsapp';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { IdentityService } from './identity';
type CacheEntry = {
@ -6,8 +6,76 @@ type CacheEntry = {
expiresAt: number;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ID_FIELDS = ['id', 'jid', 'user', 'remoteJid', 'wid'] as const;
function extractIdCandidate(rec: any): string | null {
for (const field of ID_FIELDS) {
const val = rec?.[field];
if (typeof val === 'string' && val) return val;
}
// wid._serialized (nested)
const widSerialized = rec?.wid?._serialized;
if (typeof widSerialized === 'string' && widSerialized) return widSerialized;
return null;
}
function tryLearnAlias(rec: any): void {
try {
const rawId = typeof rec?.id === 'string' ? rec.id : null;
const rawJid = typeof rec?.jid === 'string' ? rec.jid : null;
if (rawId && rawJid && rawId.includes('@lid') && rawJid.endsWith('@s.whatsapp.net')) {
IdentityService.upsertAlias(rawId, rawJid, 'contacts.update');
if (process.env.NODE_ENV !== 'test') {
console.log('[A0] contacts.update learned alias', { alias: rawId, jid: rawJid });
}
}
} catch { /* best-effort */ }
}
function extractRecordArrays(data: any): any[][] {
if (Array.isArray(data)) return [data];
if (!data || typeof data !== 'object') return [];
const arrays: any[][] = [];
const knownKeys = ['contacts', 'chats', 'data', 'payload', 'updates', 'results'];
for (const key of knownKeys) {
if (Array.isArray(data[key])) arrays.push(data[key]);
}
// Also try wrapping the single object
arrays.push([data]);
return arrays;
}
/** Processes a single contact/channel record and caches its display name. */
function processRecord(rec: any, cache: Map<string, CacheEntry>, ttlMs: number): void {
const idCandidate = extractIdCandidate(rec);
if (!idCandidate) return;
tryLearnAlias(rec);
// Skip groups
if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) return;
const normalized = normalizeWhatsAppId(String(idCandidate));
if (!normalized) return;
const name = ContactsService.extractName(rec);
if (!name) return;
cache.set(normalized, { name, expiresAt: Date.now() + ttlMs });
}
// ---------------------------------------------------------------------------
// ContactsService
// ---------------------------------------------------------------------------
export class ContactsService {
// Caché en memoria: userId(normalizado, solo dígitos) -> nombre
private static readonly cache = new Map<string, CacheEntry>();
private static readonly TTL_MS = 12 * 60 * 60 * 1000; // 12h
@ -18,23 +86,12 @@ export class ContactsService {
};
}
private static now() {
return Date.now();
}
private static extractName(obj: any): string | null {
static extractName(obj: any): string | null {
if (!obj || typeof obj !== 'object') return null;
// Intentar múltiples campos posibles que suelen aparecer en catálogos/contactos
const candidates = [
obj.name,
obj.pushname,
obj.verifiedName,
obj.notify,
obj.shortName,
obj.displayName,
obj.formattedName,
obj.subject, // a veces para grupos; pero evitaremos grupos abajo
obj.contactName
obj.name, obj.pushname, obj.verifiedName, obj.notify,
obj.shortName, obj.displayName, obj.formattedName, obj.subject,
obj.contactName,
].filter(Boolean) as string[];
const name = candidates.find(v => typeof v === 'string' && v.trim().length > 0);
return name ? name.trim() : null;
@ -42,47 +99,10 @@ export class ContactsService {
static updateFromWebhook(data: any): void {
try {
// Aceptar varios formatos posibles
const tryArrays: any[] = [];
if (Array.isArray(data)) {
tryArrays.push(data);
} else if (data && typeof data === 'object') {
for (const key of ['contacts', 'chats', 'data', 'payload', 'updates', 'results']) {
if (Array.isArray((data as any)[key])) {
tryArrays.push((data as any)[key]);
}
}
// Algunos eventos pueden traer una sola entrada
tryArrays.push([data]);
}
for (const arr of tryArrays) {
const arrays = extractRecordArrays(data);
for (const arr of arrays) {
for (const rec of arr) {
const idCandidate = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized;
if (!idCandidate) continue;
// Aprender mapping alias→número si vienen ambos (id con @lid y jid de usuario)
try {
const rawId = typeof rec?.id === 'string' ? rec.id : null;
const rawJid = typeof rec?.jid === 'string' ? rec.jid : null;
if (rawId && rawJid && rawId.includes('@lid') && rawJid.endsWith('@s.whatsapp.net')) {
IdentityService.upsertAlias(rawId, rawJid, 'contacts.update');
if (process.env.NODE_ENV !== 'test') {
console.log('[A0] contacts.update learned alias', { alias: rawId, jid: rawJid });
}
}
} catch {}
// Evitar grupos
if (typeof idCandidate === 'string' && idCandidate.endsWith('@g.us')) continue;
const normalized = normalizeWhatsAppId(String(idCandidate));
if (!normalized) continue;
const name = this.extractName(rec);
if (!name) continue;
this.cache.set(normalized, { name, expiresAt: this.now() + this.TTL_MS });
processRecord(rec, this.cache, this.TTL_MS);
}
}
} catch (e) {
@ -100,9 +120,7 @@ export class ContactsService {
body: JSON.stringify(body),
});
if (!res.ok) {
return null;
}
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const arrayCandidates: any[] = Array.isArray(data)
@ -118,13 +136,13 @@ export class ContactsService {
: [];
for (const rec of arrayCandidates) {
const recId = rec?.id ?? rec?.jid ?? rec?.user ?? rec?.remoteJid ?? rec?.wid ?? rec?.wid?._serialized;
const recId = extractIdCandidate(rec);
if (!recId) continue;
const norm = normalizeWhatsAppId(String(recId));
if (!norm || norm !== digitsId) continue;
const name = this.extractName(rec);
if (name) {
this.cache.set(digitsId, { name, expiresAt: this.now() + this.TTL_MS });
this.cache.set(digitsId, { name, expiresAt: Date.now() + this.TTL_MS });
return name;
}
}
@ -135,7 +153,6 @@ export class ContactsService {
}
private static async maybeFetchFromApi(digitsId: string): Promise<string | null> {
// Evitar llamadas de red en entorno de test
if (process.env.NODE_ENV === 'test') return null;
const baseUrl = process.env.EVOLUTION_API_URL;
@ -147,7 +164,6 @@ export class ContactsService {
const jid = `${digitsId}@s.whatsapp.net`;
const body = { where: { id: jid } };
// Probar múltiples rutas conocidas según versión de Evolution
const candidates = [
`${baseUrl}/chat/findContacts/${instance}`,
`${baseUrl}/contact/findContacts/${instance}`,
@ -168,11 +184,8 @@ export class ContactsService {
if (!normalized) return null;
const cached = this.cache.get(normalized);
if (cached && cached.expiresAt > this.now()) {
return cached.name;
}
if (cached && cached.expiresAt > Date.now()) return cached.name;
// Intento de fetch perezoso
const fetched = await this.maybeFetchFromApi(normalized);
return fetched || null;
}

File diff suppressed because it is too large Load Diff

@ -1,6 +1,54 @@
import { normalizeWhatsAppId } from '../../utils/whatsapp';
import { IdentityService } from '../identity';
/**
* Parse a participants array (strings or objects) from Evolution API into
* normalized {userId, isAdmin} entries, including identity resolution.
*/
function parseParticipants(participants: any[]): Array<{ userId: string; isAdmin: boolean }> {
const result: Array<{ userId: string; isAdmin: boolean }> = [];
for (const p of participants) {
let jid: string | null = null;
let isAdmin = false;
if (typeof p === 'string') {
jid = p;
} else if (p && typeof p === 'object') {
const rawId = p.id || p?.user?.id || p.user || null;
const rawJid = p.jid || null;
jid = rawJid || rawId || null;
if (rawId && rawJid) {
try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
}
if (typeof p.isAdmin === 'boolean') {
isAdmin = p.isAdmin;
} else if (typeof p.admin === 'string') {
isAdmin = p.admin === 'admin' || p.admin === 'superadmin';
} else if (typeof p.role === 'string') {
isAdmin = p.role.toLowerCase().includes('admin');
}
}
let norm = normalizeWhatsAppId(jid);
if (!norm) {
const digits = (jid || '').replace(/\D+/g, '');
norm = digits || null;
}
if (!norm) continue;
result.push({ userId: norm, isAdmin });
}
// Resolve identities
try {
const map = IdentityService.resolveMany(result.map(r => r.userId));
return result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
} catch {
return result;
}
}
export type ApiEvolutionGroup = {
id: string;
subject: string;
@ -120,47 +168,7 @@ export async function fetchGroupMembersFromAPI(groupId: string): Promise<Array<{
const participantsArr = Array.isArray(parsed1?.participants) ? parsed1.participants : null;
if (participantsArr) {
const result: Array<{ userId: string; isAdmin: boolean }> = [];
for (const p of participantsArr) {
let jid: string | null = null;
let isAdmin = false;
if (typeof p === 'string') {
jid = p;
} else if (p && typeof p === 'object') {
const rawId = p.id || p?.user?.id || p.user || null;
const rawJid = p.jid || null;
jid = rawJid || rawId || null;
if (rawId && rawJid) {
try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
}
if (typeof p.isAdmin === 'boolean') {
isAdmin = p.isAdmin;
} else if (typeof p.admin === 'string') {
isAdmin = p.admin === 'admin' || p.admin === 'superadmin';
} else if (typeof p.role === 'string') {
isAdmin = p.role.toLowerCase().includes('admin');
}
}
let norm = normalizeWhatsAppId(jid);
if (!norm) {
const digits = (jid || '').replace(/\D+/g, '');
norm = digits || null;
}
if (!norm) continue;
result.push({ userId: norm, isAdmin });
}
let resolved: Array<{ userId: string; isAdmin: boolean }>;
try {
const map = IdentityService.resolveMany(result.map(r => r.userId));
resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
} catch {
resolved = result;
}
return resolved;
return parseParticipants(participantsArr);
}
console.warn('⚠️ /group/participants responded without participants array, falling back to fetchAllGroups');
} else {
@ -219,46 +227,5 @@ export async function fetchGroupMembersFromAPI(groupId: string): Promise<Array<{
return [];
}
const participants = Array.isArray(g.participants) ? g.participants : [];
const result: Array<{ userId: string; isAdmin: boolean }> = [];
for (const p of participants) {
let jid: string | null = null;
let isAdmin = false;
if (typeof p === 'string') {
jid = p;
} else if (p && typeof p === 'object') {
const rawId = p.id || p?.user?.id || p.user || null;
const rawJid = p.jid || null;
jid = rawJid || rawId || null;
if (rawId && rawJid) {
try { IdentityService.upsertAlias(String(rawId), String(rawJid), 'group.participants'); } catch {}
}
if (typeof p.isAdmin === 'boolean') {
isAdmin = p.isAdmin;
} else if (typeof p.admin === 'string') {
isAdmin = p.admin === 'admin' || p.admin === 'superadmin';
} else if (typeof p.role === 'string') {
isAdmin = p.role.toLowerCase().includes('admin');
}
}
let norm = normalizeWhatsAppId(jid);
if (!norm) {
const digits = (jid || '').replace(/\D+/g, '');
norm = digits || null;
}
if (!norm) continue;
result.push({ userId: norm, isAdmin });
}
let resolved: Array<{ userId: string; isAdmin: boolean }>;
try {
const map = IdentityService.resolveMany(result.map(r => r.userId));
resolved = result.map(r => ({ userId: map.get(r.userId) || r.userId, isAdmin: r.isAdmin }));
} catch {
resolved = result;
}
return resolved;
return parseParticipants(participants);
}

@ -0,0 +1,60 @@
import type { Database } from 'bun:sqlite';
/** Row shape used by the change detector (subset of groups columns). */
export interface GroupRow {
id: string;
active: number;
archived: number;
is_community: number;
name?: string | null;
}
export interface GroupChanges {
newlyActivated: string[];
newlyDeactivated: Array<{ id: string; name: string | null }>;
}
/**
* Compare before/after snapshots of the groups table and detect:
* - Groups that became active (new or reactivated, excluding communities/archived)
* - Groups that went from active inactive (excluding communities/archived)
*/
export function detectGroupChanges(
before: GroupRow[],
after: GroupRow[]
): GroupChanges {
const beforeMap = new Map<string, GroupRow>();
for (const r of before) beforeMap.set(String(r.id), r);
const afterMap = new Map<string, GroupRow>();
for (const r of after) afterMap.set(String(r.id), r);
const newlyActivated: string[] = [];
for (const [id, a] of afterMap) {
const b = beforeMap.get(id);
const active =
Number(a.active) === 1 &&
Number(a.archived) === 0 &&
Number(a.is_community) === 0;
if (active && (!b || Number(b.active) !== 1)) {
newlyActivated.push(id);
}
}
const newlyDeactivated: Array<{ id: string; name: string | null }> = [];
for (const [id, b] of beforeMap) {
const a = afterMap.get(id);
if (!a) continue;
if (
Number(b.active) === 1 &&
Number(a.active) === 0 &&
Number(a.archived) === 0 &&
Number(a.is_community) === 0 &&
Number(b.is_community) === 0
) {
newlyDeactivated.push({ id, name: a.name ?? b.name ?? null });
}
}
return { newlyActivated, newlyDeactivated };
}

@ -0,0 +1,85 @@
import type { Database } from 'bun:sqlite';
import { normalizeWhatsAppId } from '../../utils/whatsapp';
import { ResponseQueue } from '../response-queue';
import { Metrics } from '../metrics';
/**
* Revoke calendar tokens and deactivate memberships for groups that have
* been deactivated (no longer present in the API snapshot).
*/
export function revokeTokensAndDeactivateMembers(
db: Database,
deactivatedIds: string[],
txn: (fn: () => void) => void = fn => fn()
): void {
if (deactivatedIds.length === 0) return;
txn(() => {
for (const groupId of deactivatedIds) {
db.prepare(`
UPDATE calendar_tokens
SET revoked_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE group_id = ? AND revoked_at IS NULL
`).run(groupId);
db.prepare(`
UPDATE group_members
SET is_active = 0
WHERE group_id = ? AND is_active = 1
`).run(groupId);
}
});
}
/**
* Queue admin notifications about deactivated groups.
* Skips in test mode.
*/
export async function notifyAdminsAboutDeactivated(
deactivated: Array<{ id: string; name: string | null }>
): Promise<void> {
if (deactivated.length === 0) return;
if (String(process.env.NODE_ENV || '').toLowerCase() === 'test') return;
const adminSet = new Set<string>();
const rawAdmins = String(process.env.ADMIN_USERS || '');
for (const token of rawAdmins.split(',').map(s => s.trim()).filter(Boolean)) {
const n = normalizeWhatsAppId(token);
if (n) adminSet.add(n);
}
const admins = Array.from(adminSet);
if (admins.length === 0) return;
const messages: Array<{ recipient: string; message: string }> = [];
for (const g of deactivated) {
const label = g.name ? `${g.name} (${g.id})` : g.id;
const msg =
`⚠️ El grupo ${label} parece haber dejado de existir o no está disponible.\n\n` +
`Acciones disponibles:\n- Archivar (recomendado): /admin archivar-grupo ${g.id}\n` +
`- Borrar definitivamente: /admin borrar-grupo ${g.id}`;
for (const admin of admins) {
messages.push({ recipient: admin, message: msg });
}
}
if (messages.length > 0) {
try {
await ResponseQueue.add(messages as any);
Metrics.inc('admin_deactivation_notifications', messages.length);
} catch (e) {
console.warn('No se pudo encolar notificación a admins:', e);
}
}
}
/**
* Full deactivation pipeline: revoke tokens, deactivate members, notify admins.
*/
export async function handleDeactivatedGroups(
db: Database,
deactivated: Array<{ id: string; name: string | null }>
): Promise<void> {
if (deactivated.length === 0) return;
const ids = deactivated.map(g => g.id);
revokeTokensAndDeactivateMembers(db, ids, fn => db.transaction(fn)());
await notifyAdminsAboutDeactivated(deactivated);
}

@ -0,0 +1,75 @@
import type { Database } from 'bun:sqlite';
// ---------------------------------------------------------------------------
// Snapshot freshness
// ---------------------------------------------------------------------------
function maxSnapshotAgeMs(): number {
const raw = Number(process.env.MAX_MEMBERS_SNAPSHOT_AGE_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 24 * 60 * 60 * 1000; // 24h default
}
export function isSnapshotFresh(
db: Database,
groupId: string,
nowMs: number = Date.now()
): boolean {
try {
const row = db
.prepare(`SELECT last_verified FROM groups WHERE id = ?`)
.get(groupId) as { last_verified?: string | null } | undefined;
const lv = row?.last_verified ? String(row.last_verified) : null;
if (!lv) return false;
const iso = lv.includes('T') ? lv : lv.replace(' ', 'T') + 'Z';
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return false;
return nowMs - ms <= maxSnapshotAgeMs();
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Membership queries
// ---------------------------------------------------------------------------
export function isUserActiveInGroup(
db: Database,
userId: string,
groupId: string
): boolean {
if (!userId || !groupId) return false;
const row = db
.prepare(
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
)
.get(groupId, userId);
return !!row;
}
export function getActiveGroupIdsForUser(
db: Database,
userId: string
): string[] {
if (!userId) return [];
const rows = db
.prepare(
`SELECT gm.group_id AS id
FROM group_members gm
JOIN groups g ON g.id = gm.group_id
WHERE gm.user_id = ? AND gm.is_active = 1 AND g.active = 1
AND COALESCE(g.is_community,0) = 0
AND COALESCE(g.archived,0) = 0`
)
.all(userId) as Array<{ id: string }>;
return [...new Set(rows.map(r => String(r.id)))];
}
export function getFreshMemberGroupsForUser(
db: Database,
userId: string
): string[] {
return getActiveGroupIdsForUser(db, userId).filter(gid =>
isSnapshotFresh(db, gid)
);
}

@ -0,0 +1,79 @@
/**
* Resolve a schedule interval in milliseconds.
*
* Priority:
* 1. env var if set and valid
* 2. fallbackMs default
*
* In development mode, enforces a minimum of 10s to avoid accidental API spam.
*/
export function resolveInterval(
envVar: string,
fallbackMs: number
): number {
const raw = Number(process.env[envVar]);
let interval = Number.isFinite(raw) && raw > 0 ? raw : fallbackMs;
if (process.env.NODE_ENV === 'development' && interval < 10_000) {
console.warn(
`Sync interval from ${envVar} too low (${interval}ms), using 10s minimum`
);
interval = 10_000;
}
return interval;
}
// ---------------------------------------------------------------------------
// Scheduler state holders (mutable, per-scheduler)
// ---------------------------------------------------------------------------
export interface SchedulerState {
running: boolean;
timer: ReturnType<typeof setInterval> | null;
intervalMs: number | null;
nextTickAt: number | null;
}
export function createSchedulerState(): SchedulerState {
return { running: false, timer: null, intervalMs: null, nextTickAt: null };
}
export function startScheduler(
state: SchedulerState,
intervalMs: number,
task: () => Promise<void>,
label: string
): void {
if (process.env.NODE_ENV === 'test') return;
if (state.running) return;
state.running = true;
state.intervalMs = intervalMs;
state.nextTickAt = Date.now() + intervalMs;
state.timer = setInterval(() => {
state.nextTickAt = Date.now() + (state.intervalMs ?? intervalMs);
task().catch(err =>
console.error(`${label} scheduler run error:`, err)
);
}, intervalMs);
}
export function stopScheduler(state: SchedulerState): void {
state.running = false;
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
state.intervalMs = null;
state.nextTickAt = null;
}
export function secondsUntilNextTick(
state: SchedulerState,
nowMs: number = Date.now()
): number | null {
const next = state.nextTickAt;
if (next == null) return null;
const secs = (next - nowMs) / 1000;
return secs > 0 ? secs : 0;
}

@ -1,100 +0,0 @@
/**
* Centralización de contenidos de ayuda (Help v2)
* Nota: Solo copy; no depende de flags ni del runtime. Integración en command.ts llega en Fase 4.
*/
import { section, bullets, code, italic } from '../../utils/formatting';
export function getQuickHelp(baseUrl?: string): string {
const parts: 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')}`,
])
);
parts.push(
italic('El bot responde por privado, incluso si escribes desde un grupo.')
);
return parts.join('\n');
}
export function getFullHelp(baseUrl?: string): string {
const out: string[] = [];
// Crear
out.push(section('Crear'));
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 grupo: sin menciones → queda “sin responsable”.',
'Fechas: usa la última válida encontrada; no acepta pasadas.',
])
);
// Listados
out.push('');
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.',
'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
'Fechas en DD/MM y ⚠️ si están vencidas.',
])
);
// Fechas
out.push('');
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',
])
);
// Recordatorios
out.push('');
out.push(section('Recordatorios'));
out.push(
bullets([
`${code('/t configurar diario|l-v|semanal|off [HH:MM]')}`,
'Alias: diario/diaria, laborables (l-v/lv), semanal, off/apagar.',
'Si omites hora, se conserva la anterior o se usa 08:30 por defecto (semanal asume lunes).',
])
);
// Acceso web
out.push('');
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).`,
])
);
// Otros
out.push('');
out.push(section('Otros'));
out.push(
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.',
])
);
return out.join('\n');
}

@ -12,41 +12,123 @@ type CommandResponse = {
mentions?: string[];
};
/**
* Construye (si aplica) el DM JIT al creador cuando hay menciones/tokens irrecuperables.
* Aplica flags y métricas exactamente como en CommandService.
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isTest = () => process.env.NODE_ENV === 'test';
const isEnvTrue = (key: string) => ['true','1','yes','on'].includes(String(process.env[key] || '').toLowerCase());
const envNum = (key: string, fallback: number): number => {
const v = Number(process.env[key]);
return Number.isFinite(v) && v > 0 ? v : fallback;
};
function skipMetric(reason: string, gid: string): void {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason, group_id: String(gid || '') }); } catch {}
}
function sentMetric(source: string, gid: string, extra?: Record<string, any>): void {
try { Metrics.inc('onboarding_bundle_sent_total', 1, { ...extra, group_id: String(gid) }); } catch {}
}
function promoSkipMetric(reason: string, groupId: string): void {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason }); } catch {}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function isPromptsEnabled(): boolean {
if (isTest()) return 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());
}
function checkCoverageBelowThreshold(ratio: number, groupId: string): boolean {
const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD);
const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1;
if (!(ratio < threshold)) {
promoSkipMetric('coverage_100', groupId);
return false;
}
return true;
}
function checkCoverageGracePeriod(db: Database, groupId: string, nowMs: number): boolean {
const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS);
const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90;
const row = db.prepare('SELECT last_verified FROM groups WHERE id = ?').get(groupId) as any;
const lv = row?.last_verified ? String(row.last_verified) : null;
if (!lv) return true;
const ms = parseIsoMs(lv);
if (!Number.isFinite(ms)) return true;
if (Math.floor((nowMs - ms) / 1000) < graceSec) {
promoSkipMetric('grace_period', groupId);
return false;
}
return true;
}
function checkCoverageCooldown(db: Database, groupId: string, nowMs: number): boolean {
const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS);
const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7;
const row = db.prepare('SELECT onboarding_prompted_at FROM groups WHERE id = ?').get(groupId) as any;
const promptedAt = row?.onboarding_prompted_at ? String(row.onboarding_prompted_at) : null;
if (!promptedAt) return true;
const ms = parseIsoMs(promptedAt);
if (!Number.isFinite(ms)) return true;
if ((nowMs - ms) < cdDays * 24 * 60 * 60 * 1000) {
promoSkipMetric('cooldown_active', groupId);
return false;
}
return true;
}
function validateBotNumber(groupId: string): string | null {
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) {
promoSkipMetric('missing_bot_number', groupId);
return null;
}
return bot;
}
function enqueueCoveragePrompt(db: Database, groupId: string, bot: string): void {
const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`;
db.transaction(() => {
db.prepare(`
INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)
VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(groupId, msg);
db.prepare(`UPDATE groups SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(groupId);
})();
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {}
}
// ---------------------------------------------------------------------------
// JIT assignee prompt (unchanged except for minor cleanup)
// ---------------------------------------------------------------------------
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;
}
if (!isPromptsEnabled()) { promoSkipMetric('disabled', groupLabel); 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;
}
if (!bot || !/^\d+$/.test(bot)) { promoSkipMetric('missing_bot_number', groupLabel); return responses; }
const list = unresolvedList.join(', ');
let groupCtx = '';
if (groupId && groupId.includes('@g.us')) {
const name = groupId;
groupCtx = ` (en el grupo ${name})`;
}
const groupCtx = (groupId && groupId.includes('@g.us')) ? ` (en el grupo ${groupId})` : '';
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 { }
@ -54,250 +136,227 @@ export function buildJitAssigneePrompt(createdBy: string, groupId: string, unres
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: {
// ---------------------------------------------------------------------------
// Onboarding bundle — extracted pieces
// ---------------------------------------------------------------------------
interface OnboardingBundleParams {
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') {
allowed = AllowedGroups.isAllowed(gid);
}
} catch {}
if (!allowed) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
return;
}
/** Returns false (and records a skip metric) when onboarding should not proceed. */
function checkOnboardingEnabled(params: OnboardingBundleParams): boolean {
const baseEnabled = isEnvTrue('ONBOARDING_DM_ENABLED');
const enabled = baseEnabled && (!isTest() || isEnvTrue('ONBOARDING_ENABLE_IN_TEST'));
if (!enabled) { skipMetric('disabled', String(params.gid || '')); return false; }
if (!params.gid) { skipMetric('no_group', ''); return false; }
return true;
}
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;
}
function checkGroupGating(gid: string): boolean {
if (String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() !== 'enforce') return true;
try { return AllowedGroups.isAllowed(gid); } catch { return false; }
}
function checkDisplayCode(params: OnboardingBundleParams): boolean {
if (typeof params.displayCode === 'number' && Number.isFinite(params.displayCode)) return true;
skipMetric('no_display_code', String(params.gid || ''));
return false;
}
// Candidatos
/** Fetches active group members, excluding the creator, assignees, and the bot. */
function fetchEligibleMembers(db: Database, gid: string, params: OnboardingBundleParams): string[] {
let members: string[] = [];
try {
const rows = db.prepare(`SELECT user_id FROM group_members WHERE group_id = ? AND is_active = 1`).all(gid) as Array<{ user_id: string }>;
for (const r of rows) {
const uid = String(r.user_id || '').trim();
if (/^\d+$/.test(uid) && uid.length < 14) members.push(uid);
}
const rows = db.prepare('SELECT user_id FROM group_members WHERE group_id = ? AND is_active = 1').all(gid) as Array<{ user_id: string }>;
members = rows.map(r => String(r.user_id || '').trim()).filter(id => /^\d+$/.test(id) && id.length < 14);
} catch {}
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;
}
return members.filter(id => !exclude.has(id) && (!bot || id !== bot));
}
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);
}
function applyRecipientCap(recipients: string[], gid: string): string[] {
const cap = Math.floor(envNum('ONBOARDING_EVENT_CAP', 30));
if (recipients.length <= cap) return recipients;
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
try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {}
return recipients.slice(0, cap);
}
let groupLabel = gid;
function resolveGroupLabel(db: Database, gid: string): string {
try {
const row = db.prepare(`SELECT name FROM groups WHERE id = ?`).get(gid) as any;
const row = db.prepare('SELECT name FROM groups WHERE id = ?').get(gid) as any;
const name = row?.name ? String(row.name).trim() : '';
if (name) groupLabel = name;
if (name) return name;
} catch {}
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}`);
return gid;
}
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)
function buildOnboardingMessage1(taskId: number, displayCode: string, description: string, groupLabel: string): string {
const shortDesc = description.length > 100 ? (description.slice(0, 100) + '…') : description;
return `¡Hola!, soy el bot de tareas. En ${bold(`'${groupLabel}'`)} acaban de crear una tarea: ${codeId(taskId, Number(displayCode))} _${shortDesc}_
- Para asignártela, escríbeme ${code(`t tomar ${padTaskId(Number(displayCode))}`)} por privado
- Más info: ${code('t info')} (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*
function buildOnboardingMessage2(): string {
return `*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')}`;
- 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 {}
}
}
// ---------------------------------------------------------------------------
// Cooldown & variant logic
// ---------------------------------------------------------------------------
if (!variant) continue;
type OnboardingVariant = 'initial' | 'reminder';
function cooldownDays(): number {
const raw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS);
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 14;
}
function bundleDelayMs(): number {
const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS);
return Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001);
}
function parseIsoMs(s: string): number {
const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z');
return Date.parse(iso);
}
function userHadInteractionSince(db: Database, rcpt: string, firstMs: number): boolean {
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) return false;
const lcMs = parseIsoMs(lcRaw);
return Number.isFinite(lcMs) && lcMs > firstMs;
} catch { return false; }
}
const bundleId = randomTokenBase64Url(12);
/**
* Determines the onboarding variant for a recipient.
* Returns null when the recipient should be skipped.
*/
function determineOnboardingVariant(
db: Database,
rcpt: string,
cooldownDays: number,
gid: string,
): OnboardingVariant | null {
const stats = ResponseQueue.getOnboardingStats(rcpt);
// Never received onboarding → initial
if (!stats || (stats.total || 0) === 0) return 'initial';
// No firstInitialAt → can't determine cooldown
if (!stats.firstInitialAt) return null;
const firstMs = parseIsoMs(String(stats.firstInitialAt));
if (!Number.isFinite(firstMs)) return null;
const cooldownMs = cooldownDays * 24 * 60 * 60 * 1000;
const okCooldown = (Date.now() - firstMs) >= cooldownMs;
const hadInteraction = userHadInteractionSince(db, rcpt, firstMs);
if (okCooldown && !hadInteraction) return 'reminder';
skipMetric(hadInteraction ? 'had_interaction' : 'cooldown_active', gid);
return null;
}
// ---------------------------------------------------------------------------
// Bundle enqueue
// ---------------------------------------------------------------------------
function enqueueBundleForRecipient(
rcpt: string,
msg1: string,
msg2: string,
variant: OnboardingVariant,
params: OnboardingBundleParams,
delay2: number,
): void {
const bundleId = randomTokenBase64Url(12);
const gid = String(params.gid || '');
const meta = { variant, group_id: gid, task_id: params.taskId, display_code: params.displayCode };
ResponseQueue.enqueueOnboarding(rcpt, msg1, { ...meta, part: 1, bundle_id: bundleId }, 0);
ResponseQueue.enqueueOnboarding(rcpt, msg2, { ...meta, part: 2, bundle_id: bundleId }, delay2);
sentMetric(params.gid || '', gid, { variant });
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function maybeEnqueueOnboardingBundle(db: Database, params: OnboardingBundleParams): void {
// 1. Guard clauses
if (!checkOnboardingEnabled(params)) return;
const gid = params.gid!;
if (!checkGroupGating(gid)) { skipMetric('not_allowed', gid); return; }
if (!checkDisplayCode(params)) return;
// 2. Fetch & filter recipients
const members = fetchEligibleMembers(db, gid, params);
if (members.length === 0) { skipMetric('no_members', gid); return; }
const recipients = applyRecipientCap(members, gid);
// 3. Build messages (once)
const groupLabel = resolveGroupLabel(db, gid);
const displayCodeStr = String(params.displayCode!);
const desc = (params.description || '(sin descripción)').trim();
const msg1 = buildOnboardingMessage1(params.taskId, displayCodeStr, desc, groupLabel);
const msg2 = buildOnboardingMessage2();
// 4. Cooldown & delay config
const cd = cooldownDays();
const delay2 = bundleDelayMs();
// 5. Enqueue for each eligible recipient
for (const rcpt of recipients) {
const variant = determineOnboardingVariant(db, rcpt, cd, gid);
if (!variant) continue;
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 {}
enqueueBundleForRecipient(rcpt, msg1, msg2, variant, params, delay2);
} catch { /* best effort */ }
}
}
// ---------------------------------------------------------------------------
// Group coverage prompt (unchanged)
// ---------------------------------------------------------------------------
export function publishGroupCoveragePrompt(db: Database, groupId: string, ratio: number): void {
try {
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());
})();
if (!enabled) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {}
return;
}
// Umbral de cobertura: publicar solo si ratio < threshold (por defecto 1.0)
const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD);
const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1;
if (!(ratio < threshold)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'coverage_100' }); } catch {}
return;
}
// Gating enforce
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
if (!AllowedGroups.isAllowed(groupId)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {}
return;
}
}
} catch {}
// Grace y cooldown
const rowG = db.prepare(`SELECT last_verified, onboarding_prompted_at FROM groups WHERE id = ?`).get(groupId) as any;
if (!isPromptsEnabled()) { promoSkipMetric('disabled', groupId); return; }
if (!checkCoverageBelowThreshold(ratio, groupId)) return;
if (!checkGroupGating(groupId)) { promoSkipMetric('not_allowed', groupId); return; }
const nowMs = Date.now();
const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS);
const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90;
const lv = rowG?.last_verified ? String(rowG.last_verified) : null;
if (lv) {
const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
const ageSec = Math.floor((nowMs - ms) / 1000);
if (ageSec < graceSec) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {}
return;
}
}
}
const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS);
const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7;
const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null;
if (promptedAt) {
const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z');
const ms = Date.parse(iso);
if (Number.isFinite(ms)) {
const diffMs = nowMs - ms;
if (diffMs < cdDays * 24 * 60 * 60 * 1000) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {}
return;
}
}
}
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
if (!bot || !/^\d+$/.test(bot)) {
try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {}
return;
}
const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`;
db.transaction(() => {
db.prepare(`
INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at)
VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(groupId, msg);
db.prepare(`
UPDATE groups
SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now')
WHERE id = ?
`).run(groupId);
})();
try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {}
if (!checkCoverageGracePeriod(db, groupId, nowMs)) return;
if (!checkCoverageCooldown(db, groupId, nowMs)) return;
const bot = validateBotNumber(groupId);
if (!bot) return;
enqueueCoveragePrompt(db, groupId, bot);
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
}
if (process.env.NODE_ENV !== 'test') console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e);
}
}

@ -5,6 +5,7 @@ import { ContactsService } from './contacts';
import { GroupSyncService } from './group-sync';
import { ICONS } from '../utils/icons';
import { codeId, formatDDMM, bold, italic } from '../utils/formatting';
import { formatTaskLine, formatDatePart } from './commands/shared';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { getDb } from '../db/locator';
@ -12,12 +13,207 @@ import { getDb } from '../db/locator';
type UserPreference = {
user_id: string;
reminder_freq: 'daily' | 'weekly' | 'weekdays' | 'off';
reminder_time: string; // 'HH:MM'
last_reminded_on: string | null; // 'YYYY-MM-DD'
reminder_time: string;
last_reminded_on: string | null;
};
export class RemindersService {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseHmMinutes(hm: string): number {
const [h, m] = String(hm).split(':');
return (parseInt(h || '0', 10) || 0) * 60 + (parseInt(m || '0', 10) || 0);
}
function graceMinutes(): number {
const raw = Number(process.env.REMINDERS_GRACE_MINUTES);
return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), 180) : 60;
}
function enforceGatingEnabled(): boolean {
return String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce';
}
function filterTasksByGating(tasks: any[], enforce: boolean): any[] {
if (!enforce) return tasks;
return tasks.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id));
}
// ---------------------------------------------------------------------------
// Preference evaluation
// ---------------------------------------------------------------------------
type SkipReason = 'already_reminded' | 'before_time' | 'wrong_day' | 'outside_window';
/**
* Evaluates whether a reminder should be sent for this preference+time.
* Returns null if we should proceed, or a SkipReason if we should not.
*/
function evaluatePreference(
pref: UserPreference,
todayYMD: string,
nowMin: number,
weekday: string,
): { skip: SkipReason } | null {
// Already reminded today
if (pref.last_reminded_on === todayYMD) return { skip: 'already_reminded' };
if (!pref.reminder_time) return { skip: 'already_reminded' };
const cfgMin = parseHmMinutes(pref.reminder_time);
// Before scheduled time
if (nowMin < cfgMin) return { skip: 'before_time' };
const grace = graceMinutes();
// Outside grace window → skip with metric
if (nowMin > cfgMin + grace) return { skip: 'outside_window' };
// Weekdays: Mon-Fri only
if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) {
return { skip: 'wrong_day' };
}
// Weekly: Monday only
if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') {
return { skip: 'wrong_day' };
}
return null;
}
// ---------------------------------------------------------------------------
// Message building
// ---------------------------------------------------------------------------
async function buildTaskSections(
items: any[],
todayYMD: string,
): Promise<string[]> {
const byGroup = new Map<string, any[]>();
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[] = [];
for (const [groupId, arr] of byGroup.entries()) {
const groupName =
(groupId && GroupSyncService.activeGroupsCache.get(groupId)) ||
(groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo');
sections.push(bold(groupName));
const rendered = await Promise.all(arr.map(async (t) => {
const names = await Promise.all(
(t.assignees || []).map(async (uid: string) => (await ContactsService.getDisplayName(uid)) || uid),
);
const owner =
(t.assignees?.length || 0) === 0
? `${ICONS.unassigned} sin responsable`
: `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`;
return formatTaskLine(t, owner, todayYMD);
}));
sections.push(...rendered);
}
return sections;
}
async function buildUnassignedSections(
userId: string,
enforce: boolean,
todayYMD: string,
): Promise<string[]> {
const sections: string[] = [];
let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(userId);
if (enforce) memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid));
for (const gid of memberGroups) {
const unassigned = TaskService.listGroupUnassigned(gid, 10);
if (unassigned.length === 0) continue;
const groupName = GroupSyncService.activeGroupsCache.get(gid) || gid;
sections.push(bold(`${groupName} — Sin responsable`));
const rendered = unassigned.map(t =>
formatTaskLine(t, `${ICONS.unassigned} sin responsable`, todayYMD),
);
sections.push(...rendered);
const total = TaskService.countGroupUnassigned(gid);
if (total > unassigned.length) {
sections.push(italic(`… y ${total - unassigned.length} más`));
}
}
return sections;
}
async function buildReminderMessage(
pref: UserPreference,
items: any[],
total: number,
todayYMD: string,
): Promise<string> {
const sections: string[] = [];
// Header
sections.push(
pref.reminder_freq === 'weekly'
? `${ICONS.reminder} RECORDATORIO SEMANAL — TUS TAREAS`
: `${ICONS.reminder} RECORDATORIO DIARIO — TUS TAREAS`,
);
// User's tasks grouped by group
sections.push(...await buildTaskSections(items, todayYMD));
// Overflow note
if (total > items.length) {
sections.push(italic(`… y ${total - items.length} más`));
}
// Optional: unassigned tasks from user's groups
const includeUnassigned = String(
process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '',
).toLowerCase() === 'true';
if (includeUnassigned) {
const enforce = enforceGatingEnabled();
sections.push(...await buildUnassignedSections(pref.user_id, enforce, todayYMD));
}
return sections.join('\n');
}
// ---------------------------------------------------------------------------
// Database
// ---------------------------------------------------------------------------
function fetchPreferences(db: Database): UserPreference[] {
return db.prepare(`
SELECT user_id, reminder_freq, reminder_time, last_reminded_on
FROM user_preferences
WHERE reminder_freq IN ('daily', 'weekly', 'weekdays')
`).all() as UserPreference[];
}
function markReminded(db: Database, userId: string, todayYMD: string): void {
db.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'),
COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'),
?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(user_id) DO UPDATE SET
last_reminded_on = excluded.last_reminded_on,
updated_at = excluded.updated_at
`).run(userId, userId, userId, todayYMD);
}
// ---------------------------------------------------------------------------
// RemindersService
// ---------------------------------------------------------------------------
export class RemindersService {
private static _running = false;
private static _timer: any = null;
@ -29,9 +225,7 @@ export class RemindersService {
private static ymdInTZ(d: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: this.TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
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')}`;
@ -40,9 +234,7 @@ export class RemindersService {
private static hmInTZ(d: Date): string {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: this.TZ,
hour: '2-digit',
minute: '2-digit',
hour12: false,
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
return `${get('hour')}:${get('minute')}`;
@ -52,194 +244,87 @@ export class RemindersService {
return new Intl.DateTimeFormat('en-GB', {
timeZone: this.TZ,
weekday: 'short',
}).format(d); // e.g., 'Mon', 'Tue', ...
}).format(d);
}
static start() {
if (process.env.NODE_ENV === 'test') return;
if (this._running) return;
static start(): void {
if (process.env.NODE_ENV === 'test' || this._running) return;
this._running = true;
// Arranca un tick cada minuto
this._timer = setInterval(() => {
this.runOnce().catch(err => {
console.error('RemindersService runOnce error:', err);
});
this.runOnce().catch(err => console.error('RemindersService runOnce error:', err));
}, 60_000);
// Primer tick diferido para no bloquear el arranque
setTimeout(() => this.runOnce().catch(() => {}), 5_000);
}
static stop() {
static stop(): void {
this._running = false;
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
if (this._timer) { clearInterval(this._timer); this._timer = null; }
}
static async runOnce(now: Date = new Date()): Promise<void> {
const instanceDb = getDb() as Database;
const db = getDb() as Database;
const todayYMD = this.ymdInTZ(now);
const nowHM = this.hmInTZ(now);
const weekday = this.weekdayShortInTZ(now); // 'Mon'..'Sun'
const graceRaw = Number(process.env.REMINDERS_GRACE_MINUTES);
const GRACE_MIN = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.min(Math.floor(graceRaw), 180) : 60;
const rows = instanceDb.prepare(`
SELECT user_id, reminder_freq, reminder_time, last_reminded_on
FROM user_preferences
WHERE reminder_freq IN ('daily', 'weekly', 'weekdays')
`).all() as UserPreference[];
// Determinar si aplicar gating por grupos
const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce';
const weekday = this.weekdayShortInTZ(now);
const enforce = enforceGatingEnabled();
if (enforce) {
try {
// Evitar falsos positivos por caché obsoleta entre operaciones previas del test
AllowedGroups.clearCache?.();
} catch {}
try { AllowedGroups.clearCache?.(); } catch {}
}
for (const pref of rows) {
// Evitar duplicado el mismo día
if (pref.last_reminded_on === todayYMD) continue;
// Verificar hora alcanzada y ventana de gracia
if (!pref.reminder_time) continue;
const [nowH, nowM] = String(nowHM).split(':');
const [cfgH, cfgM] = String(pref.reminder_time).split(':');
const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0);
const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0);
const prefs = fetchPreferences(db);
// Antes de la hora programada
if (nowMin < cfgMin) continue;
for (const pref of prefs) {
try {
await processOnePreference(db, pref, todayYMD, nowHM, weekday, enforce);
} catch (e) {
console.error('RemindersService: error al procesar usuario', pref.user_id, e);
}
}
}
}
// Sólo incrementar métrica si es un día válido para el usuario
// ---------------------------------------------------------------------------
// Per-preference processing (extracted from the loop)
// ---------------------------------------------------------------------------
async function processOnePreference(
db: Database,
pref: UserPreference,
todayYMD: string,
nowHM: string,
weekday: string,
enforce: boolean,
): Promise<void> {
const nowMin = parseHmMinutes(nowHM);
const evalResult = evaluatePreference(pref, todayYMD, nowMin, weekday);
if (evalResult) {
// Track metric for outside-window skips on valid weekdays
if (evalResult.skip === 'outside_window') {
const isWeekend = weekday === 'Sat' || weekday === 'Sun';
const isValidDay = !(
(pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) ||
(pref.reminder_freq === 'weekdays' && isWeekend) ||
(pref.reminder_freq === 'weekly' && weekday !== 'Mon')
);
// Fuera de ventana de gracia: saltar
if (nowMin > cfgMin + GRACE_MIN) {
try { if (isValidDay) Metrics.inc('reminders_skipped_outside_window_total'); } catch {}
continue;
if (isValidDay) {
try { Metrics.inc('reminders_skipped_outside_window_total'); } catch {}
}
}
return;
}
// Laborables: solo de lunes a viernes
if (pref.reminder_freq === 'weekdays' && (weekday === 'Sat' || weekday === 'Sun')) continue;
// Fetch and filter tasks
const allPending = TaskService.listUserPending(pref.user_id, 1000);
const filtered = filterTasksByGating(allPending, enforce);
if (filtered.length === 0) return; // No tasks yet — don't mark as reminded
// Semanal: solo lunes (Mon)
if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue;
const items = filtered.slice(0, 10);
try {
// Obtener una lista amplia para filtrar correctamente por grupos permitidos
const allPending = TaskService.listUserPending(pref.user_id, 1000);
const filtered = enforce ? allPending.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allPending;
const total = filtered.length;
const items = filtered.slice(0, 10);
if (items.length === 0) {
// No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy
continue;
}
// Construir mensaje similar a "/t ver mis"
const formatDDMM = (ymd?: string | null): string | null => {
if (!ymd) return null;
const parts = String(ymd).split('-');
if (parts.length >= 3) {
const [Y, M, D] = parts;
if (D && M) return `${D}/${M}`;
}
return String(ymd);
};
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[] = [];
sections.push(pref.reminder_freq === 'weekly' ? `${ICONS.reminder} RECORDATORIO SEMANAL — TUS TAREAS` : `${ICONS.reminder} RECORDATORIO DIARIO — 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(bold(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 < todayYMD : 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);
}
// Si hay más tareas de las listadas (tope), añadir resumen
if (total > items.length) {
sections.push(italic(`… y ${total - items.length} más`));
}
// (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca.
const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true';
if (includeUnassigned) {
let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id);
if (enforce) {
memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid));
}
for (const gid of memberGroups) {
const unassigned = TaskService.listGroupUnassigned(gid, 10);
if (unassigned.length > 0) {
const groupName =
(gid && GroupSyncService.activeGroupsCache.get(gid)) ||
gid;
sections.push(bold(`${groupName} — Sin responsable`));
const renderedUnassigned = unassigned.map((t) => {
const isOverdue = t.due_date ? t.due_date < todayYMD : 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} sin responsable`;
});
sections.push(...renderedUnassigned);
const totalUnassigned = TaskService.countGroupUnassigned(gid);
if (totalUnassigned > unassigned.length) {
sections.push(italic(`… y ${totalUnassigned - unassigned.length} más`));
}
}
}
}
await ResponseQueue.add([{
recipient: pref.user_id,
message: sections.join('\n')
}]);
// Marcar como enviado hoy
instanceDb.prepare(`
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
VALUES (?, COALESCE((SELECT reminder_freq FROM user_preferences WHERE user_id = ?), 'daily'),
COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30'),
?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
ON CONFLICT(user_id) DO UPDATE SET
last_reminded_on = excluded.last_reminded_on,
updated_at = excluded.updated_at
`).run(pref.user_id, pref.user_id, pref.user_id, todayYMD);
} catch (e) {
console.error('RemindersService: error al procesar usuario', pref.user_id, e);
}
}
}
// Build and send message
const message = await buildReminderMessage(pref, items, filtered.length, todayYMD);
await ResponseQueue.add([{ recipient: pref.user_id, message }]);
// Mark as sent
markReminded(db, pref.user_id, todayYMD);
}

@ -3,10 +3,10 @@ import { getDb } from '../db/locator';
import { IdentityService } from './identity';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { toIsoSqlUTC } from '../utils/datetime';
import { toIsoSqlUTC, toIsoUTC } from '../utils/datetime';
import * as EvolutionClient from '../clients/evolution';
import { runCleanupOnce as cleanupRunOnce } from './queue/cleanup';
import { parseQueueMetadata, isReactionMeta } from './queue/metadata';
import { parseQueueMetadata, isReactionMeta, type ReactionMeta } from './queue/metadata';
const MAX_FALLBACK_DIGITS = (() => {
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
@ -16,6 +16,99 @@ const MAX_FALLBACK_DIGITS = (() => {
const isDigits = (s: string) => /^\d+$/.test(s);
// ── Recipient resolution (extracted from sendOne) ────────────────────
type RecipientResult =
| { ok: true; number: string }
| { ok: false; status: 422; error: string };
function resolveRecipient(raw: string): RecipientResult {
const recipient = String(raw || '');
if (!recipient.includes('@')) {
const resolved = IdentityService.resolveAliasOrNull(recipient) || recipient;
if (!isDigits(resolved)) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
}
if (resolved.length >= MAX_FALLBACK_DIGITS) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
}
return { ok: true, number: resolved };
}
if (recipient.endsWith('@g.us')) return { ok: true, number: recipient };
if (recipient.endsWith('@s.whatsapp.net')) {
const n = normalizeWhatsAppId(recipient);
if (!n || !isDigits(n)) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
}
if (n.length >= MAX_FALLBACK_DIGITS) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
}
return { ok: true, number: n };
}
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'bad_domain' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_bad_domain' };
}
// ── Mention resolution (extracted from sendOne) ──────────────────────
function resolveMentions(metadata: string | null | undefined): string[] | undefined {
if (!metadata) return undefined;
let parsed: any;
try { parsed = JSON.parse(metadata); } catch { return undefined; }
if (!parsed || !Array.isArray(parsed.mentioned) || parsed.mentioned.length === 0) return undefined;
const resolved: string[] = [];
for (const m of parsed.mentioned) {
const n = normalizeWhatsAppId(String(m));
if (!n) continue;
const r = IdentityService.resolveAliasOrNull(n) || n;
if (!/^\d+$/.test(r)) continue;
resolved.push(`${r}@s.whatsapp.net`);
}
return resolved.length > 0 ? Array.from(new Set(resolved)) : undefined;
}
// ── Reaction job sender (extracted from sendOne) ─────────────────────
async function sendReactionJob(meta: ReactionMeta): Promise<{ ok: boolean; status?: number; error?: string }> {
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 key: any = { remoteJid: chatId, fromMe: !!meta.fromMe, id: messageId };
if (meta.participant) key.participant = String(meta.participant);
const result = await EvolutionClient.sendReaction({ key, reaction: emoji });
if (!result.ok) {
const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error');
console.warn('Send reaction failed:', { status: result.status, body: errTxt });
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
const out: { ok: false; error: string; status?: number } = { ok: false, error: errTxt };
if (typeof result.status === 'number') out.status = result.status;
return out;
}
console.log(`✅ Sent reaction: ${emoji} on ${chatId}/${messageId}`);
try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {}
const out: { ok: true; status?: number } = { ok: true };
if (typeof result.status === 'number') out.status = result.status;
return out;
}
type QueuedResponse = {
recipient: string;
message: string;
@ -33,9 +126,6 @@ type ClaimedItem = {
export const ResponseQueue = {
// Conservamos la cola en memoria por compatibilidad, aunque no se usa para persistencia
queue: [] as QueuedResponse[],
// Configuración fija (MVP)
WORKERS: 2,
BATCH_SIZE: 10,
@ -174,26 +264,22 @@ export const ResponseQueue = {
// 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);
const ts = Date.parse(toIsoUTC(tRaw));
if (Number.isFinite(ts) && ts > lastTsMs) {
lastTsMs = ts;
lastSentAt = tRaw || null;
lastVariant = (meta.variant === 'reminder' ? 'reminder' : 'initial');
}
// Primer initial (preferimos part=1)
// Primer initial (mantener el más antiguo)
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);
const curMs = Date.parse(toIsoUTC(String(firstInitialAt)));
const newMs = Date.parse(toIsoUTC(created));
if (Number.isFinite(newMs) && (!Number.isFinite(curMs) || newMs < curMs)) {
firstInitialAt = created || null;
}
@ -249,13 +335,6 @@ export const ResponseQueue = {
}
},
getHeaders(): HeadersInit {
return {
apikey: process.env.EVOLUTION_API_KEY || '',
'Content-Type': 'application/json',
};
},
async sendOne(item: ClaimedItem): Promise<{ ok: boolean; status?: number; error?: string }> {
const baseUrl = process.env.EVOLUTION_API_URL;
const instance = process.env.EVOLUTION_API_INSTANCE;
@ -265,120 +344,35 @@ export const ResponseQueue = {
return { ok: false, error: msg };
}
// Detectar jobs de reacción
// Reaction jobs: delegate to standalone sender
const meta = parseQueueMetadata(item.metadata);
if (isReactionMeta(meta)) {
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 };
const result = await EvolutionClient.sendReaction(payload);
return sendReactionJob(meta);
}
// Resolve recipient
const resolved = resolveRecipient(item.recipient);
if (!resolved.ok) return resolved;
// Build payload
const payload: any = { number: resolved.number, text: item.message };
const mentions = resolveMentions(item.metadata);
if (mentions) payload.mentioned = mentions;
// Send
try {
const result = await EvolutionClient.sendText(payload);
if (!result.ok) {
const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error');
console.warn('Send reaction failed:', { status: result.status, body: errTxt });
try { Metrics.inc('reactions_failed_total', 1, { emoji: emojiLabel }); } catch {}
const out: { ok: false; error: string } & { status?: number } = { ok: false, error: errTxt };
console.warn('Send failed:', { status: result.status, body: errTxt });
const out: { ok: false; error: string; status?: number } = { ok: false, error: errTxt };
if (typeof result.status === 'number') out.status = result.status;
return out;
}
console.log(`✅ Sent reaction with payload: ${JSON.stringify(payload)}`);
try { Metrics.inc('reactions_sent_total', 1, { emoji: emojiLabel }); } catch {}
const okOut: { ok: true } & { status?: number } = { ok: true };
console.log(`✅ Sent message to: ${resolved.number}`);
const okOut: { ok: true; status?: number } = { ok: true };
if (typeof result.status === 'number') okOut.status = result.status;
return okOut;
}
// Endpoint típico de Evolution API para texto simple
const url = `${baseUrl}/message/sendText/${instance}`;
try {
// Resolver destinatario efectivo (alias → número) y validar antes de construir el payload
const rawRecipient = String(item.recipient || '');
let numberOrJid = rawRecipient;
if (rawRecipient.includes('@')) {
if (rawRecipient.endsWith('@g.us')) {
// Envío a grupo: usar el JID completo tal cual
numberOrJid = rawRecipient;
} else if (rawRecipient.endsWith('@s.whatsapp.net')) {
// JID de usuario: normalizar a dígitos
const n = normalizeWhatsAppId(rawRecipient);
if (!n || !isDigits(n)) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
}
if (n.length >= MAX_FALLBACK_DIGITS) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
}
numberOrJid = n;
} else {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'bad_domain' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_bad_domain' };
}
} else {
// Sin dominio: resolver alias si existe y validar
const resolved = IdentityService.resolveAliasOrNull(rawRecipient) || rawRecipient;
if (!isDigits(resolved)) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'non_numeric' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_non_numeric' };
}
if (resolved.length >= MAX_FALLBACK_DIGITS) {
try { Metrics.inc('responses_skipped_unresolvable_recipient_total', 1, { reason: 'too_long' }); } catch {}
return { ok: false, status: 422, error: 'unresolvable_recipient_too_long' };
}
numberOrJid = resolved;
}
// Build payload, adding mentioned JIDs if present in metadata
const payload: any = {
number: numberOrJid,
text: item.message,
};
if (item.metadata) {
try {
const parsed = JSON.parse(item.metadata);
if (parsed && Array.isArray(parsed.mentioned) && parsed.mentioned.length > 0) {
const resolved: string[] = [];
for (const m of parsed.mentioned) {
const n = normalizeWhatsAppId(String(m));
if (!n) continue;
const r = IdentityService.resolveAliasOrNull(n) || n;
if (!/^\d+$/.test(r)) continue;
resolved.push(`${r}@s.whatsapp.net`);
}
// Eliminar duplicados
payload.mentioned = Array.from(new Set(resolved));
}
} catch {
// ignore bad metadata
}
}
{
const result = await EvolutionClient.sendText(payload);
if (!result.ok) {
const errTxt = result.error || (typeof result.status === 'number' ? `HTTP ${result.status}` : 'unknown_error');
console.warn('Send failed:', { status: result.status, body: errTxt });
const out: { ok: false; error: string } & { status?: number } = { ok: false, error: errTxt };
if (typeof result.status === 'number') out.status = result.status;
return out;
}
console.log(`✅ Sent message with payload: ${JSON.stringify(payload)}`);
const okOut: { ok: true } & { status?: number } = { ok: true };
if (typeof result.status === 'number') okOut.status = result.status;
return okOut;
}
} catch (err) {
const errMsg = (err instanceof Error ? err.message : String(err));
console.error('Network error sending message:', errMsg);

@ -1,4 +1,4 @@
import { REQUIRED_ENV } from '../server';
import { REQUIRED_ENV } from '../env/required';
type WebhookConfig = {
url: string;

@ -1,8 +1,95 @@
import type { Database } from 'bun:sqlite';
import { isGroupId } from '../utils/whatsapp';
import { toIsoUTC } from '../utils/datetime';
import { AllowedGroups } from '../services/allowed-groups';
import { ResponseQueue } from '../services/response-queue';
// ── Env helpers ──────────────────────────────────────────────
const DEFAULT_TTL_DAYS = 14;
function isEnvFlagEnabled(envKey: string, defaultVal = false): boolean {
const raw = String(process.env[envKey] || String(defaultVal)).toLowerCase();
return ['true', '1', 'yes', 'on'].includes(raw);
}
function envNumber(envKey: string, fallback: number): number {
const n = Number(process.env[envKey]);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function envString(envKey: string, fallback: string): string {
const v = (process.env[envKey] ?? '').trim();
return v || fallback;
}
// ── DB helpers ───────────────────────────────────────────────
interface TaskOrigin {
chat_id?: string;
message_id?: string;
created_at?: string;
participant?: string | null;
from_me?: number | boolean | null;
}
/** Query con fallback: si la columna participant/from_me no existe aún (schema antiguo), reintenta sin ellas. */
function getTaskOrigin(db: Database, taskId: number): TaskOrigin | null {
try {
const row = db.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins WHERE task_id = ?
`).get(taskId) as TaskOrigin | undefined;
return row ?? null;
} catch {
const row = db.prepare(`
SELECT chat_id, message_id, created_at
FROM task_origins WHERE task_id = ?
`).get(taskId) as TaskOrigin | undefined;
return row ?? null;
}
}
// ── Eligibility checks ───────────────────────────────────────
function isScopeEligible(chatId: string): boolean {
const scope = envString('REACTIONS_SCOPE', 'groups').toLowerCase();
return scope === 'all' || isGroupId(chatId);
}
function isWithinTtl(origin: TaskOrigin): boolean {
const ttlDays = envNumber('REACTIONS_TTL_DAYS', DEFAULT_TTL_DAYS);
const maxAgeMs = ttlDays * 24 * 60 * 60 * 1000;
const createdMs = Date.parse(toIsoUTC(String(origin.created_at || '')));
return Number.isFinite(createdMs) && (Date.now() - createdMs <= maxAgeMs);
}
function isGatingAllowed(chatId: string): boolean {
if (!isGroupId(chatId)) return true;
const mode = envString('GROUP_GATING_MODE', 'off').toLowerCase();
if (mode !== 'enforce') return true;
try {
return AllowedGroups.isAllowed(chatId);
} catch {
return true; // fail open
}
}
// ── Reaction options builder ─────────────────────────────────
function buildReactionOpts(origin: TaskOrigin): { participant?: string; fromMe?: boolean } {
const participant = origin.participant ? String(origin.participant) : undefined;
const fromMe = (origin.from_me === 1 || origin.from_me === true) ? true : undefined;
const opts: { participant?: string; fromMe?: boolean } = {};
if (participant !== undefined) opts.participant = participant;
if (typeof fromMe === 'boolean') opts.fromMe = fromMe;
return Object.keys(opts).length > 0 ? opts : {};
}
// ── Public API ───────────────────────────────────────────────
/**
* Publica una reacción al mensaje origen de la tarea si:
* - REACTIONS_ENABLED está activado,
@ -14,61 +101,23 @@ import { ResponseQueue } from '../services/response-queue';
*/
export function enqueueCompletionReactionIfEligible(db: Database, taskId: number): void {
try {
const rxEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase();
const enabled = ['true', '1', 'yes', 'on'].includes(rxEnabled);
if (!enabled) return;
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 { chat_id?: string; message_id?: string; created_at?: string; participant?: string | null; from_me?: number | boolean | null } | undefined;
} catch {
origin = db.prepare(`
SELECT chat_id, message_id, created_at
FROM task_origins
WHERE task_id = ?
`).get(taskId) as { chat_id?: string; message_id?: string; created_at?: string } | undefined;
}
if (!origin || !origin.chat_id || !origin.message_id) return;
if (!isEnvFlagEnabled('REACTIONS_ENABLED')) return;
const origin = getTaskOrigin(db, taskId);
if (!origin?.chat_id || !origin.message_id) return;
const chatId = String(origin.chat_id);
const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase();
if (!(scope === 'all' || isGroupId(chatId))) return;
// TTL desde REACTIONS_TTL_DAYS (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) return;
// Gating 'enforce' para grupos
if (isGroupId(chatId)) {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
let allowed = true;
try { allowed = AllowedGroups.isAllowed(chatId); } catch { allowed = true; }
if (!allowed) return;
}
}
// 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;
const rxOpts: { participant?: string; fromMe?: boolean } = {};
if (participant !== undefined) rxOpts.participant = participant;
if (typeof fromMe === 'boolean') rxOpts.fromMe = fromMe;
ResponseQueue.enqueueReaction(chatId, String(origin.message_id), '✅', rxOpts).catch(() => {});
if (!isScopeEligible(chatId)) return;
if (!isWithinTtl(origin)) return;
if (!isGatingAllowed(chatId)) return;
ResponseQueue.enqueueReaction(
chatId,
String(origin.message_id),
'✅',
buildReactionOpts(origin),
).catch(() => {});
} catch {
// no-op
// no-op: nunca bloquear el flujo de completado
}
}

@ -3,6 +3,22 @@
* Mantienen las mismas formas que consumen comandos, recordatorios y API web.
*/
export function mapTaskBasicRow(
row: { id?: number | null; description?: string | null; due_date?: string | null; display_code?: number | null }
): {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
} {
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
};
}
export function mapTaskListItem(
row: { id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null },
assignees: string[]

@ -1,18 +0,0 @@
export interface Task {
id: number;
description: string;
created_at: Date;
due_date: Date | null;
completed: boolean;
completed_at: Date | null;
completed_by: string | null;
group_id: string; // WhatsApp group ID where task was created
created_by: string; // WhatsApp user ID of task creator
}
export interface TaskAssignment {
task_id: number;
user_id: string; // Normalized phone number
assigned_by: string; // Who assigned this
assigned_at: Date;
}

@ -5,7 +5,7 @@ import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp';
import { pickNextDisplayCode } from './display-code';
import { enqueueCompletionReactionIfEligible } from './complete-reaction';
import { mapTaskListItem, mapTaskWithGroupNameRow, mapTaskDetailsRow } from './mappers';
import { mapTaskListItem, mapTaskWithGroupNameRow, mapTaskDetailsRow, mapTaskBasicRow } from './mappers';
type CreateTaskInput = {
description: string;
@ -97,42 +97,6 @@ export class TaskService {
return runTx();
}
// Listar pendientes del grupo (limite por defecto 10)
static listGroupPending(groupId: string, limit: number = 10): Array<{
id: number;
description: string;
due_date: string | null;
group_id: string | null;
display_code: number | null;
assignees: string[];
}> {
const rows = this.getDb()
.prepare(`
SELECT id, description, due_date, group_id, display_code
FROM tasks
WHERE group_id = ?
AND COALESCE(completed, 0) = 0 AND completed_at IS NULL
ORDER BY
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
due_date ASC,
id ASC
LIMIT ?
`)
.all(groupId, limit) as Array<{ id: number; description: string; due_date: string | null; group_id: string | null; display_code: number | null }>;
const getAssignees = this.getDb().prepare(`
SELECT user_id FROM task_assignments
WHERE task_id = ?
ORDER BY assigned_at ASC
`);
return rows.map((r) => {
const assigneesRows = getAssignees.all(r.id) as Array<{ user_id: string }>;
const assignees = assigneesRows.map((a) => String(a.user_id));
return mapTaskListItem(r, assignees);
});
}
// Listar pendientes asignadas al usuario (limite por defecto 10)
static listUserPending(userId: string, limit: number = 10): Array<{
id: number;
@ -170,19 +134,6 @@ export class TaskService {
});
}
// Contar pendientes del grupo (sin límite)
static countGroupPending(groupId: string): number {
const row = this.getDb()
.prepare(`
SELECT COUNT(*) as cnt
FROM tasks
WHERE group_id = ?
AND COALESCE(completed, 0) = 0 AND completed_at IS NULL
`)
.get(groupId) as { cnt?: number } | undefined;
return Number(row?.cnt || 0);
}
// Contar pendientes asignadas al usuario (sin límite)
static countUserPending(userId: string): number {
const row = this.getDb()
@ -369,6 +320,39 @@ export class TaskService {
};
}
// Construye el objeto task para respuestas de unassign/claim/complete
private static buildTaskResult(existing: any): {
id: number;
description: string;
due_date: string | null;
display_code: number | null;
} {
return {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
};
}
// Verifica si soltar sería prohibido (tarea personal con único asignatario)
private static isForbiddenPersonal(db: Database, taskId: number, userId: string, groupId: string | null): boolean {
if (groupId != null) return false;
try {
const stats = db.prepare(`
SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments
WHERE task_id = ?
`).get(userId, taskId) as { cnt?: number; mine?: number } | undefined;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
return cnt === 1 && mine;
} catch {
return false;
}
}
// Soltar tarea (unassign): idempotente
static unassignTask(taskId: number, userId: string): {
status: 'unassigned' | 'not_assigned' | 'not_found' | 'completed' | 'forbidden_personal';
@ -376,91 +360,38 @@ export class TaskService {
now_unassigned?: boolean; // true si tras soltar no quedan asignados
} {
const ensuredUser = ensureUserExists(userId, this.getDb());
if (!ensuredUser) {
throw new Error('No se pudo asegurar el usuario');
}
if (!ensuredUser) throw new Error('No se pudo asegurar el usuario');
const existing = this.getDb()
.prepare(`
SELECT id, description, due_date, group_id, completed, completed_at, display_code
FROM tasks
WHERE id = ?
`)
.prepare(`SELECT id, description, due_date, group_id, completed, completed_at, display_code FROM tasks WHERE id = ?`)
.get(taskId) as any;
if (!existing) {
return { status: 'not_found' };
}
// --- guard clauses ---
if (!existing) return { status: 'not_found' };
if (existing.completed || existing.completed_at) {
return { status: 'completed', task: TaskService.buildTaskResult(existing) };
}
if (TaskService.isForbiddenPersonal(this.getDb(), taskId, ensuredUser, existing.group_id)) {
return {
status: 'completed',
task: {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
status: 'forbidden_personal',
task: TaskService.buildTaskResult(existing),
now_unassigned: false,
};
}
// Regla: no permitir soltar si es tarea personal y el usuario es el único asignatario
try {
const stats = this.getDb().prepare(`
SELECT COUNT(*) AS cnt,
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
FROM task_assignments
WHERE task_id = ?
`).get(ensuredUser, taskId) as { cnt?: number; mine?: number } | undefined;
const cnt = Number(stats?.cnt || 0);
const mine = Number(stats?.mine || 0) > 0;
if (existing.group_id == null && cnt === 1 && mine) {
return {
status: 'forbidden_personal',
task: {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
now_unassigned: false,
};
}
} catch {}
// --- execute unassign & count remaining ---
const result = this.getDb()
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
.run(taskId, ensuredUser) as { changes?: number };
const deleteStmt = this.getDb().prepare(`
DELETE FROM task_assignments
WHERE task_id = ? AND user_id = ?
`);
const result = deleteStmt.run(taskId, ensuredUser) as { changes?: number };
const cntRow = this.getDb()
const remaining = Number((this.getDb()
.prepare(`SELECT COUNT(*) as cnt FROM task_assignments WHERE task_id = ?`)
.get(taskId) as { cnt?: number } | undefined;
const remaining = Number(cntRow?.cnt || 0);
if (result.changes && result.changes > 0) {
return {
status: 'unassigned',
task: {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
now_unassigned: remaining === 0,
};
}
.get(taskId) as { cnt?: number } | undefined)?.cnt || 0);
const status = result.changes && result.changes > 0 ? 'unassigned' : 'not_assigned';
return {
status: 'not_assigned',
task: {
id: Number(existing.id),
description: String(existing.description || ''),
due_date: existing.due_date ? String(existing.due_date) : null,
display_code: existing.display_code != null ? Number(existing.display_code) : null,
},
status,
task: TaskService.buildTaskResult(existing),
now_unassigned: remaining === 0,
};
}
@ -502,12 +433,7 @@ export class TaskService {
LIMIT 1
`).get(displayCode) as any;
if (!row) return null;
return {
id: Number(row.id),
description: String(row.description || ''),
due_date: row.due_date ? String(row.due_date) : null,
display_code: row.display_code != null ? Number(row.display_code) : null,
};
return mapTaskBasicRow(row);
}
// Lista tareas sin responsable para múltiples grupos.

@ -2,6 +2,41 @@ export function toIsoSqlUTC(d: Date = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
/**
* Devuelve YYYY-MM-DD en UTC (útil para consultas por rango de fecha).
*/
export function ymdUTC(date: Date = new Date()): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/**
* Convierte un string de timestamp (ISO 8601 o SQLite "YYYY-MM-DD HH:MM:SS") a ISO-8601.
* Si ya contiene 'T' se devuelve tal cual; si no, reemplaza espacio por 'T' y añade 'Z'.
*/
export function toIsoUTC(raw: string): string {
const s = String(raw ?? '').trim();
if (!s) return '';
if (s.includes('T')) return s;
return s.replace(' ', 'T') + 'Z';
}
/**
* Formats a Date as YYYY-MM-DD in the given IANA timezone.
*/
export function ymdInTZ(d: Date, tz: string): string {
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 normalizeTime(input: string | null | undefined): string | null {
const s = (input ?? '').trim();
const m = /^(\d{1,2}):(\d{1,2})$/.exec(s);

@ -50,12 +50,4 @@ export function isGroupId(jid: string | null | undefined): boolean {
return !!jid && jid.endsWith('@g.us');
}
/**
* Checks if a given raw JID represents a standard user chat.
*
* @param jid The raw JID string (e.g., '123456@s.whatsapp.net').
* @returns True if the JID ends with '@s.whatsapp.net', false otherwise.
*/
export function isUserJid(jid: string | null | undefined): boolean {
return !!jid && jid.endsWith('@s.whatsapp.net');
}

@ -0,0 +1,183 @@
/**
* startup.ts TypeScript equivalent of startup.sh
*
* Normalizes DB paths, starts the bot (index.ts) and web app (SvelteKit)
* in the background, waits for the database and auth tables to be ready,
* then runs the proxy router in the foreground.
*/
import { existsSync } from "node:fs";
import { resolve } from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Track spawned children so we can clean them up on exit. */
const children: { proc: ReturnType<typeof Bun.spawn> }[] = [];
function cleanup(): void {
for (const { proc } of children) {
try {
proc.kill();
} catch {
// Process already dead ignore
}
}
}
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
// ---------------------------------------------------------------------------
// 1. Normalize DB paths to absolute (readlink -f equivalent)
// ---------------------------------------------------------------------------
let dbPath = process.env.DB_PATH;
let dataDir = process.env.DATA_DIR || "/app/data";
if (dbPath) {
try {
dbPath = resolve(dbPath);
process.env.DB_PATH = dbPath;
} catch {
// Equivalent to `readlink -f "$DB_PATH" || true`
}
} else {
try {
dataDir = resolve(dataDir);
process.env.DATA_DIR = dataDir;
} catch {
// Equivalent to `readlink -f "$DATA_DIR" || true`
}
}
// ---------------------------------------------------------------------------
// 2. Determine the DB file path for wait checks
// ---------------------------------------------------------------------------
const dbFile: string = process.env.DB_PATH
? process.env.DB_PATH
: `${process.env.DATA_DIR || "/app/data"}/tasks.db`;
// ---------------------------------------------------------------------------
// 3. Start the bot in the background (default port 3007)
// ---------------------------------------------------------------------------
const botPort = process.env.BOT_PORT || "3007";
const botProc = Bun.spawn({
cmd: ["bun", "run", "index.ts"],
env: { ...process.env, PORT: botPort },
stdout: "inherit",
stderr: "inherit",
});
children.push({ proc: botProc });
// ---------------------------------------------------------------------------
// 4. Wait for the DB file to exist (max ~30 s)
// ---------------------------------------------------------------------------
console.log(`[startup] Waiting for database at: ${dbFile}`);
for (let i = 0; i < 150; i++) {
if (existsSync(dbFile)) break;
await Bun.sleep(200);
}
// ---------------------------------------------------------------------------
// 5. Wait for auth tables (web_tokens, web_sessions) — max ~30 s
// ---------------------------------------------------------------------------
// Check if sqlite3 CLI is available (equivalent to `command -v sqlite3`)
let hasSqlite3 = false;
try {
const check = Bun.spawn({
cmd: ["sqlite3", "--version"],
stdout: "pipe",
stderr: "pipe",
});
await check.exited;
hasSqlite3 = check.exitCode === 0;
} catch {
hasSqlite3 = false;
}
if (hasSqlite3) {
console.log(
"[startup] Checking for auth tables (web_tokens, web_sessions)...",
);
let authReady = false;
for (let i = 0; i < 150; i++) {
if (existsSync(dbFile)) {
const proc = Bun.spawn({
cmd: [
"sqlite3",
dbFile,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('web_tokens','web_sessions') LIMIT 1;",
],
stdout: "pipe",
stderr: "pipe",
});
const out = await new Response(proc.stdout).text();
await proc.exited;
if (out.trim() === "1") {
authReady = true;
break;
}
}
await Bun.sleep(200);
}
if (!authReady) {
console.log(
"[startup] Warning: auth tables may not be ready after waiting.",
);
}
} else {
console.log(
"[startup] sqlite3 not available; skipping auth-table verification (continuing).",
);
}
// ---------------------------------------------------------------------------
// 6. Start the web app (SvelteKit) in the background (default port 3008)
// ---------------------------------------------------------------------------
const webPort = process.env.WEB_PORT || "3008";
const webProc = Bun.spawn({
cmd: ["bun", "./build/index.js"],
cwd: resolve("apps/web"),
env: { ...process.env, PORT: webPort },
stdout: "inherit",
stderr: "inherit",
});
children.push({ proc: webProc });
// ---------------------------------------------------------------------------
// 7. Small wait to avoid race conditions
// ---------------------------------------------------------------------------
await Bun.sleep(1000);
// ---------------------------------------------------------------------------
// 8. Start the proxy router in the foreground (default port 3000)
// ---------------------------------------------------------------------------
const port = process.env.PORT || "3000";
const proxyProc = Bun.spawn({
cmd: ["bun", "proxy.ts"],
env: { ...process.env, PORT: port },
stdout: "inherit",
stderr: "inherit",
});
// Wait for the proxy (foreground process) to exit, then shut down.
const exitCode = await proxyProc.exited;
console.log(`[startup] Proxy exited with code ${exitCode}`);
cleanup();
process.exit(typeof exitCode === "number" ? exitCode : 1);

@ -1,20 +1,14 @@
import { toIsoSqlUTC } from '../../src/utils/datetime';
import { toIsoSqlUTC, ymdUTC, ymdInTZ as sharedYmdInTZ } from '../../src/utils/datetime';
export function toIsoSql(d: Date = new Date()): string {
return toIsoSqlUTC(d);
}
export { toIsoSqlUTC };
export { toIsoSqlUTC, ymdUTC };
/** Wraps shared ymdInTZ with a default timezone for tests. */
export function ymdInTZ(d: Date, tz: string = 'Europe/Madrid'): string {
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')}`;
return sharedYmdInTZ(d, tz);
}
export function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Madrid'): string {
@ -24,13 +18,6 @@ export function addDaysToYMD(ymd: string, days: number, tz: string = 'Europe/Mad
return ymdInTZ(base, tz);
}
export function ymdUTC(date: Date = new Date()): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
export function addDays(date: Date, days: number): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
d.setUTCDate(d.getUTCDate() + days);

@ -1,6 +1,7 @@
import Database, { type Database as SqliteDatabase } from 'bun:sqlite';
import { initializeDatabase } from '../../src/db';
import { setDb } from '../../src/db/locator';
import { toIsoSql } from './dates';
// Servicios opcionales para inyección de DB en tests.
// Importamos con nombres existentes en la base de código para respetar convenciones.
@ -44,6 +45,61 @@ export function resetServices(): void {
/**
* Marca como 'allowed' los groupIds indicados en la DB provista.
*/
/**
* Sembrar un grupo en la DB rellenando columnas NOT NULL sin valor por defecto.
* Usa PRAGMA table_info para adaptarse automáticamente a la forma actual de la tabla.
*/
export function seedGroup(db: SqliteDatabase, groupId: string): void {
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
const values: Record<string, any> = {};
const nowIso = toIsoSql(new Date());
for (const c of cols) {
const name = String(c.name);
const type = String(c.type || '').toUpperCase();
const notnull = Number(c.notnull || 0) === 1;
const hasDefault = c.dflt_value != null;
if (name === 'id') {
values[name] = groupId;
continue;
}
// Preconfigurar algunos alias comunes
if (name === 'name' || name === 'title' || name === 'subject') {
values[name] = 'Test Group';
continue;
}
if (name === 'created_by') {
values[name] = 'tester';
continue;
}
if (name.endsWith('_at')) {
values[name] = nowIso;
continue;
}
if (name === 'is_active' || name === 'active') {
values[name] = 1;
continue;
}
// Para columnas NOT NULL sin valor por defecto, asignar valores genéricos
if (notnull && !hasDefault) {
if (type.includes('INT')) values[name] = 1;
else if (type.includes('REAL')) values[name] = 0;
else values[name] = 'N/A';
}
}
// Asegurar que id esté siempre
if (!('id' in values)) values['id'] = groupId;
const colsList = Object.keys(values);
const placeholders = colsList.map(() => '?').join(', ');
const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`;
db.prepare(sql).run(...colsList.map(k => values[k]));
}
export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void {
for (const gid of groupIds) {
const g = String(gid || '').trim();

@ -0,0 +1,98 @@
/**
* Shared test harness for server.test.ts family.
*
* Provides database setup, queue mocking, and env handling used by all
* WebhookServer test suites. Each test file gets its own inmemory DB
* via the factory-style setup/teardown pair.
*/
import { Database } from 'bun:sqlite';
import { beforeAll, afterAll, beforeEach } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { ResponseQueue } from '../../src/services/response-queue';
import { GroupSyncService } from '../../src/services/group-sync';
import { initializeDatabase } from '../../src/db';
import { setDb, resetDb } from '../../src/db/locator';
import { SimulatedResponseQueue } from './queue';
// ── Request builder ────────────────────────────────────────────────────
export function createTestRequest(payload: unknown): Request {
return new Request('http://localhost:3007', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
// ── Date helper ────────────────────────────────────────────────────────
export function getFutureDate(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
// ── Lifecycle helpers ──────────────────────────────────────────────────
// ── Internal state ─────────────────────────────────────────────────────
const ENV_BACKUP = { ...process.env };
// ── Public lifecycle factory ───────────────────────────────────────────
/**
* Register the standard beforeAll / afterAll / beforeEach hooks for a
* WebhookServer test module. Returns the shared inmemory test DB so
* individual tests can query it directly.
*
* Usage (at module scope in each test file):
* const testDb = registerServerTestLifecycle();
*/
export function registerServerTestLifecycle(): Database {
const db = new Database(':memory:');
initializeDatabase(db);
const originalAdd = (ResponseQueue as any).add;
beforeAll(() => {
/* DB already set up */
});
afterAll(() => {
(ResponseQueue as any).add = originalAdd;
resetDb();
db.close();
});
beforeEach(() => {
// Clear simulated queue and swap in the fake
SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add;
// Point WebhookServer at the test DB
WebhookServer.dbInstance = db;
setDb(db);
// Reinit schema (safe after destructive tests that DROP tables)
initializeDatabase(db);
// Truncate data
db.exec('DELETE FROM response_queue');
try { db.exec('DELETE FROM task_origins'); } catch { /* may not exist */ }
db.exec('DELETE FROM tasks');
db.exec('DELETE FROM users');
db.exec('DELETE FROM groups');
// Canonical active group
db.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('group-id@g.us', 'test-community', 'Test Group', 1)
`);
(GroupSyncService as any).cacheActiveGroups();
// Standard test env
process.env = { ...ENV_BACKUP, INSTANCE_NAME: 'test-instance', NODE_ENV: 'test' };
});
return db;
}

@ -1,10 +1,10 @@
import Database from 'bun:sqlite';
import { describe, it, expect } from 'bun:test';
import { getDb, setDb, withDb, DbNotConfiguredError } from '../../../src/db/locator';
import { getDb, setDb, withDb } from '../../../src/db/locator';
describe('db/locator', () => {
it('getDb lanza si no está configurada', () => {
expect(() => getDb()).toThrow(DbNotConfiguredError);
expect(() => getDb()).toThrow();
});
it('setDb y getDb devuelven la misma instancia', () => {

@ -0,0 +1,175 @@
/**
* Advanced listing tests.
*
* Tests the "t ver sin" and "t ver todos" flows through the webhook
* handler, covering DMonly responses, pagination, and unassignedtask
* listings with their instructive notes.
*/
import { describe, test, expect } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { TaskService } from '../../src/tasks/service';
import { SimulatedResponseQueue } from '../helpers/queue';
import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness';
const testDb = registerServerTestLifecycle();
// ── Tests ──────────────────────────────────────────────────────────────
describe('Advanced listings via WebhookServer', () => {
test('should process "t ver sin" in group as DM-only with pagination line', async () => {
// 12 unassigned in the active group
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'group-id@g.us',
created_by: '9999999999',
});
}
// 2 assigned (should not appear in "sin")
TaskService.createTask(
{
description: 'Asignada 1',
due_date: '2025-10-10',
group_id: 'group-id@g.us',
created_by: '1111111111',
},
[{ user_id: '1234567890', assigned_by: '1111111111' }],
);
TaskService.createTask(
{
description: 'Asignada 2',
due_date: '2025-10-11',
group_id: 'group-id@g.us',
created_by: '1111111111',
},
[{ user_id: '1234567890', assigned_by: '1111111111' }],
);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't ver sin' },
},
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false);
}
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
});
test('should process "t ver sin" in DM returning instruction', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: '1234567890@s.whatsapp.net',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't ver sin' },
},
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No tienes tareas pendientes.');
});
test('should process "t ver todos" in group showing "Tus tareas" + "Sin dueño" with pagination', async () => {
// User's tasks (2 assigned)
TaskService.createTask(
{
description: 'Mi Tarea 1',
due_date: '2025-10-10',
group_id: 'group-id@g.us',
created_by: '2222222222',
},
[{ user_id: '1234567890', assigned_by: '2222222222' }],
);
TaskService.createTask(
{
description: 'Mi Tarea 2',
due_date: '2025-10-11',
group_id: 'group-id@g.us',
created_by: '2222222222',
},
[{ user_id: '1234567890', assigned_by: '2222222222' }],
);
// 12 unassigned to trigger pagination
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'group-id@g.us',
created_by: '9999999999',
});
}
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't ver todos' },
},
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
});
test('should process "t ver todos" in DM showing "Tus tareas" + instructive note', async () => {
TaskService.createTask(
{
description: 'Mi Tarea A',
due_date: '2025-11-20',
group_id: 'group-2@g.us',
created_by: '1111111111',
},
[{ user_id: '1234567890', assigned_by: '1111111111' }],
);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: '1234567890@s.whatsapp.net',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't ver todos' },
},
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('Tus tareas');
expect(msg).toContain(' Para ver tareas sin responsable');
});
});

@ -0,0 +1,264 @@
/**
* Core HTTP/webhook validation and messagetype handling.
*
* These tests verify that WebhookServer correctly validates incoming
* requests before they reach command processing.
*/
import { describe, test, expect } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { SimulatedResponseQueue } from '../helpers/queue';
import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness';
const testDb = registerServerTestLifecycle();
// ── Tests ──────────────────────────────────────────────────────────────
describe('WebhookServer — Basic validation', () => {
test('should reject non-POST requests', async () => {
const request = new Request('http://localhost:3007', { method: 'GET' });
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(405);
});
test('should require JSON content type', async () => {
const request = new Request('http://localhost:3007', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
});
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(400);
});
test('should validate payload structure', async () => {
const invalidPayloads = [
{},
{ event: null },
{ event: 'messages.upsert', instance: null },
];
for (const invalidPayload of invalidPayloads) {
const request = createTestRequest(invalidPayload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(400);
}
});
test('should verify instance name', async () => {
process.env.TEST_VERIFY_INSTANCE = 'true';
const payload = {
event: 'messages.upsert',
instance: 'wrong-instance',
data: {},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(403);
delete process.env.TEST_VERIFY_INSTANCE;
});
test('should handle valid messages.upsert', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore empty message content', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: '' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should handle very long messages', async () => {
const longMessage = 'tarea nueva ' + 'A'.repeat(5000);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: longMessage },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle messages with special characters and emojis', async () => {
const specialMessage = 'tarea nueva Test 😊 你好 @#$%^&*()';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: specialMessage },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore non-tarea commands', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: '/othercommand test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ignore message with mentions but no command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: {
conversation: 'Hello everyone!',
contextInfo: {
mentionedJid: ['1234567890@s.whatsapp.net'],
},
},
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ignore media attachment messages', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: {
imageMessage: { caption: 'This is an image' },
},
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should process command from extendedTextMessage', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: {
extendedTextMessage: { text: 't n Test ext' },
},
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should process command from image caption when caption starts with a command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: {
imageMessage: { caption: 't n From caption' },
},
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle requests on configured port', async () => {
const originalPort = process.env.PORT;
process.env.PORT = '3007';
const prevEnv = {
EVOLUTION_API_URL: process.env.EVOLUTION_API_URL,
EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY,
EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE,
CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER,
WEBHOOK_URL: process.env.WEBHOOK_URL,
};
process.env.EVOLUTION_API_URL = 'http://localhost:3000';
process.env.EVOLUTION_API_KEY = 'test-key';
process.env.EVOLUTION_API_INSTANCE = 'test-instance';
process.env.CHATBOT_PHONE_NUMBER = '9999999999';
process.env.WEBHOOK_URL = 'http://localhost:3007';
try {
const server = await WebhookServer.start();
const response = await fetch('http://localhost:3007/health');
expect(response.status).toBe(200);
await server.stop();
} finally {
process.env.PORT = originalPort;
process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL;
process.env.EVOLUTION_API_KEY = prevEnv.EVOLUTION_API_KEY;
process.env.EVOLUTION_API_INSTANCE = prevEnv.EVOLUTION_API_INSTANCE;
process.env.CHATBOT_PHONE_NUMBER = prevEnv.CHATBOT_PHONE_NUMBER;
process.env.WEBHOOK_URL = prevEnv.WEBHOOK_URL;
}
});
});

@ -0,0 +1,228 @@
/**
* Command logging and datehandling tests.
*
* Covers tarea command logging, date parsing edge cases, XSS/SQL
* injection resilience, and sender ID normalization.
*/
import { describe, test, expect } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { SimulatedResponseQueue } from '../helpers/queue';
import { createTestRequest, getFutureDate, registerServerTestLifecycle } from '../helpers/server-test-harness';
const testDb = registerServerTestLifecycle();
// ── Tests ──────────────────────────────────────────────────────────────
describe('tarea command logging', () => {
test('should log basic tarea command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'user123@s.whatsapp.net',
},
message: { conversation: 'tarea test' },
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should log command with due date', async () => {
const futureDate = getFutureDate(3);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'user123@s.whatsapp.net',
},
message: {
conversation: `tarea nueva Finish project @user2 ${futureDate}`,
contextInfo: {
mentionedJid: ['user2@s.whatsapp.net'],
},
},
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
});
describe('WebhookServer — Date handling & edge cases', () => {
test('should handle XSS/SQL injection attempts', async () => {
const maliciousMessage = `tarea nueva <script>alert('xss')</script>'; DROP TABLE tasks; --`;
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: maliciousMessage },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle multiple dates in command (use last one as due date)', async () => {
const futureDate1 = getFutureDate(3);
const futureDate2 = getFutureDate(5);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: `tarea nueva Test task ${futureDate1} some text ${futureDate2}` },
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore past dates as due dates', async () => {
const pastDate = '2020-01-01';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: `tarea nueva Old task ${pastDate}` },
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle multiple past dates correctly', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test 2020-01-01 2020-02-01' },
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle mixed valid and invalid date formats', async () => {
const futureDate = getFutureDate(3);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net',
},
message: { conversation: `tarea nueva Test invalid-date ${futureDate} another-bad` },
},
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should normalize sender ID before processing', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id:12@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore messages with invalid sender ID', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'invalid!@#$',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ensure user exists and use normalized ID', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test user' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
expect(user).toBeDefined();
expect((user as any).id).toBe('1234567890');
});
test('should ignore messages if user creation fails', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'invalid!user@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
const userCount = testDb.query('SELECT COUNT(*) as count FROM users').get();
expect((userCount as any).count).toBe(0);
});
});

@ -0,0 +1,292 @@
/**
* Coverage gap tests for src/server.ts functions flagged by fallow.
*
* These fill genuinely untested paths that fallow can't trace through
* the existing integration-level tests.
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { Database } from "bun:sqlite";
import { WebhookServer } from "../../src/server";
import { getMessageText } from "../../src/http/webhook-handler";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Access private static methods for direct testing. */
const server = WebhookServer as any;
function createTestRequest(body: unknown): Request {
return new Request("http://localhost:3007", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function createTestRequestRaw(body: string): Request {
return new Request("http://localhost:3007", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
}
// ---------------------------------------------------------------------------
// getMessageText — 5 branches, tested only indirectly
// ---------------------------------------------------------------------------
describe("getMessageText — direct edge cases", () => {
test("returns empty for null / undefined", () => {
expect(getMessageText(null)).toBe("");
expect(getMessageText(undefined)).toBe("");
});
test("returns empty for non-object input", () => {
expect(getMessageText("a string")).toBe("");
expect(getMessageText(42)).toBe("");
});
test("extracts conversation text", () => {
expect(getMessageText({ conversation: " hello world " })).toBe(
"hello world",
);
});
test("falls back when conversation is empty string", () => {
expect(
getMessageText({
conversation: "",
extendedTextMessage: { text: "fallback" },
}),
).toBe("fallback");
});
test("extracts extendedTextMessage.text", () => {
expect(
getMessageText({
extendedTextMessage: { text: " extended text " },
}),
).toBe("extended text");
});
test("extracts imageMessage.caption", () => {
expect(
getMessageText({
imageMessage: { caption: " image caption " },
}),
).toBe("image caption");
});
test("extracts videoMessage.caption", () => {
expect(
getMessageText({
videoMessage: { caption: " video caption " },
}),
).toBe("video caption");
});
test("returns empty when all fields are empty strings", () => {
expect(
getMessageText({
conversation: "",
extendedTextMessage: { text: "" },
imageMessage: { caption: "" },
}),
).toBe("");
});
test("returns empty when text is a non-string (edge case)", () => {
expect(
getMessageText({ conversation: 12345 }),
).toBe("");
});
});
// ---------------------------------------------------------------------------
// validateEnv — error paths never exercised
// ---------------------------------------------------------------------------
describe("validateEnv — error paths", () => {
let exitCode: number | null = null;
let stderr: string[] = [];
const realExit = process.exit;
const realConsoleError = console.error;
beforeEach(() => {
exitCode = null;
stderr = [];
process.exit = ((code?: number) => {
exitCode = code ?? 1;
throw new Error(`process.exit(${code})`);
}) as any;
console.error = (...args: any[]) => {
stderr.push(args.map(String).join(" "));
};
});
afterEach(() => {
process.exit = realExit;
console.error = realConsoleError;
});
test("exits when required env vars are missing", () => {
// Remove a required var
const saved = process.env.EVOLUTION_API_URL;
delete (process.env as any).EVOLUTION_API_URL;
try {
server.validateEnv();
} catch {
// Expected — process.exit throws
}
expect(exitCode).toBe(1);
expect(stderr.some((l) => l.includes("EVOLUTION_API_URL"))).toBe(true);
process.env.EVOLUTION_API_URL = saved;
});
test("exits when CHATBOT_PHONE_NUMBER contains non-digits", () => {
// Set all required vars so we reach the phone check
const saved: Record<string, string | undefined> = {};
for (const k of ["EVOLUTION_API_URL", "EVOLUTION_API_KEY", "EVOLUTION_API_INSTANCE", "WEBHOOK_URL"]) {
saved[k] = process.env[k];
process.env[k] = "set";
}
process.env.CHATBOT_PHONE_NUMBER = "55-1234-5678";
try {
server.validateEnv();
} catch {
// Expected
}
expect(exitCode).toBe(1);
expect(stderr.some((l) => l.includes("digits"))).toBe(true);
for (const k of Object.keys(saved)) {
process.env[k] = saved[k];
}
});
test("succeeds when all vars are valid", () => {
// Ensure required vars are set
const prev = {
EVOLUTION_API_URL: process.env.EVOLUTION_API_URL,
EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY,
EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE,
CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER,
WEBHOOK_URL: process.env.WEBHOOK_URL,
};
process.env.EVOLUTION_API_URL = "http://localhost:8080";
process.env.EVOLUTION_API_KEY = "k";
process.env.EVOLUTION_API_INSTANCE = "i";
process.env.CHATBOT_PHONE_NUMBER = "1234567890";
process.env.WEBHOOK_URL = "http://localhost:3000";
try {
server.validateEnv();
// Should not throw
expect(exitCode).toBeNull();
} finally {
Object.assign(process.env, prev);
}
});
});
// ---------------------------------------------------------------------------
// contacts.update / chats.update events — never tested
// ---------------------------------------------------------------------------
describe("routeWebhookEvent — contacts.update / chats.update", () => {
test("handles contacts.update event without crash", async () => {
const res = await WebhookServer.handleRequest(
createTestRequest({
event: "contacts.update",
instance: "test-instance",
data: {
contacts: [
{ id: "1234567890@s.whatsapp.net", name: "Test User" },
],
},
}),
);
expect(res.status).toBe(200);
});
test("handles chats.update event without crash", async () => {
const res = await WebhookServer.handleRequest(
createTestRequest({
event: "chats.update",
instance: "test-instance",
data: {
chats: [
{ id: "group-id@g.us", name: "Updated Chat" },
],
},
}),
);
expect(res.status).toBe(200);
});
test("handles contacts.update with empty data gracefully", async () => {
const res = await WebhookServer.handleRequest(
createTestRequest({
event: "contacts.update",
instance: "test-instance",
data: {},
}),
);
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Error path: malformed JSON body
// ---------------------------------------------------------------------------
describe("handleRequest — error paths", () => {
test("returns 400 for malformed JSON body", async () => {
const req = createTestRequestRaw("{not valid json");
const res = await WebhookServer.handleRequest(req);
expect(res.status).toBe(400);
});
test("returns 400 for empty body", async () => {
const req = new Request("http://localhost:3007", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const res = await WebhookServer.handleRequest(req);
expect(res.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// getBaseUrl — never tested
// ---------------------------------------------------------------------------
describe("getBaseUrl", () => {
test("uses x-forwarded-proto and x-forwarded-host when present", () => {
const req = new Request("http://localhost:3007", {
headers: {
"x-forwarded-proto": "https",
"x-forwarded-host": "example.com",
},
});
expect(server.getBaseUrl(req)).toBe("https://example.com");
});
test("falls back to host header when no forwarded headers", () => {
const req = new Request("http://myhost.local:3007", {
headers: { host: "myhost.local:3007" },
});
expect(server.getBaseUrl(req)).toBe("http://myhost.local:3007");
});
test("uses http when no proto and no forwarded proto", () => {
const req = new Request("http://10.0.0.1:3007", {
headers: { host: "10.0.0.1:3007" },
});
expect(server.getBaseUrl(req)).toBe("http://10.0.0.1:3007");
});
});

@ -0,0 +1,97 @@
/**
* Group validation tests.
*
* Ensures WebhookServer respects the active/inactive status of groups,
* supports the t command alias, and enforces the DMonly response policy.
*/
import { describe, test, expect } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { SimulatedResponseQueue } from '../helpers/queue';
import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness';
const testDb = registerServerTestLifecycle();
// ── Tests ──────────────────────────────────────────────────────────────
describe('Group validation in handleMessageUpsert', () => {
test('should ignore messages from inactive groups', async () => {
testDb.exec(`
INSERT OR REPLACE INTO groups (id, community_id, name, active)
VALUES ('inactive-group@g.us', 'test-community', 'Inactive Group', 0)
`);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'inactive-group@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should proceed with messages from active groups', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should accept t alias and process command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't n Tarea alias hoy' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should never send responses to the group (DM only policy)', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 't n Probar silencio grupo mañana' },
},
};
const request = createTestRequest(payload);
await WebhookServer.handleRequest(request);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false);
}
});
});

@ -1,941 +0,0 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import { WebhookServer } from '../../src/server';
import { ResponseQueue } from '../../src/services/response-queue';
import { GroupSyncService } from '../../src/services/group-sync';
import { initializeDatabase, ensureUserExists } from '../../src/db';
import { TaskService } from '../../src/tasks/service';
import { setDb, resetDb } from '../../src/db/locator';
let originalAdd: any;
import { SimulatedResponseQueue } from '../helpers/queue';
// Test database instance
let testDb: Database;
beforeAll(() => {
// Create in-memory test database
testDb = new Database(':memory:');
// Initialize schema
initializeDatabase(testDb);
// Guardar implementación original de ResponseQueue.add para restaurar después
originalAdd = (ResponseQueue as any).add;
});
afterAll(() => {
(ResponseQueue as any).add = originalAdd;
resetDb();
// Close the test database
testDb.close();
});
beforeEach(() => {
// Clear simulated queue
SimulatedResponseQueue.clear();
// Replace ResponseQueue with simulated version
(ResponseQueue as any).add = SimulatedResponseQueue.add;
// Inject testDb for WebhookServer to use
WebhookServer.dbInstance = testDb;
// Usar el locator global para el resto de servicios
setDb(testDb);
// Ensure database is initialized (recreates tables if dropped)
initializeDatabase(testDb);
// Reset database state between tests (borrar raíz primero; ON DELETE CASCADE limpia assignments)
testDb.exec('DELETE FROM response_queue');
try { testDb.exec('DELETE FROM task_origins'); } catch { }
testDb.exec('DELETE FROM tasks');
testDb.exec('DELETE FROM users');
testDb.exec('DELETE FROM groups');
// Insert test data for active group
testDb.exec(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES ('group-id@g.us', 'test-community', 'Test Group', 1)
`);
// Populate active groups cache with test data
GroupSyncService['cacheActiveGroups']();
});
describe('WebhookServer', () => {
const envBackup = process.env;
beforeEach(() => {
process.env = {
...envBackup,
INSTANCE_NAME: 'test-instance',
NODE_ENV: 'test'
};
});
afterEach(() => {
process.env = envBackup;
(ResponseQueue as any).add = originalAdd;
});
const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
test('should reject non-POST requests', async () => {
const request = new Request('http://localhost:3007', { method: 'GET' });
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(405);
});
test('should require JSON content type', async () => {
const request = new Request('http://localhost:3007', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }
});
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(400);
});
test('should validate payload structure', async () => {
const invalidPayloads = [
{},
{ event: null },
{ event: 'messages.upsert', instance: null }
];
for (const invalidPayload of invalidPayloads) {
const request = createTestRequest(invalidPayload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(400);
}
});
test('should verify instance name', async () => {
process.env.TEST_VERIFY_INSTANCE = 'true';
const payload = {
event: 'messages.upsert',
instance: 'wrong-instance',
data: {}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(403);
delete process.env.TEST_VERIFY_INSTANCE;
});
test('should handle valid messages.upsert', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore empty message content', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: '' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should handle very long messages', async () => {
const longMessage = '/tarea nueva ' + 'A'.repeat(5000);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: longMessage }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle messages with special characters and emojis', async () => {
const specialMessage = '/tarea nueva Test 😊 你好 @#$%^&*()';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: specialMessage }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore non-/tarea commands', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: '/othercommand test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ignore message with mentions but no command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
conversation: 'Hello everyone!',
contextInfo: {
mentionedJid: ['1234567890@s.whatsapp.net']
}
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ignore media attachment messages', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
imageMessage: { caption: 'This is an image' }
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should process command from extendedTextMessage', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
extendedTextMessage: { text: '/t n Test ext' }
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should process command from image caption when caption starts with a command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: {
imageMessage: { caption: '/t n From caption' }
}
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle requests on configured port', async () => {
const originalPort = process.env.PORT;
process.env.PORT = '3007';
// Satisfacer validación de entorno en start()
const prevEnv = {
EVOLUTION_API_URL: process.env.EVOLUTION_API_URL,
EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY,
EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE,
CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER,
WEBHOOK_URL: process.env.WEBHOOK_URL
};
process.env.EVOLUTION_API_URL = 'http://localhost:3000';
process.env.EVOLUTION_API_KEY = 'test-key';
process.env.EVOLUTION_API_INSTANCE = 'test-instance';
process.env.CHATBOT_PHONE_NUMBER = '9999999999';
process.env.WEBHOOK_URL = 'http://localhost:3007';
try {
const server = await WebhookServer.start();
const response = await fetch('http://localhost:3007/health');
expect(response.status).toBe(200);
await server.stop();
} finally {
process.env.PORT = originalPort;
process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL;
process.env.EVOLUTION_API_KEY = prevEnv.EVOLUTION_API_KEY;
process.env.EVOLUTION_API_INSTANCE = prevEnv.EVOLUTION_API_INSTANCE;
process.env.CHATBOT_PHONE_NUMBER = prevEnv.CHATBOT_PHONE_NUMBER;
process.env.WEBHOOK_URL = prevEnv.WEBHOOK_URL;
}
});
function getFutureDate(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
describe('/tarea command logging', () => {
test('should log basic /tarea command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'user123@s.whatsapp.net'
},
message: { conversation: '/tarea test' }
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
// Check that a response was queued (indicating command processing)
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should log command with due date', async () => {
const futureDate = getFutureDate(3); // Get date 3 days in future
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'user123@s.whatsapp.net'
},
message: {
conversation: `/tarea nueva Finish project @user2 ${futureDate}`,
contextInfo: {
mentionedJid: ['user2@s.whatsapp.net']
}
}
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
// Verify command processing by checking queue
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
});
test('should handle XSS/SQL injection attempts', async () => {
const maliciousMessage = `/tarea nueva <script>alert('xss')</script>'; DROP TABLE tasks; --`;
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: maliciousMessage }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle multiple dates in command (use last one as due date)', async () => {
const futureDate1 = getFutureDate(3);
const futureDate2 = getFutureDate(5);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: `/tarea nueva Test task ${futureDate1} some text ${futureDate2}` }
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore past dates as due dates', async () => {
const pastDate = '2020-01-01';
const futureDate = getFutureDate(2);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: `/tarea nueva Test task ${pastDate} more text ${futureDate}` }
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle multiple past dates correctly', async () => {
const pastDate1 = '2020-01-01';
const pastDate2 = '2021-01-01';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: `/tarea nueva Test task ${pastDate1} and ${pastDate2}` }
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle mixed valid and invalid date formats', async () => {
const futureDate = getFutureDate(2);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'sender-id@s.whatsapp.net'
},
message: { conversation: `/tarea nueva Test task 2023-13-01 (invalid) ${futureDate} 25/12/2023 (invalid)` }
}
};
await WebhookServer.handleRequest(createTestRequest(payload));
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should normalize sender ID before processing', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890:12@s.whatsapp.net' // ID with participant
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should ignore messages with invalid sender ID', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'invalid-id!' // Invalid ID
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should ensure user exists and use normalized ID', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created in real database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
expect(user).toBeDefined();
expect(user.id).toBe('1234567890');
});
test('should ignore messages if user creation fails', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: null // Invalid participant
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
// Verify no user was created
const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get();
expect(userCount.count).toBe(0);
});
// Integration tests with real database
describe('User validation in handleMessageUpsert', () => {
test('should proceed with valid user', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created in real database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
expect(user).toBeDefined();
expect(user.id).toBe('1234567890');
});
test('should ignore message if user validation fails', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'invalid!user@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
// Verify no user was created
const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get();
expect(userCount.count).toBe(0);
});
test('should handle database errors during user validation', async () => {
// Force a database error by corrupting the database state
testDb.exec('DROP TABLE users');
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
// Reinitialize database for subsequent tests (force full migration)
testDb.exec('DROP TABLE IF EXISTS schema_migrations');
initializeDatabase(testDb);
});
test('should integrate user validation completely in handleMessageUpsert with valid user', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// Verify user was created/updated in database
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
expect(user).toBeDefined();
expect(user.id).toBe('1234567890');
expect(user.first_seen).toBeDefined();
expect(user.last_seen).toBeDefined();
});
test('should use normalized ID in command service', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890:12@s.whatsapp.net' // Raw ID with participant
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
await WebhookServer.handleRequest(request);
// Verify that a response was queued, indicating command processing
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle end-to-end flow with valid user and command processing', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test task' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
// Verify user was created/updated
const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890');
expect(user).toBeDefined();
// Verify that a response was queued
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
});
describe('Group validation in handleMessageUpsert', () => {
test('should ignore messages from inactive groups', async () => {
// Insert inactive group
testDb.exec(`
INSERT OR REPLACE INTO groups (id, community_id, name, active)
VALUES ('inactive-group@g.us', 'test-community', 'Inactive Group', 0)
`);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'inactive-group@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
});
test('should proceed with messages from active groups', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/tarea nueva Test' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should accept /t alias and process command', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t n Tarea alias hoy' }
}
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should never send responses to the group (DM only policy)', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t n Probar silencio grupo mañana' }
}
};
const request = createTestRequest(payload);
await WebhookServer.handleRequest(request);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false);
}
});
});
describe('Advanced listings via WebhookServer', () => {
test('should process "/t ver sin" in group as DM-only with pagination line', async () => {
// 12 sin dueño en el grupo activo
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'group-id@g.us',
created_by: '9999999999',
});
}
// 2 asignadas (no deben aparecer en "sin")
TaskService.createTask({
description: 'Asignada 1',
due_date: '2025-10-10',
group_id: 'group-id@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
TaskService.createTask({
description: 'Asignada 2',
due_date: '2025-10-11',
group_id: 'group-id@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ver sin' }
}
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
for (const r of out) {
expect(r.recipient.endsWith('@g.us')).toBe(false);
}
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
});
test('should process "/t ver sin" in DM returning instruction', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: '1234567890@s.whatsapp.net', // DM
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ver sin' }
}
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No tienes tareas pendientes.');
});
test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => {
// Tus tareas (2 asignadas)
TaskService.createTask({
description: 'Mi Tarea 1',
due_date: '2025-10-10',
group_id: 'group-id@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
TaskService.createTask({
description: 'Mi Tarea 2',
due_date: '2025-10-11',
group_id: 'group-id@g.us',
created_by: '2222222222',
}, [{ user_id: '1234567890', assigned_by: '2222222222' }]);
// 12 sin dueño para provocar paginación
for (let i = 1; i <= 12; i++) {
TaskService.createTask({
description: `Sin dueño ${i}`,
due_date: '2025-12-31',
group_id: 'group-id@g.us',
created_by: '9999999999',
});
}
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ver todos' }
}
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
});
test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => {
TaskService.createTask({
description: 'Mi Tarea A',
due_date: '2025-11-20',
group_id: 'group-2@g.us',
created_by: '1111111111',
}, [{ user_id: '1234567890', assigned_by: '1111111111' }]);
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: '1234567890@s.whatsapp.net', // DM
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ver todos' }
}
};
const response = await WebhookServer.handleRequest(createTestRequest(payload));
expect(response.status).toBe(200);
const out = SimulatedResponseQueue.get();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('Tus tareas');
expect(msg).toContain(' Para ver tareas sin responsable');
});
});
});

@ -0,0 +1,149 @@
/**
* User validation integration tests.
*
* Verifies that WebhookServer.handleMessageUpsert correctly validates
* and persists user records, handles DB errors gracefully, and
* normalizes sender IDs before passing them to command services.
*/
import { describe, test, expect } from 'bun:test';
import { WebhookServer } from '../../src/server';
import { initializeDatabase } from '../../src/db';
import { SimulatedResponseQueue } from '../helpers/queue';
import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness';
const testDb = registerServerTestLifecycle();
// ── Tests ──────────────────────────────────────────────────────────────
describe('User validation in handleMessageUpsert', () => {
test('should proceed with valid user', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
expect(user).toBeDefined();
expect((user as any).id).toBe('1234567890');
});
test('should ignore message if user validation fails', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: 'invalid!user@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
const userCount = testDb.query('SELECT COUNT(*) as count FROM users').get();
expect((userCount as any).count).toBe(0);
});
test('should handle database errors during user validation', async () => {
testDb.exec('DROP TABLE users');
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBe(0);
// Reinitialize database for subsequent tests
testDb.exec('DROP TABLE IF EXISTS schema_migrations');
initializeDatabase(testDb);
});
test('should integrate user validation completely in handleMessageUpsert with valid user', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
expect(user).toBeDefined();
expect((user as any).id).toBe('1234567890');
expect((user as any).first_seen).toBeDefined();
expect((user as any).last_seen).toBeDefined();
});
test('should use normalized ID in command service', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890:12@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test' },
},
};
const request = createTestRequest(payload);
await WebhookServer.handleRequest(request);
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
test('should handle end-to-end flow with valid user and command processing', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'group-id@g.us',
participant: '1234567890@s.whatsapp.net',
},
message: { conversation: 'tarea nueva Test task' },
},
};
const request = createTestRequest(payload);
const response = await WebhookServer.handleRequest(request);
expect(response.status).toBe(200);
const user = testDb.query('SELECT * FROM users WHERE id = ?').get('1234567890');
expect(user).toBeDefined();
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save