Compare commits

...

14 Commits

Author SHA1 Message Date
brobert bd4f0cc364 feat: añadir métricas de onboarding y alias; recalcular tras comandos
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 2d05b6f3e1 feat: centralizar CTA de ayuda en command.ts y añadir a DMs
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 2d7620fa5d quita una chorrada que ha puesto gpt 4 days ago
brobert 65dc4d10ae docs: completar fases 0-2 y actualizar onboarding (5–10s, <14 dígitos)
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert a2fed2277c feat: actualizar last_command_at al detectar cualquier /t
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 1fafc431ff feat: agregar columna last_command_at en users en migración v19
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 394f28e5cc feat: onboarding en /t nueva filtrando IDs <14 y migrar last_command_at
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert d4a998cdf1 fix: adaptar respuestas de listing a 'No respondo en grupos.'
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 4ea186dff1 fix: actualizar textos de ayuda y respuestas de comandos al formato
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 7e135fcf50 refactor: elimina ver sin y ver grupo; actualiza aliases y ayuda
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 90265939c6 feat: actualiza alias y ayuda: /t ver -> todas; anade /t mias /t info
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 7bc7000c8c docs: actualizar plan de onboarding con paquetes de 2 DMs y recordatorio
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
4 days ago
brobert 7871d39e0a plan para onboarding fino 4 days ago
brobert ec02444c2a docs: añade plan por fases de onboarding por DM y alias de comandos
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
5 days ago

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

@ -490,5 +490,19 @@ export const migrations: Migration[] = [
} }
} catch {} } catch {}
} }
},
{
version: 19,
name: 'users-last-command-at',
checksum: 'v19-users-last-command-at-2025-10-25',
up: (db: Database) => {
try {
const cols = db.query(`PRAGMA table_info(users)`).all() as any[];
const hasCol = Array.isArray(cols) && cols.some((c: any) => String(c.name) === 'last_command_at');
if (!hasCol) {
db.exec(`ALTER TABLE users ADD COLUMN last_command_at TEXT NULL;`);
}
} catch {}
}
} }
]; ];

