diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 013e40d..6c0aed0 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -6,6 +6,7 @@ import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; import { ResponseQueue } from './response-queue'; import { toIsoSqlUTC } from '../utils/datetime'; +import { publishGroupCoveragePrompt } from './onboarding'; // In-memory cache for active groups // const activeGroupsCache = new Map(); // groupId -> groupName @@ -816,109 +817,8 @@ export class GroupSyncService { const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1)); try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {} - // A3: publicación condicional del mensaje de onboarding (sin spam) - try { - // Flags y parámetros - 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()); - })(); - - if (!enabled) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {} - return; - } - - const thrRaw = Number(process.env.ONBOARDING_COVERAGE_THRESHOLD); - const threshold = Number.isFinite(thrRaw) ? Math.min(1, Math.max(0, thrRaw)) : 1; - if (ratio >= threshold) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'coverage_100' }); } catch {} - return; - } - - // Gating en modo enforce: no publicar en grupos no allowed - try { - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce') { - try { AllowedGroups.dbInstance = this.dbInstance; } catch {} - if (!AllowedGroups.isAllowed(groupId)) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {} - return; - } - } - } catch {} - - // Grace y cooldown desde tabla groups - const rowG = this.dbInstance.prepare(` - SELECT last_verified, onboarding_prompted_at - FROM groups - WHERE id = ? - `).get(groupId) as any; - - const nowMs = Date.now(); - const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS); - const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90; - - const lv = rowG?.last_verified ? String(rowG.last_verified) : null; - if (lv) { - const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z'); - const ms = Date.parse(iso); - if (Number.isFinite(ms)) { - const ageSec = Math.floor((nowMs - ms) / 1000); - if (ageSec < graceSec) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {} - return; - } - } - } - - const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS); - const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7; - const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null; - if (promptedAt) { - const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z'); - const ms = Date.parse(iso); - if (Number.isFinite(ms)) { - const diffMs = nowMs - ms; - if (diffMs < cdDays * 24 * 60 * 60 * 1000) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {} - return; - } - } - } - - // Número del bot para construir wa.me - const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); - if (!bot || !/^\d+$/.test(bot)) { - try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {} - return; - } - - // Encolar mensaje en la cola persistente y marcar timestamp en groups - const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`; - this.dbInstance.transaction(() => { - this.dbInstance.prepare(` - INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at) - VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) - `).run(groupId, msg); - this.dbInstance.prepare(` - UPDATE groups - SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now') - WHERE id = ? - `).run(groupId); - })(); - - try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {} - } catch (e) { - // Evitar romper el flujo si falla el encolado - if (process.env.NODE_ENV !== 'test') { - console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e); - } - } + // Delegar publicación del prompt de onboarding a OnboardingService (consulta DB directamente) + try { publishGroupCoveragePrompt(this.dbInstance, groupId, ratio); } catch {} } catch (e) { console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e); } diff --git a/src/services/onboarding.ts b/src/services/onboarding.ts index ef155f0..d7a2d37 100644 --- a/src/services/onboarding.ts +++ b/src/services/onboarding.ts @@ -1,6 +1,5 @@ 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'; @@ -45,7 +44,7 @@ export function buildJitAssigneePrompt(createdBy: string, groupId: string, unres const list = unresolvedList.join(', '); let groupCtx = ''; if (groupId && groupId.includes('@g.us')) { - const name = GroupSyncService.activeGroupsCache.get(groupId) || groupId; + const name = 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}`; @@ -102,7 +101,14 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: { } // Candidatos - let members = GroupSyncService.listActiveMemberIds(gid); + let members: string[] = []; + try { + const rows = db.prepare(`SELECT user_id FROM group_members WHERE group_id = ? AND is_active = 1`).all(gid) as Array<{ user_id: string }>; + for (const r of rows) { + const uid = String(r.user_id || '').trim(); + if (/^\d+$/.test(uid) && uid.length < 14) members.push(uid); + } + } catch {} const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); const exclude = new Set([params.createdBy, ...params.assignmentUserIds]); members = members @@ -128,7 +134,12 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: { 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; + let groupLabel = gid; + try { + const row = db.prepare(`SELECT name FROM groups WHERE id = ?`).get(gid) as any; + const name = row?.name ? String(row.name).trim() : ''; + if (name) groupLabel = name; + } catch {} const codeStr = String(displayCode); const desc = (params.description || '(sin descripción)').trim(); const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; @@ -196,3 +207,91 @@ Puedes interactuar con el bot escribiéndome por privado: } catch {} } } + +export function publishGroupCoveragePrompt(db: Database, groupId: string, ratio: number): void { + try { + 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()); + })(); + if (!enabled) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'disabled' }); } catch {} + return; + } + + // Gating enforce + try { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce') { + try { (AllowedGroups as any).dbInstance = db; } catch {} + if (!AllowedGroups.isAllowed(groupId)) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'not_allowed' }); } catch {} + return; + } + } + } catch {} + + // Grace y cooldown + const rowG = db.prepare(`SELECT last_verified, onboarding_prompted_at FROM groups WHERE id = ?`).get(groupId) as any; + const nowMs = Date.now(); + const graceRaw = Number(process.env.ONBOARDING_GRACE_SECONDS); + const graceSec = Number.isFinite(graceRaw) && graceRaw >= 0 ? Math.floor(graceRaw) : 90; + + const lv = rowG?.last_verified ? String(rowG.last_verified) : null; + if (lv) { + const iso = lv.includes('T') ? lv : (lv.replace(' ', 'T') + 'Z'); + const ms = Date.parse(iso); + if (Number.isFinite(ms)) { + const ageSec = Math.floor((nowMs - ms) / 1000); + if (ageSec < graceSec) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'grace_period' }); } catch {} + return; + } + } + } + + const cdRaw = Number(process.env.ONBOARDING_COOLDOWN_DAYS); + const cdDays = Number.isFinite(cdRaw) && cdRaw >= 0 ? Math.floor(cdRaw) : 7; + const promptedAt = rowG?.onboarding_prompted_at ? String(rowG.onboarding_prompted_at) : null; + if (promptedAt) { + const iso = promptedAt.includes('T') ? promptedAt : (promptedAt.replace(' ', 'T') + 'Z'); + const ms = Date.parse(iso); + if (Number.isFinite(ms)) { + const diffMs = nowMs - ms; + if (diffMs < cdDays * 24 * 60 * 60 * 1000) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'cooldown_active' }); } catch {} + return; + } + } + } + + const bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim(); + if (!bot || !/^\d+$/.test(bot)) { + try { Metrics.inc('onboarding_prompts_skipped_total', 1, { group_id: groupId, reason: 'missing_bot_number' }); } catch {} + return; + } + + const msg = `Para poder asignarte tareas y acceder a la web, envía 'activar' al bot por privado. Solo hace falta una vez. Enlace: https://wa.me/${bot}`; + db.transaction(() => { + db.prepare(` + INSERT INTO response_queue (recipient, message, status, attempts, metadata, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'queued', 0, NULL, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `).run(groupId, msg); + db.prepare(` + UPDATE groups + SET onboarding_prompted_at = strftime('%Y-%m-%d %H:%M:%f','now') + WHERE id = ? + `).run(groupId); + })(); + + try { Metrics.inc('onboarding_prompts_sent_total', 1, { group_id: groupId, reason: 'coverage_below_threshold' }); } catch {} + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + console.warn('⚠️ Onboarding prompt skipped due to internal error for', groupId, e); + } + } +}