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
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);
|
|
}
|
|
}
|
|
}
|