@ -10,6 +10,7 @@ import { getQuickHelp, getFullHelp } from './messages/help';
import { IdentityService } from './identity'; import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { ResponseQueue } from './response-queue';
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto'; import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
type CommandContext = { type CommandContext = {
@ -36,6 +37,7 @@ export type CommandOutcome = {
export class CommandService { export class CommandService {
static dbInstance: Database = db; static dbInstance: Database = db;
private static readonly CTA_HELP: string = ' Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web';
private static parseNueva(message: string, mentionsNormalized: string[]): { private static parseNueva(message: string, mentionsNormalized: string[]): {
action: string; action: string;
@ -175,6 +177,10 @@ export class CommandService {
'mostrar': 'ver', 'mostrar': 'ver',
'listar': 'ver', 'listar': 'ver',
'ls': 'ver', 'ls': 'ver',
'mias': 'ver',
'mías': 'ver',
'todas': 'ver',
'todos': 'ver',
'x': 'completar', 'x': 'completar',
'hecho': 'completar', 'hecho': 'completar',
'completar': 'completar', 'completar': 'completar',
@ -190,11 +196,24 @@ export class CommandService {
'renunciar': 'soltar', 'renunciar': 'soltar',
'ayuda': 'ayuda', 'ayuda': 'ayuda',
'help': 'ayuda', 'help': 'ayuda',
'info': 'ayuda',
'?': 'ayuda', '?': 'ayuda',
'config': 'configurar', 'config': 'configurar',
'configurar': 'configurar' 'configurar': 'configurar'
}; };
const action = ACTION_ALIASES[rawAction] || rawAction; const action = ACTION_ALIASES[rawAction] || rawAction;
// Métrica: uso de alias (info/mias/todas)
try {
if (rawAction === 'info') {
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
} else 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 {}
// Refrescar métricas agregadas de onboarding tras cualquier comando (para conversión)
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
// Usar formatDDMM desde utils/formatting // Usar formatDDMM desde utils/formatting
@ -277,7 +296,7 @@ export class CommandService {
// Listar pendientes // Listar pendientes
if (action === 'ver') { if (action === 'ver') {
const scopeRaw = (tokens[2] || '').toLowerCase(); const scopeRaw = (tokens[2] || '').toLowerCase();
const SCOPE_ALIASES: Record<string, 'grupo' | 'mis' | 'todos' | 'sin'> = { const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
'todo': 'todos', 'todo': 'todos',
'todos': 'todos', 'todos': 'todos',
'todas': 'todos', 'todas': 'todos',
@ -286,50 +305,21 @@ export class CommandService {
'mías': 'mis', 'mías': 'mis',
'yo': 'mis', 'yo': 'mis',
}; };
const scope = scopeRaw ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) : (isGroupId(context.groupId) ? 'grupo' : 'mis'); const scope = scopeRaw
? (SCOPE_ALIASES[scopeRaw] || scopeRaw)
: ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos'));
const LIMIT = 10; const LIMIT = 10;
// Ver sin dueño del grupo actual // En grupos: no listamos; responder por DM con transición
if (scope === 'sin') { if (isGroupId(context.groupId)) {
if (!isGroupId(context.groupId)) { try { Metrics.inc('ver_dm_transition_total'); } catch {}
return [{
recipient: context.sender,
message: ' _Este comando se usa en grupos. Prueba:_ `/t ver mis`'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
return [{
recipient: context.sender,
message: '⚠️ _Este grupo no está activo._'
}];
}
const items = TaskService.listGroupUnassigned(context.groupId, LIMIT);
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
if (items.length === 0) {
return [{
recipient: context.sender,
message: `_No hay tareas sin responsable en ${groupName}._`
}];
}
const rendered = items.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}`;
});
const total = TaskService.countGroupUnassigned(context.groupId);
if (total > items.length) {
rendered.push(`… y ${total - items.length} más`);
}
return [{ return [{
recipient: context.sender, recipient: context.sender,
message: [`${groupName} — Sin responsable`, ...rendered].join('\n') message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
}]; }];
} }
// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca) // Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
if (scope === 'todos') { if (scope === 'todos') {
const sections: string[] = []; const sections: string[] = [];
@ -438,7 +428,7 @@ export class CommandService {
} }
} else { } else {
// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima // Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
sections.push(' Para ver tareas sin responsable de un grupo, usa `/t ver sin` desde ese grupo.'); sections.push(' Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.');
} }
} }
@ -448,63 +438,6 @@ export class CommandService {
}]; }];
} }
// Ver grupo
if (scope === 'grupo') {
if (!isGroupId(context.groupId)) {
return [{
recipient: context.sender,
message: ' _Este comando se usa en grupos. Prueba:_ `/t ver mis`'
}];
}
if (!GroupSyncService.isGroupActive(context.groupId)) {
return [{
recipient: context.sender,
message: '⚠️ _Este grupo no está activo._'
}];
}
// Enforcement opcional basado en membresía si la snapshot es fresca
const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true';
const fresh = GroupSyncService.isSnapshotFresh(context.groupId);
if (enforce && fresh && !GroupSyncService.isUserActiveInGroup(context.sender, context.groupId)) {
return [{
recipient: context.sender,
message: 'No puedes ver las tareas de este grupo. Pide que te añadan si crees que es un error.'
}];
}
const items = TaskService.listGroupPending(context.groupId, LIMIT);
const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId;
if (items.length === 0) {
return [{
recipient: context.sender,
message: italic(`No hay pendientes en ${groupName}.`)
}];
}
const rendered = await Promise.all(items.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 < 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}`;
}));
const total = TaskService.countGroupPending(context.groupId);
if (total > items.length) {
rendered.push(`… y ${total - items.length} más`);
}
return [{
recipient: context.sender,
message: [groupName, ...rendered].join('\n')
}];
}
// Ver mis // Ver mis
const items = TaskService.listUserPending(context.sender, LIMIT); const items = TaskService.listUserPending(context.sender, LIMIT);
@ -1213,6 +1146,15 @@ export class CommandService {
); );
// Registrar origen del comando para esta tarea (Fase 1) // Registrar origen del comando para esta tarea (Fase 1)
// Registrar interacción del usuario (last_command_at)
try {
const ensured = ensureUserExists(context.sender, this.dbInstance);
if (ensured) {
try {
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
} catch {}
}
} catch {}
try { try {
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) { if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
const participant = typeof context.participant === 'string' ? context.participant : null; const participant = typeof context.participant === 'string' ? context.participant : null;
@ -1262,7 +1204,7 @@ export class CommandService {
].filter(Boolean); ].filter(Boolean);
responses.push({ responses.push({
recipient: createdBy, recipient: createdBy,
message: ackLines.join('\n'), message: [ackLines.join('\n'), '', CommandService.CTA_HELP].join('\n'),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
}); });
@ -1278,7 +1220,7 @@ export class CommandService {
groupName ? `Grupo: ${groupName}` : null, groupName ? `Grupo: ${groupName}` : null,
`- Completar: \`/t x ${createdTask?.display_code}\``, `- Completar: \`/t x ${createdTask?.display_code}\``,
`- Soltar: \`/t soltar ${createdTask?.display_code}\`` `- Soltar: \`/t soltar ${createdTask?.display_code}\``
].filter(Boolean).join('\n'), ].filter(Boolean).join('\n') + '\n\n' + CommandService.CTA_HELP,
mentions: [creatorJid] mentions: [creatorJid]
}); });
} }
@ -1316,6 +1258,122 @@ export class CommandService {
} }
} }
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
try {
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 = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
if (!enabled) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {}
} else if (!gid) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: String(context.groupId || '') }); } catch {}
} else {
// Gating enforce
let allowed = true;
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
allowed = AllowedGroups.isAllowed(gid);
}
} catch {}
if (!allowed) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {}
} else {
const displayCode = createdTask?.display_code;
if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) {
try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {}
} else {
// Candidatos
let members = GroupSyncService.listActiveMemberIds(gid);
const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
const exclude = new Set<string>([createdBy, ...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 {}
} else {
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 por defecto
const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid;
const codeStr = String(displayCode);
const desc = (description || '(sin descripción)').trim();
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
const msg1 = `Hola, soy el bot de tareas. En ${groupLabel} acaban de crear una tarea: #${codeStr} ${shortDesc}
Encárgate: /t tomar ${codeStr} · Más info: /t info
Nota: nunca respondo en grupos; solo por privado.`;
const msg2 = `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`;
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 = this.dbInstance.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: taskId, display_code: displayCode }, 0);
ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, delay2);
try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {}
} catch {}
}
}
}
}
}
} catch {}
return responses; return responses;
} }
@ -1330,6 +1388,16 @@ export class CommandService {
return { responses: [], ok: true }; return { responses: [], ok: true };
} }
// Registrar interacción del usuario (last_command_at) para cualquier comando /t …
try {
const ensured = ensureUserExists(context.sender, this.dbInstance);
if (ensured) {
try {
this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured);
} catch {}
}
} catch {}
// Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente) // Gating de grupos en modo 'enforce' (cuando CommandService se invoca directamente)
if (isGroupId(context.groupId)) { if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { } try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
@ -1361,6 +1429,10 @@ export class CommandService {
'mostrar': 'ver', 'mostrar': 'ver',
'listar': 'ver', 'listar': 'ver',
'ls': 'ver', 'ls': 'ver',
'mias': 'ver',
'mías': 'ver',
'todas': 'ver',
'todos': 'ver',
'x': 'completar', 'x': 'completar',
'hecho': 'completar', 'hecho': 'completar',
'completar': 'completar', 'completar': 'completar',
@ -1376,6 +1448,7 @@ export class CommandService {
'renunciar': 'soltar', 'renunciar': 'soltar',
'ayuda': 'ayuda', 'ayuda': 'ayuda',
'help': 'ayuda', 'help': 'ayuda',
'info': 'ayuda',
'?': 'ayuda', '?': 'ayuda',
'config': 'configurar', 'config': 'configurar',
'configurar': 'configurar', 'configurar': 'configurar',

@ -1279,4 +1279,29 @@ export class GroupSyncService {
return { added: 0, updated: 0, deactivated: 0 }; return { added: 0, updated: 0, deactivated: 0 };
} }
} }
/**
* Devuelve los IDs de usuario activos del grupo, filtrados a dígitos puros con longitud < 14.
* No devuelve duplicados.
*/
public static listActiveMemberIds(groupId: string): string[] {
if (!groupId) return [];
try {
const rows = this.dbInstance.prepare(`
SELECT user_id
FROM group_members
WHERE group_id = ? AND is_active = 1
`).all(groupId) as Array<{ user_id: string }>;
const out = new Set<string>();
for (const r of rows) {
const uid = String(r.user_id || '').trim();
if (/^\d+$/.test(uid) && uid.length < 14) {
out.add(uid);
}
}
return Array.from(out);
} catch {
return [];
}
}
} }

