Compare commits

..

No commits in common. 'bd4f0cc364f75963145fdbf6f4e348d76073ec59' and 'ab832e208bcb324be0f77d101cc5fd215007e812' have entirely different histories.

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

@ -490,19 +490,5 @@ export const migrations: Migration[] = [
}
} 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,7 +10,6 @@ import { getQuickHelp, getFullHelp } from './messages/help';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
import { ResponseQueue } from './response-queue';
import { randomTokenBase64Url, sha256Hex } from '../utils/crypto';
type CommandContext = {
@ -37,7 +36,6 @@ export type CommandOutcome = {
export class CommandService {
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[]): {
action: string;
@ -177,10 +175,6 @@ export class CommandService {
'mostrar': 'ver',
'listar': 'ver',
'ls': 'ver',
'mias': 'ver',
'mías': 'ver',
'todas': 'ver',
'todos': 'ver',
'x': 'completar',
'hecho': 'completar',
'completar': 'completar',
@ -196,24 +190,11 @@ export class CommandService {
'renunciar': 'soltar',
'ayuda': 'ayuda',
'help': 'ayuda',
'info': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar'
};
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
@ -296,7 +277,7 @@ export class CommandService {
// Listar pendientes
if (action === 'ver') {
const scopeRaw = (tokens[2] || '').toLowerCase();
const SCOPE_ALIASES: Record<string, 'mis' | 'todos'> = {
const SCOPE_ALIASES: Record<string, 'grupo' | 'mis' | 'todos' | 'sin'> = {
'todo': 'todos',
'todos': 'todos',
'todas': 'todos',
@ -305,21 +286,50 @@ export class CommandService {
'mías': 'mis',
'yo': 'mis',
};
const scope = scopeRaw
? (SCOPE_ALIASES[scopeRaw] || scopeRaw)
: ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos'));
const scope = scopeRaw ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) : (isGroupId(context.groupId) ? 'grupo' : 'mis');
const LIMIT = 10;
// En grupos: no listamos; responder por DM con transición
if (isGroupId(context.groupId)) {
try { Metrics.inc('ver_dm_transition_total'); } catch {}
// Ver sin dueño del grupo actual
if (scope === 'sin') {
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._'
}];
}
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 [{
recipient: context.sender,
message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
message: [`${groupName} — Sin responsable`, ...rendered].join('\n')
}];
}
// Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca)
if (scope === 'todos') {
const sections: string[] = [];
@ -428,7 +438,7 @@ export class CommandService {
}
} else {
// Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima
sections.push(' Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.');
sections.push(' Para ver tareas sin responsable de un grupo, usa `/t ver sin` desde ese grupo.');
}
}
@ -438,6 +448,63 @@ 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
const items = TaskService.listUserPending(context.sender, LIMIT);
@ -1146,15 +1213,6 @@ export class CommandService {
);
// 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 {
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
const participant = typeof context.participant === 'string' ? context.participant : null;
@ -1204,7 +1262,7 @@ export class CommandService {
].filter(Boolean);
responses.push({
recipient: createdBy,
message: [ackLines.join('\n'), '', CommandService.CTA_HELP].join('\n'),
message: ackLines.join('\n'),
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
});
@ -1220,7 +1278,7 @@ export class CommandService {
groupName ? `Grupo: ${groupName}` : null,
`- Completar: \`/t x ${createdTask?.display_code}\``,
`- Soltar: \`/t soltar ${createdTask?.display_code}\``
].filter(Boolean).join('\n') + '\n\n' + CommandService.CTA_HELP,
].filter(Boolean).join('\n'),
mentions: [creatorJid]
});
}
@ -1258,122 +1316,6 @@ 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;
}
@ -1388,16 +1330,6 @@ Nota: nunca respondo en grupos; solo por privado.`;
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)
if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch { }
@ -1429,10 +1361,6 @@ Nota: nunca respondo en grupos; solo por privado.`;
'mostrar': 'ver',
'listar': 'ver',
'ls': 'ver',
'mias': 'ver',
'mías': 'ver',
'todas': 'ver',
'todos': 'ver',
'x': 'completar',
'hecho': 'completar',
'completar': 'completar',
@ -1448,7 +1376,6 @@ Nota: nunca respondo en grupos; solo por privado.`;
'renunciar': 'soltar',
'ayuda': 'ayuda',
'help': 'ayuda',
'info': 'ayuda',
'?': 'ayuda',
'config': 'configurar',
'configurar': 'configurar',

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

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

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

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

@ -854,7 +854,8 @@ describe('WebhookServer', () => {
expect(r.recipient.endsWith('@g.us')).toBe(false);
}
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('🙅');
expect(msg).toContain('… y 2 más');
});
test('should process "/t ver sin" in DM returning instruction', async () => {
@ -875,7 +876,7 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No tienes tareas pendientes.');
expect(msg).toContain('Este comando se usa en grupos');
});
test('should process "/t ver todos" in group showing "Tus tareas" + "Sin dueño (grupo actual)" with pagination in unassigned section', async () => {
@ -921,7 +922,9 @@ describe('WebhookServer', () => {
const out = SimulatedResponseQueue.getQueue();
expect(out.length).toBeGreaterThan(0);
const msg = out.map(x => x.message).join('\n');
expect(msg).toContain('No respondo en grupos.');
expect(msg).toContain('Tus tareas');
expect(msg).toContain('🙅');
expect(msg).toContain('… y 2 más');
});
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);
const msg = res[0].message;
expect(msg).toContain('/t mias');
expect(msg).toContain('/t ver mis');
expect(msg).toContain('/t web');
expect(msg).toContain('Ayuda avanzada');
expect(msg).toContain('/t ayuda avanzada');
@ -32,10 +32,11 @@ describe('CommandService - /t ayuda y /t ayuda avanzada usando help centralizado
const msg = res[0].message;
// Scopes de ver
expect(msg).toContain('/t mias');
expect(msg).toContain('/t todas');
expect(msg).toContain('/t ver sin');
expect(msg).toContain('/t ver grupo');
expect(msg).toContain('/t ver todas');
// Formatos de fecha
expect(msg).toContain('27-09-04');
expect(msg).toContain('YY-MM-DD');
// Configurar etiquetas en español
expect(msg).toContain('diario|l-v|semanal|off');
});

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

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

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

Loading…
Cancel
Save