diff --git a/src/services/command.ts b/src/services/command.ts index 188ed43..b2cb9cd 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -12,6 +12,7 @@ import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; import { ResponseQueue } from './response-queue'; import { randomTokenBase64Url, sha256Hex } from '../utils/crypto'; +import { route as routeCommand } from './commands'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -1442,7 +1443,8 @@ Puedes interactuar escribiéndome por privado: } try { - const responses = await this.processTareaCommand(context); + const routed = await routeCommand(context); + const responses = routed ?? (await this.processTareaCommand(context)); // Clasificación explícita del outcome (evita lógica en server) const tokens = msg.split(/\s+/); diff --git a/src/services/commands/index.ts b/src/services/commands/index.ts new file mode 100644 index 0000000..d558979 --- /dev/null +++ b/src/services/commands/index.ts @@ -0,0 +1,26 @@ +/** + * Router de comandos (Etapa 1) + * Por ahora no maneja nada y devuelve null para forzar fallback al CommandService actual. + * Nota: No importar CommandService aquí para evitar ciclos de import. + */ + +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): Promise { + // En esta etapa no se maneja nada; devolver null para usar el código actual. + return null; +} diff --git a/src/services/commands/shared.ts b/src/services/commands/shared.ts new file mode 100644 index 0000000..3403efd --- /dev/null +++ b/src/services/commands/shared.ts @@ -0,0 +1,123 @@ +/** + * Utilidades compartidas para handlers de comandos (Etapa 1). + * Aún no se usan desde CommandService; servirán en etapas siguientes. + */ + +import { TaskService } from '../../tasks/service'; +import { GroupSyncService } from '../group-sync'; + +export const ACTION_ALIASES: Record = { + 'n': 'nueva', + 'nueva': 'nueva', + 'crear': 'nueva', + '+': 'nueva', + 'ver': 'ver', + 'mostrar': 'ver', + 'listar': 'ver', + 'ls': 'ver', + 'mias': 'ver', + 'mías': 'ver', + 'todas': 'ver', + 'todos': 'ver', + 'x': 'completar', + 'hecho': 'completar', + 'completar': 'completar', + 'done': 'completar', + 'tomar': 'tomar', + 'claim': 'tomar', + 'asumir': 'tomar', + 'asumo': 'tomar', + 'soltar': 'soltar', + 'unassign': 'soltar', + 'dejar': 'soltar', + 'liberar': 'soltar', + 'renunciar': 'soltar', + 'ayuda': 'ayuda', + 'help': 'ayuda', + 'info': 'ayuda', + '?': 'ayuda', + 'config': 'configurar', + 'configurar': 'configurar', + 'web': 'web' +}; + +export const SCOPE_ALIASES: Record = { + 'todo': 'todos', + 'todos': 'todos', + 'todas': 'todos', + 'mis': 'mis', + 'mias': 'mis', + 'mías': 'mis', + 'yo': 'mis' +}; + +/** + * Formatea un Date a YYYY-MM-DD respetando TZ (por defecto Europe/Madrid). + */ +export function ymdInTZ(d: Date, tz?: string): string { + const TZ = (tz && tz.trim()) || (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid'; + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || ''; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +export function todayYMD(tz?: string): string { + return ymdInTZ(new Date(), tz); +} + +/** + * Parsea múltiples IDs desde tokens, deduplica, y aplica límite. + */ +export function parseMultipleIds(tokens: string[], max: number = 10): { ids: number[]; truncated: boolean } { + const raw = (tokens || []).join(' ').trim(); + const all = raw + ? raw + .split(/[,\s]+/) + .map(t => t.trim()) + .filter(Boolean) + .map(t => parseInt(t, 10)) + .filter(n => Number.isFinite(n) && n > 0) + : []; + const dedup: number[] = []; + const seen = new Set(); + for (const n of all) { + if (!seen.has(n)) { + seen.add(n); + dedup.push(n); + } + } + const truncated = dedup.length > max; + const ids = dedup.slice(0, max); + return { ids, truncated }; +} + +/** + * Resuelve un ID de entrada (display_code) a task.id si está activa. + */ +export function resolveTaskIdFromInput(n: number): number | null { + const byCode = TaskService.getActiveTaskByDisplayCode(n); + return byCode ? byCode.id : null; +} + +/** + * Aplica la política de membresía para acciones sobre una tarea. + * Devuelve true si el usuario está permitido según flags/env. + */ +export function enforceMembership(sender: string, task: { group_id?: string | null }, enforceFlag?: boolean): boolean { + const enforce = + typeof enforceFlag === 'boolean' + ? enforceFlag + : String(process.env.GROUP_MEMBERS_ENFORCE || '').toLowerCase() === 'true'; + + const gid = task?.group_id || null; + if (!gid) return true; // tareas personales no requieren membresía + if (!enforce) return true; + if (!GroupSyncService.isSnapshotFresh(gid)) return true; + + return GroupSyncService.isUserActiveInGroup(sender, gid); +}