diff --git a/src/server.ts b/src/server.ts index 8fad304..cd73da1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import type { Database } from 'bun:sqlite'; import { CommandService } from './services/command'; 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 { ensureUserExists, db } from './db'; @@ -148,86 +149,25 @@ export class WebhookServer { // 2. Starts with /tarea command const messageText = data.message.conversation; if (typeof messageText === 'string' && messageText.trim().startsWith('/tarea')) { - // Parse command components - const commandParts = messageText.trim().split(/\s+/); - const command = commandParts[0].toLowerCase(); - - if (commandParts.length < 2) { - console.log('⚠️ Invalid /tarea command - missing action'); - return; - } - - const action = commandParts[1].toLowerCase(); - let descriptionParts = []; - let dueDate = ''; - - // Process remaining parts - const dateCandidates = []; - - // First collect all valid future dates - for (let i = 2; i < commandParts.length; i++) { - const part = commandParts[i]; - // Check for date (YYYY-MM-DD) - if (/^\d{4}-\d{2}-\d{2}$/.test(part)) { - const date = new Date(part); - const today = new Date(); - today.setHours(0, 0, 0, 0); - - if (date >= today) { - dateCandidates.push({ index: i, date, part }); - } - } - } - - // Use the last valid future date as due date - if (dateCandidates.length > 0) { - const lastDate = dateCandidates[dateCandidates.length - 1]; - dueDate = lastDate.part; - - // Add all parts except the last date to description - for (let i = 2; i < commandParts.length; i++) { - // Skip the due date part - if (i === lastDate.index) continue; - - // Add to description if it's a date candidate but not selected or a regular part - const part = commandParts[i]; - descriptionParts.push(part); - } - } else { - // No valid future dates, add all parts to description - descriptionParts = commandParts.slice(2); - } - - const description = descriptionParts.join(' ').trim(); + // Extraer menciones desde el mensaje + const mentions = data.message?.contextInfo?.mentionedJid || []; - console.log('🔍 Detected /tarea command:', { - timestamp: new Date().toISOString(), - from: data.key.participant, - group: data.key.remoteJid, - rawMessage: messageText - }); + // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) + (CommandService as any).dbInstance = WebhookServer.dbInstance; + (TaskService as any).dbInstance = WebhookServer.dbInstance; - const mentions = data.message?.contextInfo?.mentionedJid || []; - console.log('✅ Successfully parsed command:', { - action, - description, - dueDate: dueDate || 'none', - mentionCount: mentions.length + // Delegar el manejo del comando + const responses = await CommandService.handle({ + sender: normalizedSenderId, + groupId: data.key.remoteJid, + message: messageText, + mentions }); - // Implement command processing logic here for real contact - let responseMessage = 'Comando procesado'; - if (action === 'nueva') { - responseMessage = `Tarea creada: ${description}${dueDate ? ` (fecha límite: ${dueDate})` : ''}`; - } else { - responseMessage = `Acción ${action} no implementada aún`; + // Encolar respuestas si las hay + if (responses.length > 0) { + await ResponseQueue.add(responses); } - - // Queue response for sending - await ResponseQueue.add([{ - recipient: normalizedSenderId, - message: responseMessage - }]); } } diff --git a/src/services/command.ts b/src/services/command.ts index 4f6c231..9bcc821 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1,8 +1,14 @@ +import type { Database } from 'bun:sqlite'; +import { db, ensureUserExists } from '../db'; +import { normalizeWhatsAppId } from '../utils/whatsapp'; +import { TaskService } from '../tasks/service'; +import { GroupSyncService } from './group-sync'; + type CommandContext = { - sender: string; - groupId: string; - message: string; - mentions: string[]; + 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 = { @@ -11,13 +17,121 @@ export type CommandResponse = { }; export class CommandService { + static dbInstance: Database = db; + + private static parseNueva(message: string): { + action: string; + description: string; + dueDate: string | null; + } { + 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 }[] = []; + for (let i = 2; i < parts.length; i++) { + const p = parts[i]; + 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 }); + } + } + } + + let dueDate: string | null = null; + let descriptionTokens: string[] = []; + + 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; + descriptionTokens.push(parts[i]); + } + } else { + descriptionTokens = parts.slice(2); + } + + const description = descriptionTokens.join(' ').trim(); + + return { + action, + description, + dueDate, + }; + } + private static async processTareaCommand( context: CommandContext ): Promise { - // Will implement actual command logic later + const trimmed = (context.message || '').trim(); + const tokens = trimmed.split(/\s+/); + const action = (tokens[1] || '').toLowerCase(); + + if (action !== 'nueva') { + return [{ + recipient: context.sender, + message: `Acción ${action || '(vacía)'} no implementada aún` + }]; + } + + // Parseo específico de "nueva" + const { description, dueDate } = this.parseNueva(trimmed); + + // 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( + (context.mentions || []) + .map(j => normalizeWhatsAppId(j)) + .filter((id): id is string => !!id) + .filter(id => !botNumber || id !== botNumber) + )); + + // Asegurar usuarios asignados + const ensuredAssignees = assigneesNormalized + .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]; + + // 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 assignedList = assignmentUserIds.join(', '); + const resp = + `✅ Tarea ${taskId} creada: "${description || '(sin descripción)'}"` + + (dueDate ? ` (vence ${dueDate})` : '') + + (assignedList ? ` — asignados: ${assignedList}` : ''); + return [{ - recipient: context.sender, - message: 'Command received: ' + context.message + recipient: createdBy, + message: resp }]; } diff --git a/tests/unit/services/command.test.ts b/tests/unit/services/command.test.ts index 383e380..1ffac6f 100644 --- a/tests/unit/services/command.test.ts +++ b/tests/unit/services/command.test.ts @@ -1,34 +1,62 @@ -import { describe, test, expect, beforeEach, mock } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; import { CommandService } from '../../../src/services/command'; +import { TaskService } from '../../../src/tasks/service'; -const testContext = { +let memDb: Database; +const testContextBase = { sender: '1234567890', groupId: 'test-group@g.us', - message: '/tarea nueva Test task', - mentions: [] + mentions: [] as string[], }; +beforeEach(() => { + memDb = new Database(':memory:'); + initializeDatabase(memDb); + (CommandService as any).dbInstance = memDb; + (TaskService as any).dbInstance = memDb; +}); + +afterEach(() => { + try { memDb.close(); } catch {} +}); + describe('CommandService', () => { test('should ignore non-tarea commands', async () => { const responses = await CommandService.handle({ - ...testContext, + ...testContextBase, message: '/othercommand' }); expect(responses).toEqual([]); }); - test('should handle tarea commands', async () => { - const responses = await CommandService.handle(testContext); + test('should handle tarea nueva: crea y responde con id y descripción', async () => { + const responses = await CommandService.handle({ + ...testContextBase, + message: '/tarea nueva Test task' + }); + expect(responses.length).toBe(1); expect(responses[0].recipient).toBe('1234567890'); - expect(responses[0].message).toBe('Command received: /tarea nueva Test task'); + expect(responses[0].message).toMatch(/^✅ Tarea \d+ creada: "Test task"/); }); test('should return error response on failure', async () => { + // Forzar error temporalmente + const original = TaskService.createTask; + (TaskService as any).createTask = () => { throw new Error('forced'); }; + const responses = await CommandService.handle({ - ...testContext, + ...testContextBase, message: '/tarea nueva Test task' }); - expect(responses[0].message).toBe('Command received: /tarea nueva Test task'); + + expect(responses.length).toBe(1); + expect(responses[0].recipient).toBe('1234567890'); + expect(responses[0].message).toBe('Error processing command'); + + // Restaurar + (TaskService as any).createTask = original; }); });