From b6aab7fa1b8b5261b5618991dcccf9d07ed1d48e Mon Sep 17 00:00:00 2001 From: brobert Date: Sat, 25 Oct 2025 23:00:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20permitir=20autoasignaci=C3=B3n=20con=20?= =?UTF-8?q?yo/@yo=20en=20/t=20nueva=20y=20a=C3=B1adir=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/command.ts | 25 +++- .../unit/services/command.self-assign.test.ts | 126 ++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 tests/unit/services/command.self-assign.test.ts diff --git a/src/services/command.ts b/src/services/command.ts index c44a9ab..a74f940 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -43,6 +43,7 @@ export class CommandService { action: string; description: string; dueDate: string | null; + selfAssign: boolean; } { const parts = (message || '').trim().split(/\s+/); const action = (parts[1] || '').toLowerCase(); @@ -109,6 +110,8 @@ export class CommandService { type DateCandidate = { index: number; ymd: string }; const dateCandidates: DateCandidate[] = []; const dateTokenIndexes = new Set(); + const selfTokenIndexes = new Set(); + let selfAssign = false; for (let i = 2; i < parts.length; i++) { // Normalizar token: minúsculas y sin puntuación adyacente simple @@ -136,6 +139,13 @@ export class CommandService { dateTokenIndexes.add(i); continue; } + + // Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple) + if (low === 'yo' || low === '@yo') { + selfAssign = true; + selfTokenIndexes.add(i); + continue; + } } const dueDate = dateCandidates.length > 0 @@ -147,6 +157,7 @@ export class CommandService { const descriptionTokens: string[] = []; for (let i = 2; i < parts.length; i++) { if (dateTokenIndexes.has(i)) continue; + if (selfTokenIndexes.has(i)) continue; const token = parts[i]; if (isMentionToken(token)) continue; descriptionTokens.push(token); @@ -154,7 +165,7 @@ export class CommandService { const description = descriptionTokens.join(' ').trim(); - return { action, description, dueDate }; + return { action, description, dueDate, selfAssign }; } private static resolveTaskIdFromInput(n: number): number | null { @@ -1062,6 +1073,10 @@ export class CommandService { .map(t => t.replace(/^@+/, '').replace(/^\+/, '')); const normalizedFromAtTokens = Array.from(new Set( atTokenCandidates.map((v) => { + // Token especial: '@yo' (capturado como 'yo' tras limpiar '@') => autoasignación; no cuenta como fallo + if (String(v).toLowerCase() === 'yo') { + return null; + } const norm = normalizeWhatsAppId(v); if (!norm) { // agregar a no resolubles para JIT (texto ya viene sin @/+) @@ -1096,7 +1111,7 @@ export class CommandService { }); } - const { description, dueDate } = this.parseNueva(trimmed, mentionsNormalizedFromContext); + const { description, dueDate, selfAssign } = this.parseNueva(trimmed, mentionsNormalizedFromContext); // Asegurar creador const createdBy = ensureUserExists(context.sender, this.dbInstance); @@ -1107,8 +1122,10 @@ export class CommandService { // 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) + [ + ...(selfAssign ? [context.sender] : []), + ...combinedAssigneeCandidates + ].filter(id => !botNumber || id !== botNumber) )); // Asegurar usuarios asignados diff --git a/tests/unit/services/command.self-assign.test.ts b/tests/unit/services/command.self-assign.test.ts new file mode 100644 index 0000000..e4958bc --- /dev/null +++ b/tests/unit/services/command.self-assign.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeAll, beforeEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { CommandService } from '../../../src/services/command'; +import { Metrics } from '../../../src/services/metrics'; + +describe('CommandService - autoasignación con "yo" / "@yo"', () => { + let memdb: Database; + + beforeAll(() => { + memdb = new Database(':memory:'); + initializeDatabase(memdb); + TaskService.dbInstance = memdb; + CommandService.dbInstance = memdb; + }); + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.METRICS_ENABLED = 'true'; + Metrics.reset?.(); + + memdb.exec(` + DELETE FROM task_assignments; + DELETE FROM tasks; + DELETE FROM users; + DELETE FROM user_preferences; + `); + }); + + function getLastTask() { + return memdb.prepare(`SELECT id, description FROM tasks ORDER BY id DESC LIMIT 1`).get() as any; + } + + function getAssignees(taskId: number): string[] { + const rows = memdb.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`).all(taskId) as any[]; + return rows.map(r => String(r.user_id)); + } + + it('en grupo: "yo" autoasigna al remitente y no queda en la descripción', async () => { + const sender = '600111222'; + await CommandService.handle({ + sender, + groupId: '12345@g.us', // contexto grupo + message: '/t n Hacer algo yo', + mentions: [], + }); + + const t = getLastTask(); + const assignees = getAssignees(Number(t.id)); + expect(assignees).toContain(sender); + expect(String(t.description)).toBe('Hacer algo'); + }); + + it('en grupo: "@yo" autoasigna y no incrementa métricas de fallo', async () => { + const sender = '600222333'; + await CommandService.handle({ + sender, + groupId: 'group@g.us', + message: '/t n Revisar docs @yo', + mentions: [], + }); + + const t = getLastTask(); + const assignees = getAssignees(Number(t.id)); + expect(assignees).toContain(sender); + expect(String(t.description)).toBe('Revisar docs'); + + const prom = Metrics.render?.('prom') || ''; + expect(prom).not.toContain('onboarding_assign_failures_total'); + }); + + it('no falsos positivos: "yoyo" y "hoyo" no autoasignan en grupo (queda sin dueño)', async () => { + const sender = '600333444'; + // yoyo + await CommandService.handle({ + sender, + groupId: 'grp@g.us', + message: '/t n Caso yoyo', + mentions: [], + }); + let t = getLastTask(); + let assignees = getAssignees(Number(t.id)); + expect(assignees.length).toBe(0); + + // hoyo + await CommandService.handle({ + sender, + groupId: 'grp@g.us', + message: '/t n Voy a cavar un hoyo', + mentions: [], + }); + t = getLastTask(); + assignees = getAssignees(Number(t.id)); + expect(assignees.length).toBe(0); + }); + + it('combinado: "yo @34600123456" asigna al remitente y al otro usuario', async () => { + const sender = '600444555'; + await CommandService.handle({ + sender, + groupId: 'g@g.us', + message: '/t n Tarea combinada yo @34600123456', + mentions: [], + }); + + const t = getLastTask(); + const assignees = getAssignees(Number(t.id)); + expect(new Set(assignees)).toEqual(new Set([sender, '34600123456'])); + }); + + it('en DM: "yo" también se asigna al remitente y no queda en la descripción', async () => { + const sender = '600555666'; + await CommandService.handle({ + sender, + groupId: `${sender}@s.whatsapp.net`, // DM + message: '/t n Mi tarea yo', + mentions: [], + }); + + const t = getLastTask(); + const assignees = getAssignees(Number(t.id)); + expect(assignees).toContain(sender); + expect(String(t.description)).toBe('Mi tarea'); + }); +});