Compare commits
29 Commits
2dc6a13e0a
...
28147446a1
| Author | SHA1 | Date |
|---|---|---|
|
|
28147446a1 | 3 days ago |
|
|
fc0eddf8b1 | 3 days ago |
|
|
0df224f0ba | 3 days ago |
|
|
4b493521ab | 3 days ago |
|
|
e2f152fd9e | 3 days ago |
|
|
fa27be673d | 3 days ago |
|
|
96daf2a643 | 3 days ago |
|
|
24962d33ff | 3 days ago |
|
|
d550d5b26a | 3 days ago |
|
|
1356a2d1d7 | 3 days ago |
|
|
6daa27f4ad | 3 days ago |
|
|
47a7def7c1 | 3 days ago |
|
|
6f88d5bc2e | 3 days ago |
|
|
a89ec3f875 | 3 days ago |
|
|
7dd390b04e | 3 days ago |
|
|
f2746a9003 | 3 days ago |
|
|
65553a14d9 | 3 days ago |
|
|
91fe688e4e | 3 days ago |
|
|
ada071d220 | 3 days ago |
|
|
5c6cac2b12 | 3 days ago |
|
|
b719f3fd33 | 3 days ago |
|
|
6fcfd2719f | 3 days ago |
|
|
d591697402 | 3 days ago |
|
|
f7229d14d4 | 3 days ago |
|
|
170859c030 | 3 days ago |
|
|
d40e5e7990 | 3 days ago |
|
|
f142975f00 | 3 days ago |
|
|
e9c2885433 | 3 days ago |
|
|
b6aab7fa1b | 3 days ago |
@ -0,0 +1,18 @@
|
||||
{
|
||||
"metrics": [
|
||||
"commands_alias_used_total",
|
||||
"ver_dm_transition_total",
|
||||
"web_tokens_issued_total",
|
||||
"commands_unknown_total",
|
||||
"commands_blocked_total",
|
||||
"onboarding_prompts_sent_total",
|
||||
"onboarding_prompts_skipped_total",
|
||||
"onboarding_assign_failures_total",
|
||||
"onboarding_bundle_sent_total",
|
||||
"onboarding_recipients_capped_total",
|
||||
"onboarding_dm_skipped_total"
|
||||
],
|
||||
"labels": {
|
||||
"commands_alias_used_total": ["info", "mias", "todas"]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,262 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ensureUserExists } from '../../../db';
|
||||
import { normalizeWhatsAppId, isGroupId } from '../../../utils/whatsapp';
|
||||
import { TaskService } from '../../../tasks/service';
|
||||
import { GroupSyncService } from '../../group-sync';
|
||||
import { ContactsService } from '../../contacts';
|
||||
import { IdentityService } from '../../identity';
|
||||
import { Metrics } from '../../metrics';
|
||||
import { ICONS } from '../../../utils/icons';
|
||||
import { codeId, formatDDMM } from '../../../utils/formatting';
|
||||
import { parseNueva } from '../parsers/nueva';
|
||||
import { CTA_HELP } from '../shared';
|
||||
import { buildJitAssigneePrompt, maybeEnqueueOnboardingBundle } from '../../onboarding';
|
||||
|
||||
type Ctx = {
|
||||
sender: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
mentions: string[];
|
||||
messageId?: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
type Msg = {
|
||||
recipient: string;
|
||||
message: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
|
||||
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
|
||||
const tokens = (context.message || '').trim().split(/\s+/);
|
||||
|
||||
// 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;
|
||||
})();
|
||||
const MAX_FALLBACK_DIGITS = (() => {
|
||||
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
|
||||
const n = parseInt(raw || '15', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 15;
|
||||
})();
|
||||
|
||||
type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
|
||||
const isDigits = (s: string) => /^\d+$/.test(s);
|
||||
const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
|
||||
if (!s) return { ok: false, reason: 'invalid' };
|
||||
if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
|
||||
if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
|
||||
if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
|
||||
if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
|
||||
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 unresolvedAssigneeDisplays: string[] = [];
|
||||
const mentionsNormalizedFromContext = Array.from(new Set(
|
||||
(context.mentions || []).map((j) => {
|
||||
const norm = normalizeWhatsAppId(j);
|
||||
if (!norm) {
|
||||
// agregar a no resolubles para JIT (mostrar sin @ ni dominio)
|
||||
const raw = String(j || '');
|
||||
const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
|
||||
if (disp) unresolvedAssigneeDisplays.push(disp);
|
||||
incOnboardingFailure('mentions', 'invalid');
|
||||
return null;
|
||||
}
|
||||
const resolved = IdentityService.resolveAliasOrNull(norm);
|
||||
if (resolved) return resolved;
|
||||
// detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
|
||||
const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
|
||||
const fromLid = dom.includes('lid');
|
||||
const p = plausibility(norm, { fromLid });
|
||||
if (p.ok) return norm;
|
||||
// conservar para copy JIT
|
||||
unresolvedAssigneeDisplays.push(norm);
|
||||
incOnboardingFailure('mentions', p.reason!);
|
||||
return null;
|
||||
}).filter((id): id is string => !!id)
|
||||
));
|
||||
|
||||
// 2) Tokens de texto que empiezan por '@' como posibles asignados
|
||||
const atTokenCandidates = tokens.slice(2)
|
||||
.filter(t => t.startsWith('@'))
|
||||
.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
|
||||
const normalizedFromAtTokens = Array.from(new Set(
|
||||
atTokenCandidates.map((v) => {
|
||||
// Token especial: '@yo' → 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 @/+)
|
||||
if (v) unresolvedAssigneeDisplays.push(v);
|
||||
incOnboardingFailure('tokens', 'invalid');
|
||||
return null;
|
||||
}
|
||||
const resolved = IdentityService.resolveAliasOrNull(norm);
|
||||
if (resolved) return resolved;
|
||||
const p = plausibility(norm, { fromLid: false });
|
||||
if (p.ok) return norm;
|
||||
// conservar para copy JIT (preferimos el token limpio v)
|
||||
unresolvedAssigneeDisplays.push(v);
|
||||
incOnboardingFailure('tokens', p.reason!);
|
||||
return null;
|
||||
}).filter((id): id is string => !!id)
|
||||
));
|
||||
|
||||
// 3) Unir y deduplicar
|
||||
const combinedAssigneeCandidates = Array.from(new Set([
|
||||
...mentionsNormalizedFromContext,
|
||||
...normalizedFromAtTokens
|
||||
]));
|
||||
|
||||
const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext);
|
||||
|
||||
// Asegurar creador
|
||||
const createdBy = ensureUserExists(context.sender, deps.db);
|
||||
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(
|
||||
[
|
||||
...(selfAssign ? [context.sender] : []),
|
||||
...combinedAssigneeCandidates
|
||||
].filter(id => !botNumber || id !== botNumber)
|
||||
));
|
||||
|
||||
// Asegurar usuarios asignados
|
||||
const ensuredAssignees = assigneesNormalized
|
||||
.map(id => ensureUserExists(id, deps.db))
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
// Asignación por defecto según contexto:
|
||||
// - En grupos: si no hay menciones → sin dueño (ningún asignado)
|
||||
// - En DM: si no hay menciones → asignada al creador
|
||||
let assignmentUserIds: string[] = [];
|
||||
if (ensuredAssignees.length > 0) {
|
||||
assignmentUserIds = ensuredAssignees;
|
||||
} else {
|
||||
assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [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,
|
||||
}))
|
||||
);
|
||||
|
||||
// Registrar origen del comando para esta tarea (si aplica)
|
||||
try {
|
||||
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
|
||||
const participant = typeof context.participant === 'string' ? context.participant : null;
|
||||
const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null;
|
||||
try {
|
||||
deps.db.prepare(`
|
||||
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(taskId, groupIdToUse, context.messageId, participant, fromMe);
|
||||
} catch {
|
||||
deps.db.prepare(`
|
||||
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, groupIdToUse, context.messageId);
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// Recuperar la tarea creada para obtener display_code asignado
|
||||
const createdTask = TaskService.getTaskById(taskId);
|
||||
|
||||
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
|
||||
|
||||
// Resolver nombres útiles
|
||||
const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;
|
||||
|
||||
const assignedDisplayNames = await Promise.all(
|
||||
assignmentUserIds.map(async uid => {
|
||||
const name = await ContactsService.getDisplayName(uid);
|
||||
return name || uid;
|
||||
})
|
||||
);
|
||||
|
||||
const responses: Msg[] = [];
|
||||
|
||||
// 1) Ack al creador con formato compacto
|
||||
const dueFmt = formatDDMM(dueDate);
|
||||
const ownerPart = assignmentUserIds.length === 0
|
||||
? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}`
|
||||
: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
|
||||
const ackLines = [
|
||||
`${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`,
|
||||
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
|
||||
ownerPart
|
||||
].filter(Boolean);
|
||||
responses.push({
|
||||
recipient: createdBy,
|
||||
message: [ackLines.join('\n'), '', CTA_HELP].join('\n'),
|
||||
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
|
||||
});
|
||||
|
||||
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
|
||||
for (const uid of assignmentUserIds) {
|
||||
if (uid === createdBy) continue;
|
||||
responses.push({
|
||||
recipient: uid,
|
||||
message: [
|
||||
`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
|
||||
`${description || '(sin descripción)'}`,
|
||||
formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
|
||||
groupName ? `Grupo: ${groupName}` : null,
|
||||
`- Completar: \`/t x ${createdTask?.display_code}\``,
|
||||
`- Soltar: \`/t soltar ${createdTask?.display_code}\``
|
||||
].filter(Boolean).join('\n') + '\n\n' + CTA_HELP,
|
||||
mentions: [`${createdBy}@s.whatsapp.net`]
|
||||
});
|
||||
}
|
||||
|
||||
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
|
||||
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays));
|
||||
|
||||
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
|
||||
try {
|
||||
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
|
||||
maybeEnqueueOnboardingBundle(deps.db, {
|
||||
gid,
|
||||
createdBy,
|
||||
assignmentUserIds,
|
||||
taskId,
|
||||
displayCode: createdTask?.display_code ?? null,
|
||||
description: description || ''
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Router de comandos (Etapa 3)
|
||||
* Maneja 'configurar' y 'web', y delega el resto al código actual (null → fallback).
|
||||
* Nota: No importar CommandService aquí para evitar ciclos de import.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ACTION_ALIASES } from './shared';
|
||||
import { handleConfigurar } from './handlers/configurar';
|
||||
import { handleWeb } from './handlers/web';
|
||||
import { handleVer } from './handlers/ver';
|
||||
import { handleCompletar } from './handlers/completar';
|
||||
import { handleTomar } from './handlers/tomar';
|
||||
import { handleSoltar } from './handlers/soltar';
|
||||
import { handleNueva } from './handlers/nueva';
|
||||
import { ResponseQueue } from '../response-queue';
|
||||
import { isGroupId } from '../../utils/whatsapp';
|
||||
import { Metrics } from '../metrics';
|
||||
|
||||
function getQuickHelp(): string {
|
||||
return [
|
||||
'Guía rápida:',
|
||||
'- Ver tus tareas: `/t mias`',
|
||||
'- Ver todas: `/t todas`',
|
||||
'- Crear: `/t n Descripción 2028-11-26 @Ana`',
|
||||
'- Completar: `/t x 123`',
|
||||
'- Tomar: `/t tomar 12`',
|
||||
'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'- Web: `/t web`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getFullHelp(): string {
|
||||
return [
|
||||
'Ayuda avanzada:',
|
||||
'Comandos y alias:',
|
||||
' · Crear: `n`, `nueva`, `crear`, `+`',
|
||||
' · Ver: `ver`, `listar`, `mostrar`, `ls` (scopes: `mis` | `todas`)',
|
||||
' · Completar: `x`, `hecho`, `completar`, `done`',
|
||||
' · Tomar: `tomar`, `claim`',
|
||||
' · Soltar: `soltar`, `unassign`',
|
||||
'Preferencias:',
|
||||
' · `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'Fechas:',
|
||||
' · `YYYY-MM-DD` o `YY-MM-DD` → `20YY-MM-DD` (ej.: 27-09-04)',
|
||||
' · Palabras: `hoy`, `mañana`',
|
||||
'Acceso web:',
|
||||
' · `/t web`',
|
||||
'Atajos:',
|
||||
' · `/t mias`',
|
||||
' · `/t todas`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUnknownHelp(): string {
|
||||
const header = '❓ COMANDO NO RECONOCIDO';
|
||||
const cta = 'Prueba `/t ayuda`';
|
||||
return [header, cta, '', getQuickHelp()].join('\n');
|
||||
}
|
||||
|
||||
export type RoutedMessage = {
|
||||
recipient: string;
|
||||
message: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
export type RouteContext = {
|
||||
sender: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
mentions: string[];
|
||||
messageId?: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
export async function route(context: RouteContext, deps?: { db: Database }): Promise<RoutedMessage[] | null> {
|
||||
const trimmed = (context.message || '').trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
const rawAction = (tokens[1] || '').toLowerCase();
|
||||
const action = ACTION_ALIASES[rawAction] || rawAction;
|
||||
|
||||
// Ayuda (no requiere DB)
|
||||
if (action === 'ayuda') {
|
||||
// Métrica de alias "info" (compatibilidad con legacy)
|
||||
try {
|
||||
if (rawAction === 'info' || rawAction === '?') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
|
||||
}
|
||||
} catch {}
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
|
||||
const message = isAdvanced
|
||||
? getFullHelp()
|
||||
: [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n');
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message
|
||||
}];
|
||||
}
|
||||
|
||||
// Requiere db inyectada para poder operar (CommandService la inyecta)
|
||||
const database = deps?.db;
|
||||
if (!database) return null;
|
||||
|
||||
if (action === 'nueva') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleNueva(context as any, { db: database });
|
||||
}
|
||||
|
||||
if (action === 'ver') {
|
||||
// Métricas de alias (mias/todas) como en el código actual
|
||||
try {
|
||||
if (rawAction === 'mias' || rawAction === 'mías') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'mias' });
|
||||
} else if (rawAction === 'todas' || rawAction === 'todos') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'todas' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
// En grupo: transición a DM
|
||||
if (isGroupId(context.groupId)) {
|
||||
try { Metrics.inc('ver_dm_transition_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
|
||||
}];
|
||||
}
|
||||
|
||||
return await handleVer(context as any);
|
||||
}
|
||||
|
||||
if (action === 'completar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleCompletar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'tomar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleTomar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'soltar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleSoltar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'configurar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return handleConfigurar(context as any, { db: database });
|
||||
}
|
||||
|
||||
if (action === 'web') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleWeb(context as any, { db: database });
|
||||
}
|
||||
|
||||
// Desconocido → ayuda rápida
|
||||
try { Metrics.inc('commands_unknown_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: buildUnknownHelp()
|
||||
}];
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
export function parseNueva(message: string, _mentionsNormalized: string[]): {
|
||||
action: string;
|
||||
description: string;
|
||||
dueDate: string | null;
|
||||
selfAssign: boolean;
|
||||
} {
|
||||
const parts = (message || '').trim().split(/\s+/);
|
||||
const action = (parts[1] || '').toLowerCase();
|
||||
|
||||
// Zona horaria configurable (por defecto Europe/Madrid)
|
||||
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
|
||||
|
||||
// Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host
|
||||
const ymdFromDateInTZ = (d: Date): string => {
|
||||
const fmt = new Intl.DateTimeFormat('es-ES', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => fmt.find(p => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
};
|
||||
|
||||
const addDaysToYMD = (ymd: string, days: number): string => {
|
||||
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
|
||||
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return ymdFromDateInTZ(base);
|
||||
};
|
||||
|
||||
const todayYMD = ymdFromDateInTZ(new Date());
|
||||
|
||||
// Helpers para validar y normalizar fechas explícitas
|
||||
const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||
const daysInMonth = (y: number, m: number) => {
|
||||
if (m === 2) return isLeap(y) ? 29 : 28;
|
||||
return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
|
||||
};
|
||||
const isValidYMD = (ymd: string): boolean => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return false;
|
||||
const Y = parseInt(m[1], 10);
|
||||
const MM = parseInt(m[2], 10);
|
||||
const DD = parseInt(m[3], 10);
|
||||
if (MM < 1 || MM > 12) return false;
|
||||
const dim = daysInMonth(Y, MM);
|
||||
if (!dim || DD < 1 || DD > dim) return false;
|
||||
return true;
|
||||
};
|
||||
const normalizeDateToken = (t: string): string | null => {
|
||||
// YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) {
|
||||
return isValidYMD(t) ? t : null;
|
||||
}
|
||||
// YY-MM-DD -> 20YY-MM-DD
|
||||
const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t);
|
||||
if (m) {
|
||||
const yy = parseInt(m[1], 10);
|
||||
const mm = m[2];
|
||||
const dd = m[3];
|
||||
const yyyy = 2000 + yy;
|
||||
const ymd = `${String(yyyy)}-${mm}-${dd}`;
|
||||
return isValidYMD(ymd) ? ymd : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type DateCandidate = { index: number; ymd: string };
|
||||
const dateCandidates: DateCandidate[] = [];
|
||||
const dateTokenIndexes = new Set<number>();
|
||||
const selfTokenIndexes = new Set<number>();
|
||||
let selfAssign = false;
|
||||
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
// Normalizar token: minúsculas y sin puntuación adyacente simple
|
||||
const raw = parts[i];
|
||||
const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, '');
|
||||
|
||||
// Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY)
|
||||
{
|
||||
const norm = normalizeDateToken(low);
|
||||
if (norm && norm >= todayYMD) {
|
||||
dateCandidates.push({ index: i, ymd: norm });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens naturales "hoy"/"mañana" (con o sin acento)
|
||||
if (low === 'hoy') {
|
||||
dateCandidates.push({ index: i, ymd: todayYMD });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
if (low === 'mañana' || low === 'manana') {
|
||||
dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) });
|
||||
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
|
||||
? dateCandidates[dateCandidates.length - 1].ymd
|
||||
: null;
|
||||
|
||||
const isMentionToken = (token: string) => token.startsWith('@');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const description = descriptionTokens.join(' ').trim();
|
||||
|
||||
return { action, description, dueDate, selfAssign };
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('en grupo: "@yo," autoasigna y no incrementa métricas de fallo', async () => {
|
||||
const sender = '600666777';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'group2@g.us',
|
||||
message: '/t n Revisar algo @yo,',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Revisar algo');
|
||||
|
||||
const prom = Metrics.render?.('prom') || '';
|
||||
expect(prom).not.toContain('onboarding_assign_failures_total');
|
||||
});
|
||||
|
||||
it('en grupo: "(yo)" autoasigna y no queda en la descripción', async () => {
|
||||
const sender = '600777888';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp2@g.us',
|
||||
message: '/t n Hacer (yo)',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Hacer');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue