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 } from '../utils/formatting'; import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; 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 }; export type CommandResponse = { recipient: string; message: string; mentions?: string[]; // full JIDs to mention in the outgoing message }; export class CommandService { static dbInstance: Database = db; 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 { // Resolver primero por display_code en tareas activas; si no, por PK const byCode = TaskService.getActiveTaskByDisplayCode(n); if (byCode) return byCode.id; const byId = TaskService.getTaskById(n); return byId ? byId.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', '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', '?': 'ayuda', 'config': 'configurar', 'configurar': 'configurar' }; const action = ACTION_ALIASES[rawAction] || rawAction; // 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 isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada'; 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 daily|l-v|weekly|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 cree.', ' · 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 }]; } // 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) : (isGroupId(context.groupId) ? 'grupo' : 'mis'); const LIMIT = 10; // 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} sin responsable`; }); const total = TaskService.countGroupUnassigned(context.groupId); if (total > items.length) { rendered.push(`… y ${total - items.length} más`); } return [{ recipient: context.sender, 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[] = []; // 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} sin responsable`; }); 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} sin responsable`; }); 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 de un grupo, usa `/t ver sin` desde ese grupo.'); } } return [{ recipient: context.sender, message: sections.join('\n') }]; } // 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 porque no apareces como miembro activo. Pide acceso a un admin 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: `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} 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}`; })); 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); 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[] = []; 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(); } 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 apareces como miembro activo del 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: no apareces como miembro activo del grupo. Pide acceso a un admin si crees que es un error.' }]; } 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 apareces como miembro activo del grupo.' }]; } const res = TaskService.unassignTask(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 === '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)} (${resolvedId})`, `${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 daily|l-v|weekly|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 daily|l-v|weekly|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') { return [{ recipient: context.sender, message: `Acción ${rawAction || '(vacía)'} no implementada aún` }]; } // Parseo específico de "nueva" // Normalizar menciones del contexto para parseo y asignaciones const mentionsNormalizedFromContext = Array.from(new Set( (context.mentions || []) .map(j => normalizeWhatsAppId(j)) .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) .filter((id): id is string => !!id) )); // Detectar también tokens de texto que empiezan por '@' como posibles asignados const atTokenCandidates = tokens.slice(2) .filter(t => t.startsWith('@')) .map(t => t.replace(/^@+/, '')); const normalizedFromAtTokens = Array.from(new Set( atTokenCandidates .map(v => normalizeWhatsAppId(v)) .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) .filter((id): id is string => !!id) )); 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, })) ); // 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} sin responsable${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'), 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 ${taskId}\``, `- Soltar: \`/t soltar ${taskId}\`` ].filter(Boolean).join('\n'), mentions: [creatorJid] }); } return responses; } static async handle(context: CommandContext): Promise { const msg = (context.message || '').trim(); if (!/^\/(tarea|t)\b/i.test(msg)) { return []; } // Gating de grupos en modo 'enforce' (Etapa 3) 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 []; } } catch { // Si falla el check, ser permisivos } } } try { return await this.processTareaCommand(context); } catch (error) { return [{ recipient: context.sender, message: 'Error processing command' }]; } } }