feat: centralizar /tarea en CommandService y usar DB

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 2 months ago
parent db790064af
commit a209c40ac3

@ -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
}]);
}
}

@ -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<CommandResponse[]> {
// 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
}];
}

@ -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;
});
});

Loading…
Cancel
Save