diff --git a/src/services/commands/handlers/nueva.ts b/src/services/commands/handlers/nueva.ts new file mode 100644 index 0000000..e07bc9c --- /dev/null +++ b/src/services/commands/handlers/nueva.ts @@ -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 { 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[]; +}; + +const CTA_HELP = 'ℹ️ Tus tareas: `/t mias` · Todas: `/t todas` · Info: `/t info` · Web: `/t web`'; + +export async function handleNueva(context: Ctx, deps: { db: Database }): Promise { + 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; +} diff --git a/src/services/commands/index.ts b/src/services/commands/index.ts index 9da8e0a..9f173a9 100644 --- a/src/services/commands/index.ts +++ b/src/services/commands/index.ts @@ -11,6 +11,7 @@ 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'; @@ -41,6 +42,11 @@ export async function route(context: RouteContext, deps?: { db: Database }): Pro 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 { diff --git a/src/services/onboarding.ts b/src/services/onboarding.ts new file mode 100644 index 0000000..2750595 --- /dev/null +++ b/src/services/onboarding.ts @@ -0,0 +1,198 @@ +import type { Database } from 'bun:sqlite'; +import { ResponseQueue } from './response-queue'; +import { GroupSyncService } from './group-sync'; +import { AllowedGroups } from './allowed-groups'; +import { Metrics } from './metrics'; +import { randomTokenBase64Url } from '../utils/crypto'; +import { ICONS } from '../utils/icons'; +import { codeId, code, bold, padTaskId } from '../utils/formatting'; + +type CommandResponse = { + recipient: string; + message: string; + mentions?: string[]; +}; + +/** + * Construye (si aplica) el DM JIT al creador cuando hay menciones/tokens irrecuperables. + * Aplica flags y métricas exactamente como en CommandService. + */ +export function buildJitAssigneePrompt(createdBy: string, groupId: string, unresolvedAssigneeDisplays: string[]): CommandResponse[] { + const responses: CommandResponse[] = []; + const unresolvedList = Array.from(new Set((unresolvedAssigneeDisplays || []).filter(Boolean))); + if (unresolvedList.length === 0) return responses; + + 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 = String(groupId && groupId.includes('@g.us') ? groupId : 'dm'); + if (!enabled) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupLabel, source: 'jit_assignee_failure', reason: 'disabled' }); } catch { } + return responses; + } + + 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 { } + return responses; + } + + const list = unresolvedList.join(', '); + let groupCtx = ''; + if (groupId && groupId.includes('@g.us')) { + const name = GroupSyncService.activeGroupsCache.get(groupId) || 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 { } + + return responses; +} + +/** + * Encola el paquete de 2 DMs de onboarding para miembros del grupo (si aplica). + * Respeta gating AllowedGroups, cap, cooldown, delays y métricas. + */ +export function maybeEnqueueOnboardingBundle(db: Database, params: { + gid: string | null; + createdBy: string; + assignmentUserIds: string[]; + taskId: number; + displayCode: number | null; + description: string; +}): void { + 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 = params.gid; + + if (!enabled) { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'disabled', group_id: String(gid || '') }); } catch {} + return; + } + if (!gid) { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_group', group_id: '' }); } catch {} + return; + } + + // Gating enforce + let allowed = true; + try { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce') { + try { (AllowedGroups as any).dbInstance = db; } catch {} + allowed = AllowedGroups.isAllowed(gid); + } + } catch {} + if (!allowed) { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'not_allowed', group_id: String(gid) }); } catch {} + return; + } + + const displayCode = params.displayCode; + if (!(typeof displayCode === 'number' && Number.isFinite(displayCode))) { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'no_display_code', group_id: String(gid) }); } catch {} + return; + } + + // Candidatos + let members = GroupSyncService.listActiveMemberIds(gid); + const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); + const exclude = new Set([params.createdBy, ...params.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 {} + return; + } + + 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 + + const groupLabel = GroupSyncService.activeGroupsCache.get(gid) || gid; + const codeStr = String(displayCode); + const desc = (params.description || '(sin descripción)').trim(); + const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; + + const codeInline = codeId(params.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 = db.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: params.taskId, display_code: displayCode }, 0); + ResponseQueue.enqueueOnboarding(rcpt, msg2, { variant, part: 2, bundle_id: bundleId, group_id: gid, task_id: params.taskId, display_code: displayCode }, delay2); + try { Metrics.inc('onboarding_bundle_sent_total', 1, { variant, group_id: String(gid) }); } catch {} + } catch {} + } +}