diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md new file mode 100644 index 0000000..79b2a47 --- /dev/null +++ b/docs/MANUAL_TESTS.md @@ -0,0 +1,51 @@ +# Pruebas manuales sugeridas (Iteración A) + +Ejecuta el servidor (entorno de desarrollo) y usa un cliente WhatsApp conectado a Evolution API. + +1) Comando base y ayuda +- En un grupo activo: enviar “/t” o “/t ayuda”. + - Esperado: no aparece nada en el grupo; recibes un DM con la guía rápida. +- En DM al bot: enviar “/t”. + - Esperado: recibes el mismo DM de ayuda. + +2) Crear tarea en grupo (sin menciones) +- Enviar en el grupo: “/t n Comprar leche mañana”. + - Esperado: + - Se crea la tarea con due_date = YYYY-MM-DD (mañana). + - No se asigna a nadie (sin dueño). + - No aparece nada en el grupo. + - Recibes un DM con formato compacto: + ✅ “*Comprar leche*” + 📅 + 👥 sin dueño () + +3) Crear tarea en DM (sin menciones) +- Enviar al bot por DM: “/t n Pagar comedor hoy”. + - Esperado: + - Se crea la tarea con due_date = YYYY-MM-DD (hoy). + - Tarea asignada a ti (creador). + - Recibes un DM de confirmación (formato compacto). + - No se envía nada a ningún grupo. + +4) Crear tarea con menciones en grupo +- Enviar: “/t n Acta de la reunión 2025-09-12 @34611122233”. + - Esperado: + - Se crea la tarea con due_date 2025-09-12. + - Se asigna a 34611122233 (normalizado). + - No aparece nada en el grupo. + - DM al creador con: + ✅ “*Acta de la reunión*” + 📅 2025-09-12 + 👤 + - DM al asignado: + 🔔 — 📅 2025-09-12 + “*Acta de la reunión*” + Grupo: + Completar: /t x + +5) Prefijos aceptados +- Repetir 2–4 usando “/tarea n ...” (debe comportarse igual que “/t ...”). + +Notas +- En el log del servidor verás “✅ Sent message to with this as payload: ...” por cada DM encolado y enviado por Evolution API. +- Bajo tests (NODE_ENV=test), el servicio evita llamadas de red del ContactsService, por lo que los nombres pueden mostrarse como números. diff --git a/src/server.ts b/src/server.ts index 31488b0..8e6f864 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { GroupSyncService } from './services/group-sync'; import { ResponseQueue } from './services/response-queue'; import { TaskService } from './tasks/service'; import { WebhookManager } from './services/webhook-manager'; -import { normalizeWhatsAppId } from './utils/whatsapp'; +import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp'; import { ensureUserExists, db, initializeDatabase } from './db'; import { ContactsService } from './services/contacts'; @@ -144,8 +144,8 @@ export class WebhookServer { return; } - // Check if the group is active - if (!GroupSyncService.isGroupActive(data.key.remoteJid)) { + // Check if the group is active (allow DMs always) + if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) { if (process.env.NODE_ENV !== 'test') { console.log('⚠️ Group is not active, ignoring message'); } @@ -154,9 +154,10 @@ export class WebhookServer { // Forward to command service only if: // 1. It's a text message (has conversation field) - // 2. Starts with /tarea command + // 2. Starts with /t or /tarea command const messageText = data.message.conversation; - if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) { + const trimmedMessage = typeof messageText === 'string' ? messageText.trim() : ''; + if (trimmedMessage.startsWith('/tarea') || trimmedMessage.startsWith('/t')) { // Extraer menciones desde el mensaje const mentions = data.message?.contextInfo?.mentionedJid || []; diff --git a/src/services/command.ts b/src/services/command.ts index 1ffafef..353001d 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1,6 +1,6 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; -import { normalizeWhatsAppId } from '../utils/whatsapp'; +import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; import { TaskService } from '../tasks/service'; import { GroupSyncService } from './group-sync'; import { ContactsService } from './contacts'; @@ -29,49 +29,62 @@ export class CommandService { const parts = (message || '').trim().split(/\s+/); const action = (parts[1] || '').toLowerCase(); - // Buscar última fecha futura con formato YYYY-MM-DD - const dateIndices: { index: number; text: string }[] = []; + 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); - const today = new Date(); - today.setHours(0, 0, 0, 0); - if (!isNaN(d.getTime()) && d >= today) { - dateIndices.push({ index: i, text: 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; } } - let dueDate: string | null = null; - let descriptionTokens: string[] = []; + const dueDate = dateCandidates.length > 0 + ? dateCandidates[dateCandidates.length - 1].ymd + : null; const isMentionToken = (token: string) => token.startsWith('@'); - if (dateIndices.length > 0) { - const last = dateIndices[dateIndices.length - 1]; - dueDate = last.text; - for (let i = 2; i < parts.length; i++) { - if (i === last.index) continue; - const token = parts[i]; - if (isMentionToken(token)) continue; // quitar @menciones del texto - descriptionTokens.push(token); - } - } else { - for (let i = 2; i < parts.length; i++) { - const token = parts[i]; - if (isMentionToken(token)) continue; - descriptionTokens.push(token); - } + 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, - }; + return { action, description, dueDate }; } private static async processTareaCommand( @@ -79,12 +92,50 @@ export class CommandService { ): Promise { const trimmed = (context.message || '').trim(); const tokens = trimmed.split(/\s+/); - const action = (tokens[1] || '').toLowerCase(); + 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 ${action || '(vacía)'} no implementada aún` + message: `Acción ${rawAction || '(vacía)'} no implementada aún` }]; } @@ -129,8 +180,15 @@ export class CommandService { .map(id => ensureUserExists(id, this.dbInstance)) .filter((id): id is string => !!id); - // Si no hay asignados, asignar al creador - const assignmentUserIds = ensuredAssignees.length > 0 ? ensuredAssignees : [createdBy]; + // 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)) @@ -167,10 +225,19 @@ export class CommandService { const responses: CommandResponse[] = []; - // 1) Ack al creador siempre, en una sola línea con id y descripción + // 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: `✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"`, + message: ackLines.join('\n'), mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined }); @@ -179,38 +246,24 @@ export class CommandService { if (uid === createdBy) continue; responses.push({ recipient: uid, - message: - `🆕 Nueva tarea:\n` + - `• ${description || '(sin descripción)'}\n` + - (dueDate ? `• Vence: ${dueDate}\n` : '') + - `• Asignada por: ${creatorName || createdBy} (@${createdBy})` + - (groupName ? `\n• Grupo: ${groupName}` : ''), + message: [ + `🔔 ${taskId}${dueDate ? ` — 📅 ${dueDate}` : ''}`, + `“*${description || '(sin descripción)'}*”`, + groupName ? `Grupo: ${groupName}` : null, + `Completar: /t x ${taskId}` + ].filter(Boolean).join('\n'), mentions: [creatorJid] }); } - // 3) Opcional: mensaje al grupo con menciones para visibilidad - if (groupIdToUse && process.env.NOTIFY_GROUP_ON_CREATE === 'true') { - const assignNamesForGroup = await Promise.all( - assignmentUserIds.map(async uid => '@' + (await ContactsService.getDisplayName(uid) || uid)) - ); - - responses.push({ - recipient: groupIdToUse, - message: - `📝 Tarea ${taskId} creada por ${creatorName || createdBy}:\n` + - `• ${description || '(sin descripción)'}\n` + - (dueDate ? `• Vence: ${dueDate}\n` : '') + - (assignNamesForGroup.length ? `• Asignados: ${assignNamesForGroup.join(' ')}` : ''), - mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined - }); - } + return responses; } static async handle(context: CommandContext): Promise { - if (!context.message.startsWith('/tarea')) { + const msg = (context.message || '').trim(); + if (!/^\/(tarea|t)\b/i.test(msg)) { return []; }