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'; 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('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); } const totalMy = TaskService.countUserPending(context.sender); if (totalMy > myItems.length) { sections.push(`… y ${totalMy - myItems.length} más`); } } else { sections.push('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) { 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) { 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: '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); } 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 = [ `${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 ackParts = [ `${ICONS.create} ${codeId(taskId)} _${description || '(sin descripción)'}_`, dueFmt ? `${ICONS.date} ${dueFmt}` : null, ownerPart ].filter(Boolean); responses.push({ recipient: createdBy, message: ackParts.join(' — '), 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, italic(`Acciones: 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' }]; } } }