You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

182 lines
6.8 KiB
TypeScript

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<number> {
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<number> {
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<void> {
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);
}
}
}