From 64096e93be2e592cbe8411dea889ce99eaa225ff Mon Sep 17 00:00:00 2001 From: brobert Date: Fri, 17 Oct 2025 10:51:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20onboarding=20A3=20en=20gr?= =?UTF-8?q?oup-sync.ts=20y=20.env.example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .env.example | 12 +++++ src/services/group-sync.ts | 104 +++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/.env.example b/.env.example index 50e4aff..152a1b5 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,18 @@ WEB_BASE_URL="https://taskbot.server.brobert.net" # Host público de la web (p. # DB_PATH="./data/tasks.db" # Si se define, ignora DATA_DIR y usa esta ruta exacta ONBOARDING_FALLBACK_MIN_DIGITS=8 # A2: longitud mínima para conservar números en menciones/tokens; por defecto 8 +# Onboarding A3 (prompts únicos por grupo) +# Habilita/deshabilita la publicación (por defecto true fuera de test) +# ONBOARDING_PROMPTS_ENABLED=true +# Permite publicación durante tests específicos +# ONBOARDING_ENABLE_IN_TEST=false +# Umbral de cobertura (publica si coverage < threshold). Por defecto 1.0 +# ONBOARDING_COVERAGE_THRESHOLD=1 +# Periodo de gracia tras la última verificación de miembros (segundos). Por defecto 90 +# ONBOARDING_GRACE_SECONDS=90 +# Cooldown entre publicaciones (días). Por defecto 7 +# ONBOARDING_COOLDOWN_DAYS=7 + # Sincronización de grupos (opcional) # Intervalo en milisegundos; por defecto 86400000 (24h). En desarrollo puede bajarse (mínimo recomendable 10000ms). # GROUP_SYNC_INTERVAL_MS=86400000 diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 0c7ba57..eb6f7b2 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -663,6 +663,110 @@ 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 as any).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 'hola' 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); + } + } } catch (e) { console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e); }