diff --git a/src/services/command.ts b/src/services/command.ts index 17910e5..332de9a 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -10,6 +10,7 @@ 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, sha256Hex } from '../utils/crypto'; type CommandContext = { @@ -1131,6 +1132,15 @@ export class CommandService { ); // 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; @@ -1234,6 +1244,121 @@ export class CommandService { } } + // 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 delay2 = 5000 + Math.floor(Math.random() * 5001); // 5–10s + + 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 msg1 = `Hola, soy el bot de tareas. En ‘${groupLabel}’ acaban de crear una tarea: #${codeStr} ${shortDesc} +Encárgate: /t tomar ${codeStr} · Más info: /t info +Nota: nunca respondo en grupos; solo por privado.`; + const msg2 = `Guía rápida (este es un mensaje único): +· Tus tareas: /t mias · Todas: /t todas +· Recordatorios: /t configurar diario | l‑v | semanal | off +· Web: /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; } diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index bb5c9be..766f495 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -1279,4 +1279,29 @@ export class GroupSyncService { return { added: 0, updated: 0, deactivated: 0 }; } } + + /** + * Devuelve los IDs de usuario activos del grupo, filtrados a dígitos puros con longitud < 14. + * No devuelve duplicados. + */ + public static listActiveMemberIds(groupId: string): string[] { + if (!groupId) return []; + try { + const rows = this.dbInstance.prepare(` + SELECT user_id + FROM group_members + WHERE group_id = ? AND is_active = 1 + `).all(groupId) as Array<{ user_id: string }>; + const out = new Set(); + for (const r of rows) { + const uid = String(r.user_id || '').trim(); + if (/^\d+$/.test(uid) && uid.length < 14) { + out.add(uid); + } + } + return Array.from(out); + } catch { + return []; + } + } } diff --git a/src/services/response-queue.ts b/src/services/response-queue.ts index 11442bf..1305a6a 100644 --- a/src/services/response-queue.ts +++ b/src/services/response-queue.ts @@ -107,6 +107,97 @@ export const ResponseQueue = { } }, + // Encolar un DM de onboarding (part=1 inmediato, part=2 con retraso) + enqueueOnboarding( + recipient: string, + message: string, + metadata: { + variant: 'initial' | 'reminder'; + part: 1 | 2; + bundle_id: string; + group_id?: string | null; + task_id?: number | null; + display_code?: number | null; + }, + delayMs?: number + ): void { + if (!recipient || !message) return; + const botNumber = (process.env.CHATBOT_PHONE_NUMBER || '').trim(); + if (botNumber && recipient === botNumber) { + try { Metrics.inc('onboarding_dm_skipped_total', 1, { reason: 'bot_number', group_id: String(metadata.group_id || '') }); } catch {} + return; + } + const metaObj: any = { + kind: 'onboarding', + variant: metadata.variant, + part: metadata.part, + bundle_id: metadata.bundle_id, + group_id: metadata.group_id ?? null, + task_id: metadata.task_id ?? null, + display_code: metadata.display_code ?? null + }; + const nextAt = delayMs && delayMs > 0 ? this.futureIso(delayMs) : this.nowIso(); + this.dbInstance.prepare(` + INSERT INTO response_queue (recipient, message, metadata, next_attempt_at) + VALUES (?, ?, ?, ?) + `).run(recipient, message, JSON.stringify(metaObj), nextAt); + try { Metrics.inc('onboarding_dm_sent_total', 1, { variant: metadata.variant, part: String(metadata.part), group_id: String(metadata.group_id || '') }); } catch {} + }, + + // Estadísticas de onboarding por destinatario (consulta simple sobre response_queue) + getOnboardingStats(recipient: string): { total: number; lastSentAt: string | null; firstInitialAt?: string | null; lastVariant?: 'initial' | 'reminder' | null } { + if (!recipient) return { total: 0, lastSentAt: null, firstInitialAt: undefined, lastVariant: null }; + const rows = this.dbInstance.prepare(` + SELECT status, created_at, updated_at, metadata + FROM response_queue + WHERE recipient = ? AND metadata IS NOT NULL + `).all(recipient) as Array<{ status: string; created_at: string; updated_at: string; metadata: string | null }>; + + let total = 0; + let lastSentAt: string | null = null; + let firstInitialAt: string | null | undefined = undefined; + let lastVariant: 'initial' | 'reminder' | null = null; + let lastTsMs = -1; + + for (const r of rows) { + let meta: any = null; + try { meta = r.metadata ? JSON.parse(r.metadata) : null; } catch { meta = null; } + if (!meta || meta.kind !== 'onboarding') continue; + total++; + + // Elegir timestamp de referencia + const tRaw = (r.updated_at || r.created_at || '').toString(); + const iso = tRaw.includes('T') ? tRaw : (tRaw.replace(' ', 'T') + 'Z'); + const ts = Date.parse(iso); + if (Number.isFinite(ts) && ts > lastTsMs) { + lastTsMs = ts; + lastSentAt = tRaw || null; + lastVariant = (meta.variant === 'reminder' ? 'reminder' : 'initial'); + } + + // Primer initial (preferimos part=1) + if (meta.variant === 'initial') { + const created = (r.created_at || '').toString(); + if (!firstInitialAt) { + firstInitialAt = created || null; + } else { + // mantener el más antiguo + try { + const curIso = (firstInitialAt as string).includes('T') ? firstInitialAt as string : ((firstInitialAt as string).replace(' ', 'T') + 'Z'); + const curMs = Date.parse(curIso); + const newIso = created.includes('T') ? created : (created.replace(' ', 'T') + 'Z'); + const newMs = Date.parse(newIso); + if (Number.isFinite(newMs) && (!Number.isFinite(curMs) || newMs < curMs)) { + firstInitialAt = created || null; + } + } catch {} + } + } + } + + return { total, lastSentAt, firstInitialAt, lastVariant }; + }, + // Encolar una reacción con idempotencia (24h) usando metadata canónica async enqueueReaction(chatId: string, messageId: string, emoji: string, opts?: { participant?: string; fromMe?: boolean }): Promise { try {