import type { Database } from 'bun:sqlite'; import { getDb } from '../db/locator'; import { toIsoSqlUTC } from '../utils/datetime'; export class MaintenanceService { private static _timer: any = null; private static _healthCheckTimer: any = null; private static _lastRestartAttempt: number = 0; private static get retentionDays(): number { const v = Number(process.env.GROUP_MEMBERS_INACTIVE_RETENTION_DAYS); if (Number.isFinite(v)) return v; return 180; // por defecto 180 días } private static get evolutionApiConfig() { return { url: process.env.EVOLUTION_API_URL, instance: process.env.EVOLUTION_API_INSTANCE, apiKey: process.env.EVOLUTION_API_KEY, intervalMs: Number(process.env.HEALTH_CHECK_INTERVAL_MS || '120000'), // 2 min por defecto restartCooldownMs: Number(process.env.HEALTH_CHECK_RESTART_COOLDOWN_MS || '900000'), // 15 min por defecto }; } static start(): void { if (process.env.NODE_ENV === 'test' && process.env.FORCE_SCHEDULERS !== 'true') return; // --- Tareas diarias existentes --- if (this.retentionDays > 0) { const intervalMs = 24 * 60 * 60 * 1000; // diario this._timer = setInterval(() => { this.cleanupInactiveMembersOnce().catch(err => { console.error('❌ Error en cleanup de miembros inactivos:', err); }); this.reconcileAliasUsersOnce().catch(err => { console.error('❌ Error en reconcile de alias de usuarios:', err); }); }, intervalMs); } // --- Nuevo Health Check de Evolution API --- const { url, instance, apiKey, intervalMs } = this.evolutionApiConfig; if (url && instance && apiKey) { console.log('[MaintenanceService] Iniciando health check de Evolution API...'); this._healthCheckTimer = setInterval(() => { this.performEvolutionHealthCheck().catch(err => { console.error('❌ Error en el health check de Evolution API:', err); }); }, intervalMs); } else { console.warn('[MaintenanceService] Variables de entorno para el health check de Evolution API (URL, INSTANCE, API_KEY) no encontradas. Health check desactivado.'); } } static stop(): void { if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._healthCheckTimer) { clearInterval(this._healthCheckTimer); this._healthCheckTimer = null; } } static async cleanupInactiveMembersOnce(instance?: Database, retentionDays: number = this.retentionDays): Promise { if (retentionDays <= 0) return 0; const threshold = toIsoSqlUTC(new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)); const dbi = ((instance ?? getDb()) as Database); const res = dbi.prepare(` DELETE FROM group_members WHERE is_active = 0 AND last_seen_at < ? `).run(threshold); const deleted = Number(res?.changes || 0); return deleted; } /** * Reconciliación de usuarios: fusiona IDs alias (LID u opacos) hacia el número real * en todas las tablas relevantes, basándose en user_aliases. * Devuelve el número de alias procesados. */ static async reconcileAliasUsersOnce(instance?: Database): Promise { try { const dbi = ((instance ?? getDb()) as Database); const rows = dbi.prepare(`SELECT alias, user_id FROM user_aliases WHERE alias != user_id`).all() as any[]; let merged = 0; for (const r of rows) { const alias = String(r.alias); const real = String(r.user_id); dbi.transaction(() => { // Asegurar existencia del usuario real try { dbi.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`) .run(real); } catch {} const updates = [ `UPDATE tasks SET created_by = ? WHERE created_by = ?`, `UPDATE task_assignments SET user_id = ? WHERE user_id = ?`, `UPDATE task_assignments SET assigned_by = ? WHERE assigned_by = ?`, `UPDATE user_preferences SET user_id = ? WHERE user_id = ?`, `UPDATE web_tokens SET user_id = ? WHERE user_id = ?`, `UPDATE group_members SET user_id = ? WHERE user_id = ?` ]; for (const sql of updates) { try { dbi.prepare(sql).run(real, alias); } catch { // Ignorar si la tabla no existe en este despliegue } } // Intentar eliminar el usuario alias si ya no tiene referencias try { dbi.prepare(`DELETE FROM users WHERE id = ?`).run(alias); } catch {} })(); merged++; } return merged; } catch { // Si no existe la tabla user_aliases o hay error de DB, no hacemos nada return 0; } } /** * Verifica el estado de la instancia de Evolution API y la reinicia si es necesario. */ private static async performEvolutionHealthCheck(): Promise { const { url, instance, apiKey, restartCooldownMs } = this.evolutionApiConfig; const stateUrl = `${url}/instance/connectionState/${instance}`; const restartUrl = `${url}/instance/restart/${instance}`; const headers: HeadersInit = { apikey: String(apiKey || '') }; try { const response = await fetch(stateUrl, { method: 'GET', headers }); if (!response.ok) { console.error(`[HealthCheck] Error al consultar estado de Evolution API: ${response.status} ${response.statusText}`); return; } const data = await response.json(); const currentState = data?.instance?.state; console.log(`[HealthCheck] Estado de la instancia '${instance}': ${currentState}`); if (currentState !== 'open') { const now = Date.now(); if (now - this._lastRestartAttempt > restartCooldownMs) { console.warn(`[HealthCheck] La instancia no está 'open'. Estado actual: ${currentState}. Intentando reiniciar...`); try { const restartResponse = await fetch(restartUrl, { method: 'PUT', headers }); if (restartResponse.ok) { console.log(`[HealthCheck] Petición de reinicio para '${instance}' enviada exitosamente.`); this._lastRestartAttempt = now; } else { console.error(`[HealthCheck] Fallo al reiniciar la instancia. Status: ${restartResponse.status} ${restartResponse.statusText}`); } } catch (restartError) { console.error('[HealthCheck] Error de red al intentar reiniciar la instancia:', restartError); } } else { console.log(`[HealthCheck] La instancia no está 'open', pero esperando cooldown de ${Math.round(restartCooldownMs / 60000)} minutos para no sobrecargar la API.`); } } } catch (error) { console.error('[HealthCheck] Error de red o inesperado al verificar el estado de la Evolution API:', error); } } }