diff --git a/src/services/command.ts b/src/services/command.ts index 7fb9471..458f411 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -8,846 +8,846 @@ import { ICONS } from '../utils/icons'; import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting'; 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 + 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 + 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('en-GB', { - 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()); - - 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 YYYY-MM-DD - if (/^\d{4}-\d{2}-\d{2}$/.test(low)) { - // Validar rango básico y filtrar pasado según hoy en TZ - if (low >= todayYMD) { - dateCandidates.push({ index: i, ymd: low }); - 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 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 (scopes: grupo | mis | todos | sin)', - ' · Completar: x, hecho, completar, done', - ' · Tomar: tomar, claim', - ' · Soltar: soltar, unassign', - '- Preferencias:', - ' · `/t configurar daily|weekly|off` (hora por defecto 08:30; semanal: lunes 08:30)', - '- Notas:', - ' · En grupos, el bot responde por DM al autor (no publica en el grupo).', - ' · Si creas en grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna al creador.', - ' · Fechas dd/MM con ⚠️ si está vencida.', - ' · Mostramos los IDs 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 mañana @Ana`', - '- Ver grupo: `/t ver grupo`', - '- Ver mis: `/t ver mis`', - '- Ver todos: `/t ver todos`', - '- Completar: `/t x 123`', - '- Configurar recordatorios: `/t configurar daily|weekly|off`' - ].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.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.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.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.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.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.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') { - const idToken = tokens[2]; - const id = idToken ? parseInt(idToken, 10) : NaN; - if (!id || Number.isNaN(id)) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t x 26`' - }]; - } - - const task = TaskService.getTaskById(id); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(id)} 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 completar esta tarea porque no apareces como miembro activo del grupo.' - }]; - } - - const res = TaskService.completeTask(id, context.sender); - const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(id)} 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(id)} 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(id)} completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - // Tomar tarea (con validación opcional de membresía) - if (action === 'tomar') { - const idToken = tokens[2]; - const id = idToken ? parseInt(idToken, 10) : NaN; - if (!id || Number.isNaN(id)) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t tomar 26`' - }]; - } - - const task = TaskService.getTaskById(id); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(id)} 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(id, 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(id)} no encontrada.` - }]; - } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'already') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(id)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - const lines = [ - italic(`${ICONS.take} Has tomado ${codeId(id)}`), - `${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') - }]; - } - - // Soltar tarea (con validación opcional de membresía) - if (action === 'soltar') { - const idToken = tokens[2]; - const id = idToken ? parseInt(idToken, 10) : NaN; - if (!id || Number.isNaN(id)) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t soltar 26`' - }]; - } - - const task = TaskService.getTaskById(id); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(id)} 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(id, 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(id)} no encontrada.` - }]; - } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'not_assigned') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(id)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - if (res.now_unassigned) { - const lines = [ - `${ICONS.unassigned} ${codeId(id)}`, - `${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(id)}`, - `${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', - 'semanal': 'weekly', - 'weekly': 'weekly', - 'off': 'off', - 'apagar': 'off', - 'ninguno': 'off' - }; - const freq = map[optRaw]; - if (!freq) { - return [{ - recipient: context.sender, - message: 'Uso: `/t configurar daily|weekly|off`' - }]; - } - const ensured = ensureUserExists(context.sender, this.dbInstance); - if (!ensured) { - throw new Error('No se pudo asegurar el usuario'); - } - this.dbInstance.prepare(` + 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()); + + 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 YYYY-MM-DD + if (/^\d{4}-\d{2}-\d{2}$/.test(low)) { + // Validar rango básico y filtrar pasado según hoy en TZ + if (low >= todayYMD) { + dateCandidates.push({ index: i, ymd: low }); + 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 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 (scopes: grupo | mis | todos | sin)', + ' · Completar: x, hecho, completar, done', + ' · Tomar: tomar, claim', + ' · Soltar: soltar, unassign', + '- Preferencias:', + ' · `/t configurar daily|weekly|off` (hora por defecto 08:30; semanal: lunes 08:30)', + '- Notas:', + ' · En grupos, el bot responde por DM (no publica en el grupo).', + ' · Si creas en 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 todos: `/t ver todos`', + '- Completar: `/t x 123`', + '- Configurar recordatorios: `/t configurar daily|weekly|off`' + ].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.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.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.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.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.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.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') { + const idToken = tokens[2]; + const id = idToken ? parseInt(idToken, 10) : NaN; + if (!id || Number.isNaN(id)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t x 26`' + }]; + } + + const task = TaskService.getTaskById(id); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(id)} 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 completar esta tarea porque no apareces como miembro activo del grupo.' + }]; + } + + const res = TaskService.completeTask(id, context.sender); + const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; + if (res.status === 'not_found') { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(id)} 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(id)} 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(id)} completada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + // Tomar tarea (con validación opcional de membresía) + if (action === 'tomar') { + const idToken = tokens[2]; + const id = idToken ? parseInt(idToken, 10) : NaN; + if (!id || Number.isNaN(id)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t tomar 26`' + }]; + } + + const task = TaskService.getTaskById(id); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(id)} 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(id, 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(id)} no encontrada.` + }]; + } + if (res.status === 'completed') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + if (res.status === 'already') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(id)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + const lines = [ + italic(`${ICONS.take} Has tomado ${codeId(id)}`), + `${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') + }]; + } + + // Soltar tarea (con validación opcional de membresía) + if (action === 'soltar') { + const idToken = tokens[2]; + const id = idToken ? parseInt(idToken, 10) : NaN; + if (!id || Number.isNaN(id)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Uso: `/t soltar 26`' + }]; + } + + const task = TaskService.getTaskById(id); + if (!task) { + return [{ + recipient: context.sender, + message: `⚠️ Tarea ${codeId(id)} 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(id, 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(id)} no encontrada.` + }]; + } + if (res.status === 'completed') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(id)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + if (res.status === 'not_assigned') { + return [{ + recipient: context.sender, + message: `ℹ️ ${codeId(id)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` + }]; + } + + if (res.now_unassigned) { + const lines = [ + `${ICONS.unassigned} ${codeId(id)}`, + `${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(id)}`, + `${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', + 'semanal': 'weekly', + 'weekly': 'weekly', + 'off': 'off', + 'apagar': 'off', + 'ninguno': 'off' + }; + const freq = map[optRaw]; + if (!freq) { + return [{ + recipient: context.sender, + message: 'Uso: `/t configurar daily|weekly|off`' + }]; + } + 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((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, updated_at = excluded.updated_at `).run(ensured, freq, ensured); - const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado'; - return [{ - recipient: context.sender, - message: `✅ Recordatorios: ${label}` - }]; - } - - 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)) - .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)) - .filter((id): id is string => !!id) - )); - const combinedAssigneeCandidates = Array.from(new Set([ - ...mentionsNormalizedFromContext, - ...normalizedFromAtTokens - ])); - - 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, - })) - ); - - 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)} ${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)}`, - `${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 []; - } - - try { - return await this.processTareaCommand(context); - } catch (error) { - return [{ - recipient: context.sender, - message: 'Error processing command' - }]; - } - } + const label = freq === 'daily' ? 'diario' : freq === 'weekly' ? 'semanal (lunes 08:30)' : 'apagado'; + return [{ + recipient: context.sender, + message: `✅ Recordatorios: ${label}` + }]; + } + + 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)) + .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)) + .filter((id): id is string => !!id) + )); + const combinedAssigneeCandidates = Array.from(new Set([ + ...mentionsNormalizedFromContext, + ...normalizedFromAtTokens + ])); + + 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, + })) + ); + + 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)} ${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)}`, + `${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 []; + } + + try { + return await this.processTareaCommand(context); + } catch (error) { + return [{ + recipient: context.sender, + message: 'Error processing command' + }]; + } + } }