refactor: desacoplar onboarding y eliminar dependencia GroupSync

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
main
brobert 1 month ago
parent 8e79b1fa43
commit 1b0d2ec91c

@ -6,6 +6,7 @@ import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { ResponseQueue } from './response-queue'; import { ResponseQueue } from './response-queue';
import { toIsoSqlUTC } from '../utils/datetime'; import { toIsoSqlUTC } from '../utils/datetime';
import { publishGroupCoveragePrompt } from './onboarding';
// In-memory cache for active groups // In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName // const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -816,109 +817,8 @@ export class GroupSyncService {
const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1)); const ratio = Math.max(0, Math.min(1, total > 0 ? resolvable / total : 1));
try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {} try { Metrics.set('alias_coverage_ratio', ratio, { group_id: groupId }); } catch {}
// A3: publicación condicional del mensaje de onboarding (sin spam) // Delegar publicación del prompt de onboarding a OnboardingService (consulta DB directamente)
try { try { publishGroupCoveragePrompt(this.dbInstance, groupId, ratio); } catch {}
// 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);
}
}
} catch (e) { } catch (e) {
console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e); console.warn('⚠️ No se pudo calcular alias_coverage_ratio para', groupId, e);
} }

@ -1,6 +1,5 @@
import type { Database } from 'bun:sqlite'; import type { Database } from 'bun:sqlite';
import { ResponseQueue } from './response-queue'; import { ResponseQueue } from './response-queue';
import { GroupSyncService } from './group-sync';
import { AllowedGroups } from './allowed-groups'; import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics'; import { Metrics } from './metrics';
import { randomTokenBase64Url } from '../utils/crypto'; import { randomTokenBase64Url } from '../utils/crypto';
@ -45,7 +44,7 @@ export function buildJitAssigneePrompt(createdBy: string, groupId: string, unres
const list = unresolvedList.join(', '); const list = unresolvedList.join(', ');
let groupCtx = ''; let groupCtx = '';
if (groupId && groupId.includes('@g.us')) { if (groupId && groupId.includes('@g.us')) {
const name = GroupSyncService.activeGroupsCache.get(groupId) || groupId; const name = groupId;
groupCtx = ` (en el grupo ${name})`; 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}`; 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 // 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 bot = String(process.env.CHATBOT_PHONE_NUMBER || '').trim();
const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]); const exclude = new Set<string>([params.createdBy, ...params.assignmentUserIds]);
members = members members = members
@ -128,7 +134,12 @@ export function maybeEnqueueOnboardingBundle(db: Database, params: {
const delayEnv = Number(process.env.ONBOARDING_BUNDLE_DELAY_MS); 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); // 510s const delay2 = Number.isFinite(delayEnv) && delayEnv >= 0 ? Math.floor(delayEnv) : 5000 + Math.floor(Math.random() * 5001); // 510s
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 codeStr = String(displayCode);
const desc = (params.description || '(sin descripción)').trim(); const desc = (params.description || '(sin descripción)').trim();
const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc; const shortDesc = desc.length > 100 ? (desc.slice(0, 100) + '…') : desc;
@ -196,3 +207,91 @@ Puedes interactuar con el bot escribiéndome por privado:
} catch {} } 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);
}
}
}

Loading…
Cancel
Save