Compare commits

..

29 Commits

Author SHA1 Message Date
brobert 28147446a1 fix: métricas de unknown/alias y chequeo de tabla users para onboarding
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert fc0eddf8b1 feat: agrega atajos '/t mias' y '/t todas' a la ayuda
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 0df224f0ba feat: agregar ayuda rápida y fallback para comandos desconocidos
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 4b493521ab chore: limpieza incremental de command.ts en bloques pequeños
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert e2f152fd9e fix: retornar [] en processTareaCommand y usar [] como fallback de rutas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert fa27be673d fix: evita continuar en soltar devolviendo []
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 96daf2a643 fix: agregar retorno temprano en completar y tomar
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 24962d33ff fix: devuelve lista vacía al usar ver
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert d550d5b26a chore: eliminar soporte de configurar y web en el comando
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 1356a2d1d7 refactor: reemplaza verificación de membresía por enforceMembership
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 6daa27f4ad refactor: usar parseMultipleIds para parsear IDs múltiples
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 47a7def7c1 refactor: usar resolveTaskIdFromInput externo y quitar método
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 6f88d5bc2e refactor: usar todayYMD_TZ desde shared en CommandService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert a89ec3f875 refactor: centralizar ACTION_ALIASES y SCOPE_ALIASES en commands/shared
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 7dd390b04e chore: quitar import innecesario de normalizeWhatsAppId en command.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert f2746a9003 refactor: mover CTA_HELP a shared.ts y usarlo en nueva
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 65553a14d9 refactor: eliminar import de GroupSyncService en completar/tomar/soltar
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 91fe688e4e feat: mover lógica de nueva a handler dedicado y añadir onboarding
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert ada071d220 feat: añadir handlers completar/tomar/soltar y enrutar comandos
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 5c6cac2b12 fix: corregir ruta de import de TaskService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert b719f3fd33 feat: añadir handler ver y enrutar /t ver con métricas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 6fcfd2719f feat: integrar router etapa 3 con handlers configurar/web y db
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert d591697402 feat: extraer parseNueva a módulo dedicado y usarlo desde CommandService
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert f7229d14d4 docs: añade onboarding_assign_failures_total
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 170859c030 feat: añadir router de comandos (Etapa 1) y shared.ts; usar route
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert d40e5e7990 docs: añadir golden de textos y métricas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert f142975f00 docs: añade plan de refactor de CommandService (enfoque A)
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert e9c2885433 fix: limpiar puntuación en tokens @ y añadir tests de autoasignación
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert b6aab7fa1b feat: permitir autoasignación con yo/@yo en /t nueva y añadir tests
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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