import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; import { TaskService } from '../tasks/service'; import { GroupSyncService } from './group-sync'; import { ContactsService } from './contacts'; import { ICONS } from '../utils/icons'; import { padTaskId, codeId, formatDDMM, bold, italic, code, section } from '../utils/formatting'; 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 = { sender: string; // normalized user id (digits only), but accept raw too groupId: string; // full JID (e.g., xxx@g.us) message: string; // raw message text mentions: string[]; // array of raw JIDs mentioned messageId?: string; // id del mensaje origen (para task_origins y reacciones) participant?: string; // JID del autor del mensaje origen (en grupos) fromMe?: boolean; // si el mensaje origen fue enviado por la instancia }; export type CommandResponse = { recipient: string; message: string; mentions?: string[]; // full JIDs to mention in the outgoing message }; export type CommandOutcome = { responses: CommandResponse[]; ok: boolean; createdTaskIds?: number[]; }; 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; description: string; dueDate: string | null; } { const parts = (message || '').trim().split(/\s+/); const action = (parts[1] || '').toLowerCase(); // Zona horaria configurable (por defecto Europe/Madrid) const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; // Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host const ymdFromDateInTZ = (d: Date): string => { const fmt = new Intl.DateTimeFormat('es-ES', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(d); const get = (t: string) => fmt.find(p => p.type === t)?.value || ''; return `${get('year')}-${get('month')}-${get('day')}`; }; const addDaysToYMD = (ymd: string, days: number): string => { const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10)); const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1)); base.setUTCDate(base.getUTCDate() + days); return ymdFromDateInTZ(base); }; const todayYMD = ymdFromDateInTZ(new Date()); // Helpers para validar y normalizar fechas explícitas const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0); const daysInMonth = (y: number, m: number) => { if (m === 2) return isLeap(y) ? 29 : 28; return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]; }; const isValidYMD = (ymd: string): boolean => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); if (!m) return false; const Y = parseInt(m[1], 10); const MM = parseInt(m[2], 10); const DD = parseInt(m[3], 10); if (MM < 1 || MM > 12) return false; const dim = daysInMonth(Y, MM); if (!dim || DD < 1 || DD > dim) return false; return true; }; const normalizeDateToken = (t: string): string | null => { // YYYY-MM-DD if (/^\d{4}-\d{2}-\d{2}$/.test(t)) { return isValidYMD(t) ? t : null; } // YY-MM-DD -> 20YY-MM-DD const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t); if (m) { const yy = parseInt(m[1], 10); const mm = m[2]; const dd = m[3]; const yyyy = 2000 + yy; const ymd = `${String(yyyy)}-${mm}-${dd}`; return isValidYMD(ymd) ? ymd : null; } return null; }; type DateCandidate = { index: number; ymd: string }; const dateCandidates: DateCandidate[] = []; const dateTokenIndexes = new Set(); for (let i = 2; i < parts.length; i++) { // Normalizar token: minúsculas y sin puntuación adyacente simple const raw = parts[i]; const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, ''); // Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY) { const norm = normalizeDateToken(low); if (norm && norm >= todayYMD) { dateCandidates.push({ index: i, ymd: norm }); dateTokenIndexes.add(i); continue; } } // Tokens naturales "hoy"/"mañana" (con o sin acento) if (low === 'hoy') { dateCandidates.push({ index: i, ymd: todayYMD }); dateTokenIndexes.add(i); continue; } if (low === 'mañana' || low === 'manana') { dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) }); dateTokenIndexes.add(i); continue; } } const dueDate = dateCandidates.length > 0 ? dateCandidates[dateCandidates.length - 1].ymd : null; const isMentionToken = (token: string) => token.startsWith('@'); const descriptionTokens: string[] = []; for (let i = 2; i < parts.length; i++) { if (dateTokenIndexes.has(i)) continue; const token = parts[i]; if (isMentionToken(token)) continue; descriptionTokens.push(token); } const description = descriptionTokens.join(' ').trim(); return { action, description, dueDate }; } private static resolveTaskIdFromInput(n: number): number | null { const byCode = TaskService.getActiveTaskByDisplayCode(n); return byCode ? byCode.id : null; } private static async processTareaCommand( context: CommandContext ): Promise { const trimmed = (context.message || '').trim(); const tokens = trimmed.split(/\s+/); const rawAction = (tokens[1] || '').toLowerCase(); const ACTION_ALIASES: Record = { 'n': 'nueva', 'nueva': 'nueva', 'crear': 'nueva', '+': 'nueva', 'ver': 'ver', 'mostrar': 'ver', 'listar': 'ver', 'ls': 'ver', 'mias': 'ver', 'mías': 'ver', 'todas': 'ver', 'todos': 'ver', 'x': 'completar', 'hecho': 'completar', 'completar': 'completar', 'done': 'completar', 'tomar': 'tomar', 'claim': 'tomar', 'asumir': 'tomar', 'asumo': 'tomar', 'soltar': 'soltar', 'unassign': 'soltar', 'dejar': 'soltar', 'liberar': 'soltar', 'renunciar': 'soltar', 'ayuda': 'ayuda', 'help': 'ayuda', 'info': 'ayuda', '?': 'ayuda', 'config': 'configurar', 'configurar': 'configurar' }; 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 // TZ y "hoy" en TZ para marcar vencidas en listados const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid'; const ymdInTZ = (d: Date): string => { const parts = new Intl.DateTimeFormat('en-GB', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(d); const get = (t: string) => parts.find(p => p.type === t)?.value || ''; return `${get('year')}-${get('month')}-${get('day')}`; }; const todayYMD = ymdInTZ(new Date()); if (!action || action === 'ayuda') { const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase(); const helpV2Enabled = !['false', '0', 'no'].includes(feature); const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada'; // Fallback legacy (Help v1) if (!helpV2Enabled) { if (isAdvanced) { const adv = [ '*Ayuda avanzada:*', 'Comandos y alias:', ' · Crear: `n`, `nueva`, `crear`, `+`', ' · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)', ' · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)', ' · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)', ' · Soltar: `soltar`, `unassign`', 'Preferencias:', ' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)', 'Notas:', ' · En grupos, el bot responde por DM (no publica en el grupo).', ' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.', ' · Fechas dd/MM con ⚠️ si está vencida.', ' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).', ].join('\n'); return [{ recipient: context.sender, message: adv }]; } const help = [ 'Guía rápida:', '- Crear: `/t n Descripción 2028-11-26 @Ana`', '- Ver grupo: `/t ver` (en el grupo)', '- Ver mis tareas: `/t ver mis` (por DM)', '- Ver todas: `/t ver todas` (por DM)', '- Completar: `/t x 123` (máx. 10)', '- Tomar: `/t tomar 12` (máx. 10)', '- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`', '- Ayuda avanzada: `/t ayuda avanzada`' ].join('\n'); return [{ recipient: context.sender, message: help }]; } // Help v2 if (isAdvanced) { return [{ recipient: context.sender, message: getFullHelp() }]; } const quick = getQuickHelp(); const msg = [quick, '', `Ayuda avanzada: ${code('/t ayuda avanzada')}`].join('\n'); return [{ recipient: context.sender, message: msg }]; } // Listar pendientes if (action === 'ver') { const scopeRaw = (tokens[2] || '').toLowerCase(); const SCOPE_ALIASES: Record = { 'todo': 'todos', 'todos': 'todos', 'todas': 'todos', 'mis': 'mis', 'mias': 'mis', '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 LIMIT = 10; // En grupos: no listamos; responder por DM con transición if (isGroupId(context.groupId)) { try { Metrics.inc('ver_dm_transition_total'); } catch {} return [{ recipient: context.sender, message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web' }]; } // Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca) if (scope === 'todos') { const sections: string[] = []; // Encabezado fijo para la sección de tareas del usuario sections.push(bold('Tus tareas')); // Tus tareas (mis) const myItems = TaskService.listUserPending(context.sender, LIMIT); if (myItems.length > 0) { // Agrupar por grupo como en "ver mis" const byGroup = new Map(); for (const t of myItems) { const key = t.group_id || '(sin grupo)'; const arr = byGroup.get(key) || []; arr.push(t); byGroup.set(key, arr); } for (const [groupId, arr] of byGroup.entries()) { const groupName = (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); sections.push(groupName); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) ); const owner = (t.assignees?.length || 0) === 0 ? `${ICONS.unassigned} sin responsable` : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); sections.push(''); } // Quitar línea en blanco final si procede if (sections.length > 0 && sections[sections.length - 1] === '') { sections.pop(); } const totalMy = TaskService.countUserPending(context.sender); if (totalMy > myItems.length) { sections.push(`… y ${totalMy - myItems.length} más`); } } else { sections.push(italic('_No tienes tareas pendientes._')); } // En contexto de grupo: mantener compatibilidad mostrando solo "sin responsable" del grupo actual if (isGroupId(context.groupId)) { if (!GroupSyncService.isGroupActive(context.groupId)) { sections.push('⚠️ _Este grupo no está activo._'); } else { const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT); if (unassigned.length > 0) { if (sections.length && sections[sections.length - 1] !== '') sections.push(''); sections.push(`${groupName} — Sin responsable`); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < 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}`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(context.groupId); if (totalUnassigned > unassigned.length) { sections.push(`… y ${totalUnassigned - unassigned.length} más`); } } else { sections.push(`${groupName} — Sin responsable\n_(no hay tareas sin responsable)_`); } } } else { // En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender); if (memberGroups.length > 0) { const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); for (const gid of perGroup.keys()) { const unassigned = perGroup.get(gid)!; const groupName = (gid && GroupSyncService.activeGroupsCache.get(gid)) || gid; if (unassigned.length > 0) { if (sections.length && sections[sections.length - 1] !== '') sections.push(''); sections.push(`${groupName} — Sin responsable`); const renderedUnassigned = unassigned.map((t) => { const isOverdue = t.due_date ? t.due_date < 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}`; }); sections.push(...renderedUnassigned); const totalUnassigned = TaskService.countGroupUnassigned(gid); if (totalUnassigned > unassigned.length) { sections.push(`… y ${totalUnassigned - unassigned.length} más`); } } } } else { // Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.'); } } return [{ recipient: context.sender, message: sections.join('\n') }]; } // Ver mis const items = TaskService.listUserPending(context.sender, LIMIT); if (items.length === 0) { return [{ recipient: context.sender, message: italic('No tienes tareas pendientes.') }]; } const total = TaskService.countUserPending(context.sender); // Agrupar por grupo const byGroup = new Map(); for (const t of items) { const key = t.group_id || '(sin grupo)'; const arr = byGroup.get(key) || []; arr.push(t); byGroup.set(key, arr); } const sections: string[] = [bold('Tus tareas')]; for (const [groupId, arr] of byGroup.entries()) { const groupName = (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); sections.push(groupName); const rendered = await Promise.all(arr.map(async (t) => { const names = await Promise.all( (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) ); const owner = (t.assignees?.length || 0) === 0 ? `${ICONS.unassigned}` : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; const isOverdue = t.due_date ? t.due_date < todayYMD : false; const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; })); sections.push(...rendered); sections.push(''); } // Quitar línea en blanco final si procede if (sections.length > 0 && sections[sections.length - 1] === '') { sections.pop(); } if (total > items.length) { sections.push(`… y ${total - items.length} más`); } return [{ recipient: context.sender, message: sections.join('\n') }]; } // Completar tarea (con validación opcional de membresía) if (action === 'completar') { // Soportar múltiples IDs separados por espacios y/o comas const rawIds = (tokens.slice(2).join(' ') || '').trim(); const parsedList = Array.from(new Set( rawIds .split(/[,\s]+/) .map(t => t.trim()) .filter(Boolean) .map(t => parseInt(t, 10)) .filter(n => Number.isFinite(n) && n > 0) )); const MAX_IDS = 10; const truncated = parsedList.length > MAX_IDS; const ids = parsedList.slice(0, MAX_IDS); // Sin IDs: ayuda de uso if (ids.length === 0) { return [{ recipient: context.sender, message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)' }]; } // Caso de 1 ID: mantener comportamiento actual if (ids.length === 1) { const idInput = ids[0]; const resolvedId = this.resolveTaskIdFromInput(idInput); if (!resolvedId) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` }]; } const task = TaskService.getTaskById(resolvedId); if (!task) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { return [{ recipient: context.sender, message: 'No puedes completar esta tarea porque no eres de este grupo.' }]; } const res = TaskService.completeTask(resolvedId, context.sender); const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; if (res.status === 'not_found') { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } if (res.status === 'already') { const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; return [{ recipient: context.sender, message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}` }]; } const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; return [{ recipient: context.sender, message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}` }]; } // Modo múltiple const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0; const lines: string[] = []; if (truncated) { lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); } for (const idInput of ids) { const resolvedId = this.resolveTaskIdFromInput(idInput); if (!resolvedId) { lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); cntNotFound++; continue; } const task = TaskService.getTaskById(resolvedId); if (task && task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); cntBlocked++; continue; } const res = TaskService.completeTask(resolvedId, context.sender); const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'already') { lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); cntAlready++; } else if (res.status === 'updated') { lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`); cntUpdated++; } else if (res.status === 'not_found') { lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); cntNotFound++; } } // Resumen final const summary: string[] = []; if (cntUpdated) summary.push(`completadas ${cntUpdated}`); if (cntAlready) summary.push(`ya estaban ${cntAlready}`); if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); if (summary.length) { lines.push(''); lines.push(`Resumen: ${summary.join(', ')}.`); } return [{ recipient: context.sender, message: lines.join('\n') }]; } // Tomar tarea (con validación opcional de membresía) if (action === 'tomar') { // Soportar múltiples IDs separados por espacios y/o comas const rawIds = (tokens.slice(2).join(' ') || '').trim(); const parsedList = Array.from(new Set( rawIds .split(/[,\s]+/) .map(t => t.trim()) .filter(Boolean) .map(t => parseInt(t, 10)) .filter(n => Number.isFinite(n) && n > 0) )); const MAX_IDS = 10; const truncated = parsedList.length > MAX_IDS; const ids = parsedList.slice(0, MAX_IDS); // Sin IDs: ayuda de uso if (ids.length === 0) { return [{ recipient: context.sender, message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)' }]; } // Caso de 1 ID: mantener comportamiento actual if (ids.length === 1) { const idInput = ids[0]; const resolvedId = this.resolveTaskIdFromInput(idInput); if (!resolvedId) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` }]; } const task = TaskService.getTaskById(resolvedId); if (!task) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { return [{ recipient: context.sender, message: 'No puedes tomar esta tarea porque no eres de este grupo.' }]; } const res = TaskService.claimTask(resolvedId, context.sender); const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'not_found') { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } if (res.status === 'completed') { return [{ recipient: context.sender, message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.status === 'already') { return [{ recipient: context.sender, message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` }]; } const lines = [ italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`), `${res.task?.description || '(sin descripción)'}`, res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' ].filter(Boolean); return [{ recipient: context.sender, message: lines.join('\n') }]; } // Modo múltiple const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0; const lines: string[] = []; if (truncated) { lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); } for (const idInput of ids) { const resolvedId = this.resolveTaskIdFromInput(idInput); if (!resolvedId) { lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); cntNotFound++; continue; } const task = TaskService.getTaskById(resolvedId); if (task && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); cntBlocked++; continue; } const res = TaskService.claimTask(resolvedId, context.sender); const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'already') { lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`); cntAlready++; } else if (res.status === 'claimed') { lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`); cntClaimed++; } else if (res.status === 'completed') { lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); cntCompleted++; } else if (res.status === 'not_found') { lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); cntNotFound++; } } // Resumen final const summary: string[] = []; if (cntClaimed) summary.push(`tomadas ${cntClaimed}`); if (cntAlready) summary.push(`ya las tenías ${cntAlready}`); if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`); if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); if (summary.length) { lines.push(''); lines.push(`Resumen: ${summary.join(', ')}.`); } return [{ recipient: context.sender, message: lines.join('\n') }]; } // Soltar tarea (con validación opcional de membresía) if (action === 'soltar') { const idToken = tokens[2]; const idInput = idToken ? parseInt(idToken, 10) : NaN; if (!idInput || Number.isNaN(idInput)) { return [{ recipient: context.sender, message: 'ℹ️ Uso: `/t soltar 26`' }]; } const resolvedId = this.resolveTaskIdFromInput(idInput); if (!resolvedId) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` }]; } const task = TaskService.getTaskById(resolvedId); if (!task) { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } const enforce = String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; if (task.group_id && GroupSyncService.isSnapshotFresh(task.group_id) && enforce && !GroupSyncService.isUserActiveInGroup(context.sender, task.group_id)) { return [{ recipient: context.sender, message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.' }]; } const res = TaskService.unassignTask(resolvedId, context.sender); const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; if (res.status === 'forbidden_personal') { return [{ recipient: context.sender, message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla' }]; } if (res.status === 'not_found') { return [{ recipient: context.sender, message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` }]; } if (res.status === 'completed') { return [{ recipient: context.sender, message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.status === 'not_assigned') { return [{ recipient: context.sender, message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` }]; } if (res.now_unassigned) { const lines = [ `${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`, `${res.task?.description || '(sin descripción)'}`, res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', italic('queda sin responsable.') ].filter(Boolean); return [{ recipient: context.sender, message: lines.join('\n') }]; } const lines = [ `${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`, `${res.task?.description || '(sin descripción)'}`, res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' ].filter(Boolean); return [{ recipient: context.sender, message: lines.join('\n') }]; } if (action === 'configurar') { const optRaw = (tokens[2] || '').toLowerCase(); const map: Record = { 'daily': 'daily', 'diario': 'daily', 'diaria': 'daily', 'l-v': 'weekdays', 'lv': 'weekdays', 'laborables': 'weekdays', 'weekdays': 'weekdays', 'semanal': 'weekly', 'weekly': 'weekly', 'off': 'off', 'apagar': 'off', 'ninguno': 'off' }; const freq = map[optRaw]; // Hora opcional HH:MM const timeRaw = tokens[3] || ''; let timeNorm: string | null = null; if (timeRaw) { const m = /^(\d{1,2}):([0-5]\d)$/.exec(timeRaw); if (!m) { return [{ recipient: context.sender, message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`' }]; } const hh = Math.max(0, Math.min(23, parseInt(m[1], 10))); timeNorm = `${String(hh).padStart(2, '0')}:${m[2]}`; } if (!freq) { return [{ recipient: context.sender, message: 'ℹ️ Uso: `/t configurar diario|l-v|semanal|off [HH:MM]`' }]; } const ensured = ensureUserExists(context.sender, this.dbInstance); if (!ensured) { throw new Error('No se pudo asegurar el usuario'); } this.dbInstance.prepare(` INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) VALUES (?, ?, COALESCE(?, COALESCE((SELECT reminder_time FROM user_preferences WHERE user_id = ?), '08:30')), NULL, strftime('%Y-%m-%d %H:%M:%f', 'now')) ON CONFLICT(user_id) DO UPDATE SET reminder_freq = excluded.reminder_freq, reminder_time = CASE WHEN ? IS NOT NULL THEN excluded.reminder_time ELSE reminder_time END, updated_at = excluded.updated_at `).run(ensured, freq, timeNorm, ensured, timeNorm); let label: string; if (freq === 'daily') { label = timeNorm ? `diario (${timeNorm})` : 'diario'; } else if (freq === 'weekdays') { label = timeNorm ? `laborables (lunes a viernes ${timeNorm})` : 'laborables (lunes a viernes)'; } else if (freq === 'weekly') { label = timeNorm ? `semanal (lunes ${timeNorm})` : 'semanal (lunes 08:30)'; } else { label = 'apagado'; } return [{ recipient: context.sender, message: `✅ Recordatorios: ${label}` }]; } // Enlace de acceso a la web (/t web) if (action === 'web') { // Solo por DM if (isGroupId(context.groupId)) { return [{ recipient: context.sender, message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.' }]; } const base = (process.env.WEB_BASE_URL || '').trim(); if (!base) { return [{ recipient: context.sender, message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).' }]; } const ensured = ensureUserExists(context.sender, this.dbInstance); if (!ensured) { throw new Error('No se pudo asegurar el usuario'); } const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); const now = new Date(); const nowIso = toIso(now); const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos // Invalidar tokens vigentes (uso único) this.dbInstance.prepare(` UPDATE web_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL AND expires_at > ? `).run(nowIso, ensured, nowIso); // Generar nuevo token y guardar solo el hash const token = randomTokenBase64Url(32); const tokenHash = await sha256Hex(token); this.dbInstance.prepare(` INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata) VALUES (?, ?, ?, NULL) `).run(ensured, tokenHash, expiresIso); try { Metrics.inc('web_tokens_issued_total'); } catch { } const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString(); return [{ recipient: context.sender, message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".` }]; } if (action !== 'nueva') { const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase(); const helpV2Enabled = !['false', '0', 'no'].includes(feature); try { Metrics.inc('commands_unknown_total'); } catch { } if (!helpV2Enabled) { return [{ recipient: context.sender, message: `Acción ${rawAction || '(vacía)'} no implementada aún` }]; } const header = `❓ ${section('Comando no reconocido')}`; const cta = `Prueba ${code('/t ayuda')}`; const help = getQuickHelp(); return [{ recipient: context.sender, message: [header, cta, '', help].join('\n') }]; } // Parseo específico de "nueva" // Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles) const MIN_FALLBACK_DIGITS = (() => { const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim(); const n = parseInt(raw || '8', 10); return Number.isFinite(n) && n > 0 ? n : 8; })(); const MAX_FALLBACK_DIGITS = (() => { const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim(); const n = parseInt(raw || '15', 10); return Number.isFinite(n) && n > 0 ? n : 15; })(); type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid'; const isDigits = (s: string) => /^\d+$/.test(s); const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => { if (!s) return { ok: false, reason: 'invalid' }; if (opts?.fromLid) return { ok: false, reason: 'from_lid' }; if (!isDigits(s)) return { ok: false, reason: 'non_numeric' }; if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' }; if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' }; return { ok: true }; }; const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => { try { const gid = isGroupId(context.groupId) ? context.groupId : 'dm'; Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason }); } catch { } }; // 1) Menciones aportadas por el backend (JIDs crudos) const unresolvedAssigneeDisplays: string[] = []; const mentionsNormalizedFromContext = Array.from(new Set( (context.mentions || []).map((j) => { const norm = normalizeWhatsAppId(j); if (!norm) { // agregar a no resolubles para JIT (mostrar sin @ ni dominio) const raw = String(j || ''); const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, ''); if (disp) unresolvedAssigneeDisplays.push(disp); incOnboardingFailure('mentions', 'invalid'); return null; } const resolved = IdentityService.resolveAliasOrNull(norm); if (resolved) return resolved; // detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico) const dom = String(j || '').split('@')[1]?.toLowerCase() || ''; const fromLid = dom.includes('lid'); const p = plausibility(norm, { fromLid }); if (p.ok) return norm; // conservar para copy JIT unresolvedAssigneeDisplays.push(norm); incOnboardingFailure('mentions', p.reason!); return null; }).filter((id): id is string => !!id) )); // 2) Tokens de texto que empiezan por '@' como posibles asignados const atTokenCandidates = tokens.slice(2) .filter(t => t.startsWith('@')) .map(t => t.replace(/^@+/, '').replace(/^\+/, '')); const normalizedFromAtTokens = Array.from(new Set( atTokenCandidates.map((v) => { const norm = normalizeWhatsAppId(v); if (!norm) { // agregar a no resolubles para JIT (texto ya viene sin @/+) if (v) unresolvedAssigneeDisplays.push(v); incOnboardingFailure('tokens', 'invalid'); return null; } const resolved = IdentityService.resolveAliasOrNull(norm); if (resolved) return resolved; const p = plausibility(norm, { fromLid: false }); if (p.ok) return norm; // conservar para copy JIT (preferimos el token limpio v) unresolvedAssigneeDisplays.push(v); incOnboardingFailure('tokens', p.reason!); return null; }).filter((id): id is string => !!id) )); // 3) Unir y deduplicar const combinedAssigneeCandidates = Array.from(new Set([ ...mentionsNormalizedFromContext, ...normalizedFromAtTokens ])); if (process.env.NODE_ENV !== 'test') { console.log('[A0] /t nueva menciones', { context_mentions: context.mentions || [], mentions_normalized: mentionsNormalizedFromContext, at_tokens: atTokenCandidates, at_normalized: normalizedFromAtTokens, combined: combinedAssigneeCandidates }); } const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext); // Asegurar creador const createdBy = ensureUserExists(context.sender, this.dbInstance); if (!createdBy) { throw new Error('No se pudo asegurar el usuario creador'); } // Normalizar menciones y excluir duplicados y el número del bot const botNumber = process.env.CHATBOT_PHONE_NUMBER || ''; const assigneesNormalized = Array.from(new Set( combinedAssigneeCandidates .filter(id => !botNumber || id !== botNumber) )); // Asegurar usuarios asignados const ensuredAssignees = assigneesNormalized .map(id => ensureUserExists(id, this.dbInstance)) .filter((id): id is string => !!id); // Asignación por defecto según contexto: // - En grupos: si no hay menciones → sin dueño (ningún asignado) // - En DM: si no hay menciones → asignada al creador let assignmentUserIds: string[] = []; if (ensuredAssignees.length > 0) { assignmentUserIds = ensuredAssignees; } else { assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy]; } // Definir group_id solo si el grupo está activo const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId)) ? context.groupId : null; // Crear tarea y asignaciones const taskId = TaskService.createTask( { description: description || '', due_date: dueDate ?? null, group_id: groupIdToUse, created_by: createdBy, }, assignmentUserIds.map(uid => ({ user_id: uid, assigned_by: createdBy, })) ); // Registrar origen del comando para esta tarea (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; const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null; try { this.dbInstance.prepare(` INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me) VALUES (?, ?, ?, ?, ?) `).run(taskId, groupIdToUse, context.messageId, participant, fromMe); } catch { this.dbInstance.prepare(` INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id) VALUES (?, ?, ?) `).run(taskId, groupIdToUse, context.messageId); } } } catch { } // Recuperar la tarea creada para obtener display_code asignado const createdTask = TaskService.getTaskById(taskId); const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`); // Resolver nombres útiles const creatorName = await ContactsService.getDisplayName(createdBy); const creatorJid = `${createdBy}@s.whatsapp.net`; const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null; const assignedDisplayNames = await Promise.all( assignmentUserIds.map(async uid => { const name = await ContactsService.getDisplayName(uid); return name || uid; }) ); const responses: CommandResponse[] = []; // 1) Ack al creador con formato compacto const dueFmt = formatDDMM(dueDate); const ownerPart = assignmentUserIds.length === 0 ? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}` : `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`; const ackLines = [ `${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`, dueFmt ? `${ICONS.date} ${dueFmt}` : null, ownerPart ].filter(Boolean); responses.push({ recipient: createdBy, message: [ackLines.join('\n'), '', CommandService.CTA_HELP].join('\n'), mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined }); // 2) DM a cada asignado (excluyendo al creador para evitar duplicados) for (const uid of assignmentUserIds) { if (uid === createdBy) continue; responses.push({ recipient: uid, message: [ `${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`, `${description || '(sin descripción)'}`, formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null, groupName ? `Grupo: ${groupName}` : null, `- Completar: \`/t x ${createdTask?.display_code}\``, `- Soltar: \`/t soltar ${createdTask?.display_code}\`` ].filter(Boolean).join('\n') + '\n\n' + CommandService.CTA_HELP, mentions: [creatorJid] }); } // A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables { const unresolvedList = Array.from(new Set(unresolvedAssigneeDisplays.filter(Boolean))); if (unresolvedList.length > 0) { const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test'; const enabled = isTest ? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true' : (() => { const v = process.env.ONBOARDING_PROMPTS_ENABLED; return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase()); })(); const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm'; if (!enabled) { try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { } } else { const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); if (!bot || !/^\d+$/.test(bot)) { try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { } } else { const list = unresolvedList.join(', '); let groupCtx = ''; if (isGroupId(context.groupId)) { const name = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; groupCtx = ` (en el grupo ${name})`; } const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`; responses.push({ recipient: createdBy, message: msg }); try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { } } } } } // 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([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); // 5–10s 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 codeInline = codeId(taskId, displayCode); const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`); const cmdInfo = code(`/t info`); const groupBold = bold(`‘${groupLabel}’`); const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_ - Para hacerte cargo: ${cmdTake} - Más info: ${cmdInfo} ${ICONS.info} Solo escribo por privado. ${ICONS.info} Cuando reciba tu primer mensaje ya no te enviaré más este recordatorio`; const msg2 = `GUÍA RÁPIDA Puedes interactuar escribiéndome por privado: - Ver tus tareas: ${code('/t mias')} - Ver todas: ${code('/t todas')} - Recibe recordatorios: ${code('/t configurar diario|l-v|semanal|off')} - Web: ${code('/t web')}`; for (const rcpt of recipients) { const stats = ResponseQueue.getOnboardingStats(rcpt); let variant: 'initial' | 'reminder' | null = null; if (!stats || (stats.total || 0) === 0) { variant = 'initial'; } else if (stats.firstInitialAt) { let firstMs = NaN; try { const s = String(stats.firstInitialAt); const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z'); firstMs = Date.parse(iso); } catch {} const nowMs = Date.now(); const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false; // Interacción del usuario desde el primer paquete let hadInteraction = false; try { const row = 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; } static async handle(context: CommandContext): Promise { const outcome = await this.handleWithOutcome(context); return outcome.responses; } static async handleWithOutcome(context: CommandContext): Promise { const msg = (context.message || '').trim(); if (!/^\/(tarea|t)\b/i.test(msg)) { 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 { } const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); if (mode === 'enforce') { try { if (!AllowedGroups.isAllowed(context.groupId)) { try { Metrics.inc('commands_blocked_total'); } catch { } return { responses: [], ok: true }; } } catch { // Si falla el check, ser permisivos } } } try { const responses = await this.processTareaCommand(context); // Clasificación explícita del outcome (evita lógica en server) const tokens = msg.split(/\s+/); const rawAction = (tokens[1] || '').toLowerCase(); const ACTION_ALIASES: Record = { 'n': 'nueva', 'nueva': 'nueva', 'crear': 'nueva', '+': 'nueva', 'ver': 'ver', 'mostrar': 'ver', 'listar': 'ver', 'ls': 'ver', 'mias': 'ver', 'mías': 'ver', 'todas': 'ver', 'todos': 'ver', 'x': 'completar', 'hecho': 'completar', 'completar': 'completar', 'done': 'completar', 'tomar': 'tomar', 'claim': 'tomar', 'asumir': 'tomar', 'asumo': 'tomar', 'soltar': 'soltar', 'unassign': 'soltar', 'dejar': 'soltar', 'liberar': 'soltar', 'renunciar': 'soltar', 'ayuda': 'ayuda', 'help': 'ayuda', 'info': 'ayuda', '?': 'ayuda', 'config': 'configurar', 'configurar': 'configurar', 'web': 'web' }; const action = ACTION_ALIASES[rawAction] || rawAction; // Casos explícitos considerados éxito if (!action || action === 'ayuda' || action === 'web') { return { responses, ok: true }; } const lowerMsgs = (responses || []).map(r => String(r?.message || '').toLowerCase()); const isOkException = (m: string) => m.includes('ya estaba completada') || m.includes('ya la tenías') || m.includes('no la tenías'); const isErrorMsg = (m: string) => m.startsWith('ℹ️ uso:'.toLowerCase()) || m.includes('uso:') || m.includes('no puedes') || m.includes('no permitido') || m.includes('no encontrada') || m.includes('comando no reconocido'); let hasError = false; for (const m of lowerMsgs) { if (isErrorMsg(m) && !isOkException(m)) { hasError = true; break; } } return { responses, ok: !hasError }; } catch (error) { return { responses: [{ recipient: context.sender, message: 'Error processing command' }], ok: false }; } } }