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'; 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(); const today = new Date(); today.setHours(0, 0, 0, 0); const formatYMD = (d: Date) => d.toISOString().slice(0, 10); type DateCandidate = { index: number; ymd: string }; const dateCandidates: DateCandidate[] = []; const dateTokenIndexes = new Set(); for (let i = 2; i < parts.length; i++) { const p = parts[i]; const low = p.toLowerCase(); if (/^\d{4}-\d{2}-\d{2}$/.test(p)) { const d = new Date(p); if (!isNaN(d.getTime())) { d.setHours(0, 0, 0, 0); if (d >= today) { dateCandidates.push({ index: i, ymd: formatYMD(d) }); dateTokenIndexes.add(i); } } continue; } if (low === 'hoy') { dateCandidates.push({ index: i, ymd: formatYMD(today) }); dateTokenIndexes.add(i); continue; } if (low === 'mañana' || low === 'manana') { const tmr = new Date(today); tmr.setDate(tmr.getDate() + 1); dateCandidates.push({ index: i, ymd: formatYMD(tmr) }); 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', 'soltar': 'soltar', 'unassign': 'soltar', 'ayuda': 'ayuda', 'help': 'ayuda', '?': 'ayuda', 'config': 'configurar', 'configurar': 'configurar' }; const action = ACTION_ALIASES[rawAction] || rawAction; if (!action || action === 'ayuda') { const help = [ 'Guía rápida:', '- Crear: /t n Descripción mañana @Ana', '- Ver grupo: /t ver grupo', '- Ver mis: /t ver mis', '- Completar: /t x 123' ].join('\n'); return [{ recipient: context.sender, message: help }]; } 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 ackHeader = `✅ ${taskId} “*${description || '(sin descripción)'}*”`; const ackLines: string[] = [ackHeader]; if (dueDate) ackLines.push(`📅 ${dueDate}`); if (assignmentUserIds.length === 0) { ackLines.push(`👥 sin dueño${groupName ? ` (${groupName})` : ''}`); } else { const assigneesList = assignedDisplayNames.join(', '); ackLines.push(`${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assigneesList}`); } 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: [ `🔔 ${taskId}${dueDate ? ` — 📅 ${dueDate}` : ''}`, `“*${description || '(sin descripción)'}*”`, groupName ? `Grupo: ${groupName}` : null, `Completar: /t x ${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' }]; } } }