feat: añadir fallback numérico y métricas en CommandService (A2)

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent de3c47049b
commit 8b1af56764

@ -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

@ -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);
});
});
Loading…
Cancel
Save