diff --git a/src/services/command.ts b/src/services/command.ts index 0f859d3..83ef7c5 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1019,23 +1019,66 @@ export class CommandService { } // Parseo específico de "nueva" - // Normalizar menciones del contexto para parseo y asignaciones + // Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles) + const MIN_FALLBACK_DIGITS = (() => { + const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim(); + const n = parseInt(raw || '8', 10); + return Number.isFinite(n) && n > 0 ? n : 8; + })(); + + type FailReason = 'non_numeric' | 'too_short' | 'invalid'; + const isDigits = (s: string) => /^\d+$/.test(s); + const plausibility = (s: string): { ok: boolean; reason?: FailReason } => { + if (!s) return { ok: false, reason: 'invalid' }; + if (!isDigits(s)) return { ok: false, reason: 'non_numeric' }; + if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' }; + return { ok: true }; + }; + const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => { + try { + const gid = isGroupId(context.groupId) ? context.groupId : 'dm'; + Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason }); + } catch {} + }; + + // 1) Menciones aportadas por el backend (JIDs crudos) const mentionsNormalizedFromContext = Array.from(new Set( - (context.mentions || []) - .map(j => normalizeWhatsAppId(j)) - .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) - .filter((id): id is string => !!id) + (context.mentions || []).map((j) => { + const norm = normalizeWhatsAppId(j); + if (!norm) { + incOnboardingFailure('mentions', 'invalid'); + return null; + } + const resolved = IdentityService.resolveAliasOrNull(norm); + if (resolved) return resolved; + const p = plausibility(norm); + if (p.ok) return norm; + incOnboardingFailure('mentions', p.reason!); + return null; + }).filter((id): id is string => !!id) )); - // Detectar también tokens de texto que empiezan por '@' como posibles asignados + + // 2) Tokens de texto que empiezan por '@' como posibles asignados const atTokenCandidates = tokens.slice(2) .filter(t => t.startsWith('@')) - .map(t => t.replace(/^@+/, '')); + .map(t => t.replace(/^@+/, '').replace(/^\+/, '')); const normalizedFromAtTokens = Array.from(new Set( - atTokenCandidates - .map(v => normalizeWhatsAppId(v)) - .map(id => id ? IdentityService.resolveAliasOrNull(id) : null) - .filter((id): id is string => !!id) + atTokenCandidates.map((v) => { + const norm = normalizeWhatsAppId(v); + if (!norm) { + incOnboardingFailure('tokens', 'invalid'); + return null; + } + const resolved = IdentityService.resolveAliasOrNull(norm); + if (resolved) return resolved; + const p = plausibility(norm); + if (p.ok) return norm; + incOnboardingFailure('tokens', p.reason!); + return null; + }).filter((id): id is string => !!id) )); + + // 3) Unir y deduplicar const combinedAssigneeCandidates = Array.from(new Set([ ...mentionsNormalizedFromContext, ...normalizedFromAtTokens diff --git a/tests/unit/services/command.nueva-assignees.test.ts b/tests/unit/services/command.nueva-assignees.test.ts new file mode 100644 index 0000000..293d423 --- /dev/null +++ b/tests/unit/services/command.nueva-assignees.test.ts @@ -0,0 +1,121 @@ +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 - /t nueva (A2: fallback menciones)', () => { + 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'; + process.env.CHATBOT_PHONE_NUMBER = '1234567890'; + process.env.ONBOARDING_FALLBACK_MIN_DIGITS = '8'; + Metrics.reset(); + + memdb.exec(` + DELETE FROM task_assignments; + DELETE FROM tasks; + DELETE FROM users; + DELETE FROM user_preferences; + `); + }); + + function getLastTaskId(): number { + const row = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any; + return row ? Number(row.id) : 0; + } + + 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('asigna por mención de JID real sin alias (fallback a dígitos)', async () => { + const res = await CommandService.handle({ + sender: '111', + groupId: '', // DM + message: '/t n Tarea por mención', + mentions: ['34600123456@s.whatsapp.net'], + }); + expect(res.length).toBeGreaterThan(0); + + const taskId = getLastTaskId(); + expect(taskId).toBeGreaterThan(0); + const assignees = getAssignees(taskId); + expect(assignees).toContain('34600123456'); + }); + + it('asigna por token @ con + (fallback y normalización de +)', async () => { + const res = await CommandService.handle({ + sender: '222', + groupId: '', + message: '/t nueva Tarea token @+34600123456', + mentions: [], + }); + expect(res.length).toBeGreaterThan(0); + + const taskId = getLastTaskId(); + const assignees = getAssignees(taskId); + expect(assignees).toContain('34600123456'); + }); + + it('mención/tokens irrecuperables: no bloquea, incrementa métrica', async () => { + const res = await CommandService.handle({ + sender: '333', + groupId: '', + message: '/t n Mixta @34600123456 @lid-opaque', + mentions: [], + }); + expect(res.length).toBeGreaterThan(0); + + const taskId = getLastTaskId(); + const assignees = getAssignees(taskId); + expect(assignees).toContain('34600123456'); + + const prom = Metrics.render('prom'); + expect(prom).toContain('onboarding_assign_failures_total'); + expect(prom).toContain('source="tokens"'); + }); + + it('filtra el número del bot entre candidatos', async () => { + // CHATBOT_PHONE_NUMBER = '1234567890' (ver beforeEach) + const res = await CommandService.handle({ + sender: '444', + groupId: '', + message: '/t n Asignar @1234567890 @34600123456', + mentions: [], + }); + expect(res.length).toBeGreaterThan(0); + + const taskId = getLastTaskId(); + const assignees = getAssignees(taskId); + expect(assignees).toContain('34600123456'); + expect(assignees).not.toContain('1234567890'); + }); + + it('deduplica cuando el mismo usuario aparece por mención y token', async () => { + const res = await CommandService.handle({ + sender: '555', + groupId: '', + message: '/t n Dedupe @34600123456', + mentions: ['34600123456@s.whatsapp.net'], + }); + expect(res.length).toBeGreaterThan(0); + + const taskId = getLastTaskId(); + const assignees = getAssignees(taskId); + // Solo un registro para el mismo usuario + const count = assignees.filter(v => v === '34600123456').length; + expect(count).toBe(1); + }); +});