From 4b493521ab30e79889c55feff8a1f7eabb3c44a4 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 26 Oct 2025 00:42:06 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20limpieza=20incremental=20de=20command.?= =?UTF-8?q?ts=20en=20bloques=20peque=C3=B1os?= 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 | 1046 +-------------------------------------- 1 file changed, 1 insertion(+), 1045 deletions(-) diff --git a/src/services/command.ts b/src/services/command.ts index f81eb98..00b66bb 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -1,20 +1,10 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; import { isGroupId } from '../utils/whatsapp'; -import { TaskService } from '../tasks/service'; -import { GroupSyncService } from './group-sync'; -import { ContactsService } from './contacts'; -import { ICONS } from '../utils/icons'; -import { padTaskId, codeId, formatDDMM, bold, italic, code, section } from '../utils/formatting'; -import { getQuickHelp, getFullHelp } from './messages/help'; -import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; -import { ResponseQueue } from './response-queue'; -import { randomTokenBase64Url } from '../utils/crypto'; import { route as routeCommand } from './commands'; -import { ACTION_ALIASES, SCOPE_ALIASES, todayYMD as todayYMD_TZ, resolveTaskIdFromInput, parseMultipleIds, enforceMembership } from './commands/shared'; -import { parseNueva } from './commands/parsers/nueva'; +import { ACTION_ALIASES } from './commands/shared'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -40,1043 +30,9 @@ export type CommandOutcome = { export class CommandService { static dbInstance: Database = db; - private static readonly CTA_HELP: string = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`'; - private static async processTareaCommand( - context: CommandContext - ): Promise { - return []; - const trimmed = (context.message || '').trim(); - const tokens = trimmed.split(/\s+/); - const rawAction = (tokens[1] || '').toLowerCase(); - const action = ACTION_ALIASES[rawAction] || rawAction; - // Métrica: uso de alias (info/mias/todas) - try { - if (rawAction === 'info') { - Metrics.inc('commands_alias_used_total', 1, { action: 'info' }); - } else 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 {} - // Refrescar métricas agregadas de onboarding tras cualquier comando (para conversión) - try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {} - - // Usar formatDDMM desde utils/formatting - - // TZ y "hoy" en TZ para marcar vencidas en listados - const todayYMD = todayYMD_TZ(); - - if (!action || action === 'ayuda') { - const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase(); - const helpV2Enabled = !['false', '0', 'no'].includes(feature); - - const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada'; - - // Fallback legacy (Help v1) - if (!helpV2Enabled) { - if (isAdvanced) { - const adv = [ - '*Ayuda avanzada:*', - 'Comandos y alias:', - ' · Crear: `n`, `nueva`, `crear`, `+`', - ' · Ver: `ver`, `mostrar`, `listar`, `ls` (opciones: `grupo` | `mis` | `todos` | `sin`)', - ' · Completar: `x`, `hecho`, `completar`, `done` (acepta varios IDs: "`/t x 14 19 24`" o "`/t x 14,19,24`"; máximo 10)', - ' · Tomar: `tomar`, `claim` (acepta varios IDs: "`/t tomar 12 19 50`" o "`/t tomar 12,19,50`"; máximo 10)', - ' · Soltar: `soltar`, `unassign`', - 'Preferencias:', - ' · `/t configurar diario|l-v|semanal|off [HH:MM]` (por defecto _08:30_; semanal: _lunes_; l-v: lunes a viernes)', - 'Notas:', - ' · En grupos, el bot responde por DM (no publica en el grupo).', - ' · Si creas una tarea en un grupo y no mencionas a nadie → “sin responsable”; en DM → se asigna a quien la crea.', - ' · Fechas dd/MM con ⚠️ si está vencida.', - ' · Mostramos los IDs de las tareas con 4 dígitos, pero puedes escribirlos sin ceros (p. ej., 26).', - ].join('\n'); - return [{ - recipient: context.sender, - message: adv - }]; - } - const help = [ - 'Guía rápida:', - '- Crear: `/t n Descripción 2028-11-26 @Ana`', - '- Ver grupo: `/t ver` (en el grupo)', - '- Ver mis tareas: `/t ver mis` (por DM)', - '- Ver todas: `/t ver todas` (por DM)', - '- Completar: `/t x 123` (máx. 10)', - '- Tomar: `/t tomar 12` (máx. 10)', - '- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`', - '- Ayuda avanzada: `/t ayuda avanzada`' - ].join('\n'); - return [{ - recipient: context.sender, - message: help - }]; - } - - // Help v2 - if (isAdvanced) { - return [{ - recipient: context.sender, - message: getFullHelp() - }]; - } - const quick = getQuickHelp(); - const msg = [quick, '', `Ayuda avanzada: ${code('/t ayuda avanzada')}`].join('\n'); - return [{ - recipient: context.sender, - message: msg - }]; - } - - // Listar pendientes - if (action === 'ver') { - return []; - const scopeRaw = (tokens[2] || '').toLowerCase(); - const scope = scopeRaw - ? (SCOPE_ALIASES[scopeRaw] || scopeRaw) - : ((rawAction === 'mias' || rawAction === 'mías') ? 'mis' : ((rawAction === 'todas' || rawAction === 'todos') ? 'todos' : 'todos')); - const LIMIT = 10; - - // En grupos: no listamos; responder por DM con transición - 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' - }]; - } - - - // Ver todos: "tus tareas" + "sin responsable" de grupos donde eres miembro activo (snapshot fresca) - if (scope === 'todos') { - const sections: string[] = []; - - // Encabezado fijo para la sección de tareas del usuario - sections.push(bold('Tus tareas')); - - // Tus tareas (mis) - const myItems = TaskService.listUserPending(context.sender, LIMIT); - if (myItems.length > 0) { - // Agrupar por grupo como en "ver mis" - const byGroup = new Map(); - for (const t of myItems) { - const key = t.group_id || '(sin grupo)'; - const arr = byGroup.get(key) || []; - arr.push(t); - byGroup.set(key, arr); - } - - for (const [groupId, arr] of byGroup.entries()) { - const groupName = - (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || - (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - - sections.push(groupName); - const rendered = await Promise.all(arr.map(async (t) => { - const names = await Promise.all( - (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) - ); - const owner = - (t.assignees?.length || 0) === 0 - ? `${ICONS.unassigned} sin responsable` - : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; - })); - sections.push(...rendered); - sections.push(''); - } - - // Quitar línea en blanco final si procede - if (sections.length > 0 && sections[sections.length - 1] === '') { - sections.pop(); - } - - const totalMy = TaskService.countUserPending(context.sender); - if (totalMy > myItems.length) { - sections.push(`… y ${totalMy - myItems.length} más`); - } - } else { - sections.push(italic('_No tienes tareas pendientes._')); - } - - // En contexto de grupo: mantener compatibilidad mostrando solo "sin responsable" del grupo actual - if (isGroupId(context.groupId)) { - if (!GroupSyncService.isGroupActive(context.groupId)) { - sections.push('⚠️ _Este grupo no está activo._'); - } else { - const groupName = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; - const unassigned = TaskService.listGroupUnassigned(context.groupId, LIMIT); - if (unassigned.length > 0) { - if (sections.length && sections[sections.length - 1] !== '') sections.push(''); - sections.push(`${groupName} — Sin responsable`); - const renderedUnassigned = unassigned.map((t) => { - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`; - }); - sections.push(...renderedUnassigned); - - const totalUnassigned = TaskService.countGroupUnassigned(context.groupId); - if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); - } - } else { - sections.push(`${groupName} — Sin responsable\n_(no hay tareas sin responsable)_`); - } - } - } else { - // En DM: usar membresía real (snapshot fresca) para incluir "sin responsable" por grupo - const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(context.sender); - if (memberGroups.length > 0) { - const perGroup = TaskService.listUnassignedByGroups(memberGroups, LIMIT); - for (const gid of perGroup.keys()) { - const unassigned = perGroup.get(gid)!; - const groupName = - (gid && GroupSyncService.activeGroupsCache.get(gid)) || - gid; - - if (unassigned.length > 0) { - if (sections.length && sections[sections.length - 1] !== '') sections.push(''); - sections.push(`${groupName} — Sin responsable`); - const renderedUnassigned = unassigned.map((t) => { - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${ICONS.unassigned}`; - }); - sections.push(...renderedUnassigned); - - const totalUnassigned = TaskService.countGroupUnassigned(gid); - if (totalUnassigned > unassigned.length) { - sections.push(`… y ${totalUnassigned - unassigned.length} más`); - } - } - } - } else { - // Si no hay snapshot fresca de membresía, mantenemos una nota instructiva mínima - sections.push('ℹ️ Para ver tareas sin responsable, escribe por privado `/t todas` o usa `/t web`.'); - } - } - - return [{ - recipient: context.sender, - message: sections.join('\n') - }]; - } - - - // Ver mis - const items = TaskService.listUserPending(context.sender, LIMIT); - if (items.length === 0) { - return [{ - recipient: context.sender, - message: italic('No tienes tareas pendientes.') - }]; - } - - const total = TaskService.countUserPending(context.sender); - - // Agrupar por grupo - const byGroup = new Map(); - for (const t of items) { - const key = t.group_id || '(sin grupo)'; - const arr = byGroup.get(key) || []; - arr.push(t); - byGroup.set(key, arr); - } - - const sections: string[] = [bold('Tus tareas')]; - for (const [groupId, arr] of byGroup.entries()) { - const groupName = - (groupId && GroupSyncService.activeGroupsCache.get(groupId)) || - (groupId && groupId !== '(sin grupo)' ? groupId : 'Sin grupo'); - - sections.push(groupName); - const rendered = await Promise.all(arr.map(async (t) => { - const names = await Promise.all( - (t.assignees || []).map(async (uid) => (await ContactsService.getDisplayName(uid)) || uid) - ); - const owner = - (t.assignees?.length || 0) === 0 - ? `${ICONS.unassigned}` - : `${t.assignees!.length > 1 ? '👥' : '👤'} ${names.join(', ')}`; - const isOverdue = t.due_date ? t.due_date < todayYMD : false; - const datePart = t.due_date ? ` — ${isOverdue ? `${ICONS.warn} ` : ''}${ICONS.date} ${formatDDMM(t.due_date)}` : ''; - return `- ${codeId(t.id, t.display_code)} ${t.description || '(sin descripción)'}${datePart} — ${owner}`; - })); - sections.push(...rendered); - sections.push(''); - } - - // Quitar línea en blanco final si procede - if (sections.length > 0 && sections[sections.length - 1] === '') { - sections.pop(); - } - - if (total > items.length) { - sections.push(`… y ${total - items.length} más`); - } - - return [{ - recipient: context.sender, - message: sections.join('\n') - }]; - } - - // Completar tarea (con validación opcional de membresía) - if (action === 'completar') { - return []; - // Soportar múltiples IDs separados por espacios y/o comas - const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); - - // Sin IDs: ayuda de uso - if (ids.length === 0) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t x 26` o múltiples: `/t x 14 19 24` o `/t x 14,19,24` (máx. 10)' - }]; - } - - // Caso de 1 ID: mantener comportamiento actual - if (ids.length === 1) { - const idInput = ids[0]; - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } - - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: 'No puedes completar esta tarea porque no eres de este grupo.' - }]; - } - - const res = TaskService.completeTask(resolvedId, context.sender); - const who = (await ContactsService.getDisplayName(context.sender)) || context.sender; - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (res.status === 'already') { - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} _Ya estaba completada_ — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - return [{ - recipient: context.sender, - message: `${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} _completada_ — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - // Modo múltiple - let cntUpdated = 0, cntAlready = 0, cntNotFound = 0, cntBlocked = 0; - const lines: string[] = []; - - if (truncated) { - lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); - } - - for (const idInput of ids) { - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); - cntNotFound++; - continue; - } - - const task = TaskService.getTaskById(resolvedId); - if (!enforceMembership(context.sender, task)) { - lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); - cntBlocked++; - continue; - } - - const res = TaskService.completeTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - if (res.status === 'already') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntAlready++; - } else if (res.status === 'updated') { - lines.push(`${ICONS.complete} ${codeId(resolvedId, res.task?.display_code)} completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntUpdated++; - } else if (res.status === 'not_found') { - lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); - cntNotFound++; - } - } - - // Resumen final - const summary: string[] = []; - if (cntUpdated) summary.push(`completadas ${cntUpdated}`); - if (cntAlready) summary.push(`ya estaban ${cntAlready}`); - if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); - if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); - if (summary.length) { - lines.push(''); - lines.push(`Resumen: ${summary.join(', ')}.`); - } - - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; - } - - // Tomar tarea (con validación opcional de membresía) - if (action === 'tomar') { - return []; - // Soportar múltiples IDs separados por espacios y/o comas - const { ids, truncated } = parseMultipleIds(tokens.slice(2), 10); - - // Sin IDs: ayuda de uso - if (ids.length === 0) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t tomar 26` o múltiples: `/t tomar 12 19 50` o `/t tomar 12,19,50` (máx. 10)' - }]; - } - - // Caso de 1 ID: mantener comportamiento actual - if (ids.length === 1) { - const idInput = ids[0]; - - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } - - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: 'No puedes tomar esta tarea porque no eres de este grupo.' - }]; - } - - const res = TaskService.claimTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'already') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - const lines = [ - italic(`${ICONS.take} Has tomado ${codeId(resolvedId, res.task?.display_code)}`), - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' - ].filter(Boolean); - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; - } - - // Modo múltiple - let cntClaimed = 0, cntAlready = 0, cntCompleted = 0, cntNotFound = 0, cntBlocked = 0; - const lines: string[] = []; - - if (truncated) { - lines.push('⚠️ Se procesarán solo los primeros 10 IDs.'); - } - - for (const idInput of ids) { - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - lines.push(`⚠️ ${codeId(idInput)} no encontrada.`); - cntNotFound++; - continue; - } - - const task = TaskService.getTaskById(resolvedId); - if (!enforceMembership(context.sender, task)) { - lines.push(`🚫 ${codeId(resolvedId)} — no permitido (no eres miembro activo).`); - cntBlocked++; - continue; - } - - const res = TaskService.claimTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - if (res.status === 'already') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya la tenías — ${res.task?.description || '(sin descripción)'}${due}`); - cntAlready++; - } else if (res.status === 'claimed') { - lines.push(`${ICONS.take} ${codeId(resolvedId, res.task?.display_code)} tomada — ${res.task?.description || '(sin descripción)'}${due}`); - cntClaimed++; - } else if (res.status === 'completed') { - lines.push(`ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}`); - cntCompleted++; - } else if (res.status === 'not_found') { - lines.push(`⚠️ ${codeId(resolvedId)} no encontrada.`); - cntNotFound++; - } - } - - // Resumen final - const summary: string[] = []; - if (cntClaimed) summary.push(`tomadas ${cntClaimed}`); - if (cntAlready) summary.push(`ya las tenías ${cntAlready}`); - if (cntCompleted) summary.push(`ya completadas ${cntCompleted}`); - if (cntNotFound) summary.push(`no encontradas ${cntNotFound}`); - if (cntBlocked) summary.push(`bloqueadas ${cntBlocked}`); - if (summary.length) { - lines.push(''); - lines.push(`Resumen: ${summary.join(', ')}.`); - } - - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; - } - - // Soltar tarea (con validación opcional de membresía) - if (action === 'soltar') { - return []; - const idToken = tokens[2]; - const idInput = idToken ? parseInt(idToken, 10) : NaN; - if (!idInput || Number.isNaN(idInput)) { - return [{ - recipient: context.sender, - message: 'ℹ️ Uso: `/t soltar 26`' - }]; - } - - const resolvedId = resolveTaskIdFromInput(idInput); - if (!resolvedId) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(idInput)} no encontrada.` - }]; - } - - const task = TaskService.getTaskById(resolvedId); - if (!task) { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (!enforceMembership(context.sender, task)) { - return [{ - recipient: context.sender, - message: '⚠️ No puedes soltar esta tarea porque no eres de este grupo.' - }]; - } - - const res = TaskService.unassignTask(resolvedId, context.sender); - const due = res.task?.due_date ? ` — ${ICONS.date} ${formatDDMM(res.task?.due_date)}` : ''; - - if (res.status === 'forbidden_personal') { - return [{ - recipient: context.sender, - message: '⚠️ No puedes soltar una tarea personal. Márcala como completada para eliminarla' - }]; - } - - if (res.status === 'not_found') { - return [{ - recipient: context.sender, - message: `⚠️ Tarea ${codeId(resolvedId)} no encontrada.` - }]; - } - if (res.status === 'completed') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} ya estaba completada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - if (res.status === 'not_assigned') { - return [{ - recipient: context.sender, - message: `ℹ️ ${codeId(resolvedId, res.task?.display_code)} no la tenías asignada — ${res.task?.description || '(sin descripción)'}${due}` - }]; - } - - if (res.now_unassigned) { - const lines = [ - `${ICONS.unassigned} ${codeId(resolvedId, res.task?.display_code)}`, - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '', - italic('queda sin responsable.') - ].filter(Boolean); - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; - } - - const lines = [ - `${ICONS.unassign} ${codeId(resolvedId, res.task?.display_code)}`, - `${res.task?.description || '(sin descripción)'}`, - res.task?.due_date ? `${ICONS.date} ${formatDDMM(res.task?.due_date)}` : '' - ].filter(Boolean); - return [{ - recipient: context.sender, - message: lines.join('\n') - }]; - } - - - - if (action !== 'nueva') { - const feature = String(process.env.FEATURE_HELP_V2 ?? 'true').toLowerCase(); - const helpV2Enabled = !['false', '0', 'no'].includes(feature); - - try { Metrics.inc('commands_unknown_total'); } catch { } - if (!helpV2Enabled) { - return [{ - recipient: context.sender, - message: `Acción ${rawAction || '(vacía)'} no implementada aún` - }]; - } - const header = `❓ ${section('Comando no reconocido')}`; - const cta = `Prueba ${code('/t ayuda')}`; - const help = getQuickHelp(); - return [{ - recipient: context.sender, - message: [header, cta, '', help].join('\n') - }]; - } - - // Parseo específico de "nueva" - // 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' (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 @/+) - 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 - ])); - - if (process.env.NODE_ENV !== 'test') { - console.log('[A0] /t nueva menciones', { - context_mentions: context.mentions || [], - mentions_normalized: mentionsNormalizedFromContext, - at_tokens: atTokenCandidates, - at_normalized: normalizedFromAtTokens, - combined: combinedAssigneeCandidates - }); - } - - const { description, dueDate, selfAssign } = parseNueva(trimmed, mentionsNormalizedFromContext); - - // Asegurar creador - const createdBy = ensureUserExists(context.sender, this.dbInstance); - 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, this.dbInstance)) - .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 (Fase 1) - // Registrar interacción del usuario (last_command_at) - try { - const ensured = ensureUserExists(context.sender, this.dbInstance); - if (ensured) { - try { - this.dbInstance.prepare(`UPDATE users SET last_command_at = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`).run(ensured); - } catch {} - } - } catch {} - 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 { - this.dbInstance.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 { - this.dbInstance.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 creatorName = await ContactsService.getDisplayName(createdBy); - const creatorJid = `${createdBy}@s.whatsapp.net`; - 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: CommandResponse[] = []; - - // 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'), '', CommandService.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' + CommandService.CTA_HELP, - mentions: [creatorJid] - }); - } - - // A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables - { - const unresolvedList = Array.from(new Set(unresolvedAssigneeDisplays.filter(Boolean))); - if (unresolvedList.length > 0) { - const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test'; - const enabled = isTest - ? String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true' - : (() => { - const v = process.env.ONBOARDING_PROMPTS_ENABLED; - return v == null ? true : ['true', '1', 'yes'].includes(String(v).toLowerCase()); - })(); - const groupLabel = isGroupId(context.groupId) ? String(context.groupId) : 'dm'; - if (!enabled) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { } - } else { - const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); - if (!bot || !/^\d+$/.test(bot)) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'missing_bot_number' }); } catch { } - } else { - const list = unresolvedList.join(', '); - let groupCtx = ''; - if (isGroupId(context.groupId)) { - const name = GroupSyncService.activeGroupsCache.get(context.groupId) || context.groupId; - groupCtx = ` (en el grupo ${name})`; - } - const msg = `No puedo asignar a ${list} aún${groupCtx}. Pídele que toque este enlace y diga 'activar': https://wa.me/${bot}`; - responses.push({ recipient: createdBy, message: msg }); - try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure' }); } catch { } - } - } - } - } - - // Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo - try { - const isTest = String(process.env.NODE_ENV || '').toLowerCase() === 'test'; - const enabledBase = ['true','1','yes','on'].includes(String(process.env.ONBOARDING_DM_ENABLED || '').toLowerCase()); - const enabled = enabledBase && (!isTest || String(process.env.ONBOARDING_ENABLE_IN_TEST || '').toLowerCase() === 'true'); - const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null); - - if (!enabled) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {} - } else if (!gid) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: String(context.groupId || '') }); } catch {} - } else { - // Gating enforce - let allowed = true; - try { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} - allowed = AllowedGroups.isAllowed(gid); - } - } catch {} - if (!allowed) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {} - } else { - const displayCode = createdTask?.display_code; - if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {} - } else { - // Candidatos - let members = GroupSyncService.listActiveMemberIds(gid); - const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); - const exclude = new Set([createdBy, ...assignmentUserIds]); - members = members - .filter(id => /^\d+$/.test(id) && id.length < 14) - .filter(id => !exclude.has(id)) - .filter(id => !bot || id !== bot); - - if (members.length === 0) { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_members', group_id: String(gid) }); } catch {} - } else { - const capRaw = Number(process.env.ONBOARDING_EVENT_CAP); - const cap = Number.isFinite(capRaw) && capRaw > 0 ? Math.floor(capRaw) : 30; - let recipients = members; - if (recipients.length > cap) { - try { Metrics.inc('onboarding_recipients_capped_total', recipients.length - cap, { group_id: String(gid) }); } catch {} - recipients = recipients.slice(0, cap); - } - - const cooldownRaw = Number(process.env.ONBOARDING_DM_COOLDOWN_DAYS); - const cooldownDays = Number.isFinite(cooldownRaw) && cooldownRaw >= 0 ? Math.floor(cooldownRaw) : 14; - const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS); - const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 5–10s por defecto - - const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid; - const codeStr = String(displayCode); - const desc = (description || '(sin descripción)').trim(); - const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; - - const codeInline = codeId(taskId, displayCode); - const cmdTake = code(`/t tomar ${padTaskId(displayCode)}`); - const cmdInfo = code(`/t info`); - const groupBold = bold(`‘${groupLabel}’`); - - const msg1 = `¡Hola!, soy el bot de tareas. En ${groupBold} acaban de crear una tarea: ${codeInline} _${shortDesc}_ -- Para hacerte cargo: ${cmdTake} -- Más info: ${cmdInfo} -${ICONS.info} Solo escribo por privado. -${ICONS.info} Cuando reciba tu primer mensaje ya no te enviaré más este recordatorio`; - - const msg2 = `GUÍA RÁPIDA -Puedes interactuar escribiéndome por privado: -- Ver tus tareas: ${code('/t mias')} -- Ver todas: ${code('/t todas')} -- Recibe recordatorios: ${code('/t configurar diario|l-v|semanal|off')} -- Web: ${code('/t web')}`; - - for (const rcpt of recipients) { - const stats = ResponseQueue.getOnboardingStats(rcpt); - let variant: 'initial' | 'reminder' | null = null; - - if (!stats || (stats.total || 0) === 0) { - variant = 'initial'; - } else if (stats.firstInitialAt) { - let firstMs = NaN; - try { - const s = String(stats.firstInitialAt); - const iso = s.includes('T') ? s : (s.replace(' ', 'T') + 'Z'); - firstMs = Date.parse(iso); - } catch {} - const nowMs = Date.now(); - const okCooldown = Number.isFinite(firstMs) ? (nowMs - firstMs) >= cooldownDays * 24 * 60 * 60 * 1000 : false; - - // Interacción del usuario desde el primer paquete - let hadInteraction = false; - try { - const row = this.dbInstance.prepare(`SELECT last_command_at FROM users WHERE id = ?`).get(rcpt) as any; - const lcRaw = row?.last_command_at ? String(row.last_command_at) : null; - if (lcRaw) { - const lcIso = lcRaw.includes('T') ? lcRaw : (lcRaw.replace(' ', 'T') + 'Z'); - const lcMs = Date.parse(lcIso); - hadInteraction = Number.isFinite(lcMs) && Number.isFinite(firstMs) && lcMs > firstMs; - } - } catch {} - - if (okCooldown && !hadInteraction) { - variant = 'reminder'; - } else { - try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: hadInteraction ? 'had_interaction' : 'cooldown_active', group_id: String(gid) }); } catch {} - } - } - - if (!variant) continue; - - const bundleId = randomTokenBase64Url(12); - try { - ResponseQueue.enqueueOnboarding(rcpt, msg1, { variant, part: 1, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, 0); - ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: taskId, display_code: displayCode }, delay2); - try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {} - } catch {} - } - } - } - } - } - } catch {} - - return responses; - } static async handle(context: CommandContext): Promise { const outcome = await this.handleWithOutcome(context);