@ -11,11 +11,12 @@ export function getQuickHelp(baseUrl?: string): string {
parts.push( parts.push(
bullets([ bullets([
`Crear: ${code('/t n Descripción 27-11-14 @Ana')}`, `Crear: ${code('/t n Descripción 27-11-14 @Ana')}`,
`Ver mis tareas: ${code('/t ver mis')} _por privado_`, `Ver mis: ${code('/t mias')} _por privado_`,
`Ver todas: ${code('/t ver todas')} _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)_`, `Completar: ${code('/t x 26')} _(máx. 10 a la vez)_`,
`Tomar: ${code('/t tomar 12')} _(max. 10 a la vez)_`, `Tomar: ${code('/t tomar 12')} _(máx. 10 a la vez)_`,
`Soltar: ${code('/t soltar 26')}_(max. 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_`, `Recordatorios: ${code('/t configurar diario|l-v|semanal|off [HH:MM]')} _por privado_`,
`Versión web: ${code('/t web')}`, `Versión web: ${code('/t web')}`,
]) ])
@ -47,12 +48,9 @@ export function getFullHelp(baseUrl?: string): string {
out.push(section('Listados')); out.push(section('Listados'));
out.push( out.push(
bullets([ bullets([
`${code('/t ver grupo')} pendientes del grupo actual (desde grupo activo).`, `${code('/t mias')} tus pendientes (por privado).`,
`${code('/t ver mis')} tus pendientes (por privado).`, `${code('/t todas')} tus pendientes + “sin responsable”.`,
`${code('/t ver todas')} tus pendientes + “sin responsable”.`, 'Nota: no respondo en grupos; usa estos comandos por privado.',
'En grupo: “sin responsable” solo del grupo actual.',
'En privado: “sin responsable” de tus grupos.',
`${code('/t ver sin')} solo “sin responsable” del grupo actual (desde grupo).`,
'Máx. 10 elementos por sección; se añade “… y N más” si hay más.', 'Máx. 10 elementos por sección; se añade “… y N más” si hay más.',
'Fechas en DD/MM y ⚠️ si están vencidas.', 'Fechas en DD/MM y ⚠️ si están vencidas.',
]) ])

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

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

@ -854,8 +854,7 @@ describe('WebhookServer', () => {
expect(r.recipient.endsWith('@g.us')).toBe(false); expect(r.recipient.endsWith('@g.us')).toBe(false);
} }
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('🙅'); expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('… y 2 más');
}); });
test('should process "/t ver sin" in DM returning instruction', async () => { test('should process "/t ver sin" in DM returning instruction', async () => {
@ -876,7 +875,7 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('Este comando se usa en grupos'); 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 () => { test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => {
@ -922,9 +921,7 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue(); const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0); expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n'); const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('Tus tareas'); expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('🙅');
expect(msg).toContain('… y 2 más');
}); });
test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => { test('should process "/t ver todos" in DM showing "Tus tareas" + instructive note', async () => {

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

@ -49,11 +49,7 @@ test('listar grupo por defecto con /t ver en grupo e incluir “… y X más”'
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('Test Group'); expect(responses[0].message).toContain('No respondo en grupos.');
// Debe indicar que hay 2 más (límite 10)
expect(responses[0].message).toContain('… y 2 más');
// Debe mostrar “sin responsable”
expect(responses[0].message).toContain('🙅');
}); });
test('listar “mis” por defecto en DM con /t ver', async () => { test('listar “mis” por defecto en DM con /t ver', async () => {
@ -193,11 +189,7 @@ test('ver sin en grupo activo: solo sin dueño y paginación', async () => {
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
const msg = responses[0].message; const msg = responses[0].message;
expect(msg).toContain('Test Group'); expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('🙅');
expect(msg).toContain('… y 2 más');
expect(msg).not.toContain('Asignada 1');
expect(msg).not.toContain('Asignada 2');
}); });
test('ver sin por DM devuelve instrucción', async () => { test('ver sin por DM devuelve instrucción', async () => {
@ -211,7 +203,7 @@ test('ver sin por DM devuelve instrucción', async () => {
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
expect(responses[0].recipient).toBe('1234567890'); expect(responses[0].recipient).toBe('1234567890');
expect(responses[0].message).toContain('Este comando se usa en grupos'); expect(responses[0].message).toContain('No tienes tareas pendientes.');
}); });
test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con paginación en la sección sin dueño', async () => { test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con paginación en la sección sin dueño', async () => {
@ -257,10 +249,7 @@ test('ver todos en grupo: “Tus tareas” + “Sin dueño (grupo actual)” con
expect(responses.length).toBe(1); expect(responses.length).toBe(1);
const msg = responses[0].message; const msg = responses[0].message;
expect(msg).toContain('Tus tareas'); expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('Test Group');
expect(msg).toContain('🙅');
expect(msg).toContain('… y 2 más'); // paginación en la sección “sin dueño”
}); });
test('ver todos por DM: “Tus tareas” + nota instructiva para ver sin dueño desde el grupo', async () => { test('ver todos por DM: “Tus tareas” + nota instructiva para ver sin dueño desde el grupo', async () => {

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

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

Loading…
Cancel
Save