/* eslint-disable max-depth, @typescript-eslint/no-unused-vars */ import { DEFAULT_SESSION_CLEANUP_CONFIG } from '../Defaults' import type { SessionCleanupConfig, SessionCleanupStats, SignalKeyStoreWithTransaction } from '../Types' import type { ILogger } from '../Utils/logger' import { jidDecode } from '../WABinary' import type { LIDMappingStore } from './lid-mapping' import type { SessionActivityTracker } from './session-activity-tracker' // Re-export for backward compatibility export type { SessionCleanupConfig, SessionCleanupStats } /** * Session metadata for cleanup decisions */ interface SessionMetadata { jid: string isLID: boolean isPrimary: boolean lastActivityMs?: number createdAtMs?: number hasLIDMapping?: boolean } /** * Creates a session cleanup manager * * SAFETY GUARANTEES: * - Does NOT affect WebSocket connections (only local database) * - Does NOT cause message loss (Signal Protocol auto-recreates sessions) * - Runs in low-traffic hours (configurable, default 3am) * - Atomic transactions (all-or-nothing) * - Comprehensive logging and statistics * * CLEANUP RULES: * 1. Secondary devices (Web, Desktop) - Inactive > X days (default: 15) * 2. Primary devices - Inactive > Y days (default: 30) * 3. LID orphans (no PN mapping) - Inactive > Z hours (default: 24) * * @param keys - Signal key store with transaction support * @param lidMapping - LID mapping store for orphan detection * @param sessionActivityTracker - Session activity tracker for timestamp-based cleanup * @param logger - Structured logger instance * @param config - Cleanup configuration (uses defaults from env) */ export const makeSessionCleanup = ( keys: SignalKeyStoreWithTransaction, lidMapping: LIDMappingStore, sessionActivityTracker: SessionActivityTracker | null, logger: ILogger, config: SessionCleanupConfig = DEFAULT_SESSION_CLEANUP_CONFIG ) => { let cleanupInterval: ReturnType | null = null let initialTimeout: ReturnType | null = null let lastCleanupAt = 0 let cleanupRunning = false let startupCleanupPromise: Promise | null = null /** * Get all sessions from database * Returns array of session keys (signal addresses) */ const getAllSessionKeys = async (): Promise => { try { // Get all sessions from key store // Signal addresses format: "user_domain.device" const sessions = await keys.get('session', []) return Object.keys(sessions) } catch (error) { logger.error({ error }, 'Failed to get all session keys') return [] } } /** * Parse session metadata from signal address * Signal address format: "user_domain.device" * Examples: * "5511999999999_0.0" โ†’ PN primary device * "5511999999999_0.1" โ†’ PN secondary device * "123456789_2.0" โ†’ LID primary device */ const parseSessionMetadata = async (signalAddr: string): Promise => { try { // Parse signal address: "user_domain.device" const [userWithDomain, deviceStr] = signalAddr.split('.') if (!userWithDomain) return null const [user, domainStr] = userWithDomain.split('_') if (!user) return null const device = parseInt(deviceStr || '0', 10) const domain = parseInt(domainStr || '0', 10) // Determine if LID (domain 2 or 6) or PN (domain 0 or 5) const isLID = domain === 2 || domain === 6 const isPrimary = device === 0 // Construct JID for mapping lookup let jid: string if (isLID) { jid = `${user}@lid` } else { jid = `${user}${device > 0 ? `:${device}` : ''}@s.whatsapp.net` } // Check if LID has PN mapping let hasLIDMapping: boolean | undefined if (isLID) { const pn = await lidMapping.getPNForLID(jid) hasLIDMapping = !!pn } return { jid, isLID, isPrimary, hasLIDMapping } } catch (error) { logger.warn({ error, signalAddr }, 'Failed to parse session metadata') return null } } /** * Determine if session should be cleaned up */ const shouldCleanupSession = (metadata: SessionMetadata, now: number): { cleanup: boolean; reason: string } => { // Rule 1: LID orphans (no PN mapping) after X hours if (metadata.isLID && metadata.hasLIDMapping === false) { const thresholdMs = config.lidOrphanHours * 60 * 60 * 1000 // Check if lastActivity is old enough if (metadata.lastActivityMs) { const inactiveDuration = now - metadata.lastActivityMs if (inactiveDuration > thresholdMs) { return { cleanup: true, reason: `LID orphan without PN mapping, inactive for ${Math.floor(inactiveDuration / 3600000)}h (threshold: ${config.lidOrphanHours}h)` } } } else { // No activity tracked - consider it old return { cleanup: true, reason: `LID orphan without PN mapping, no activity tracked (threshold: ${config.lidOrphanHours}h)` } } return { cleanup: false, reason: 'LID orphan but not inactive long enough' } } // Rule 2: Secondary devices inactive > X days if (!metadata.isPrimary && metadata.lastActivityMs) { const thresholdMs = config.secondaryDeviceInactiveDays * 24 * 60 * 60 * 1000 const inactiveDuration = now - metadata.lastActivityMs if (inactiveDuration > thresholdMs) { return { cleanup: true, reason: `Secondary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.secondaryDeviceInactiveDays} days)` } } return { cleanup: false, reason: `Secondary device active within ${config.secondaryDeviceInactiveDays} days` } } // Rule 3: Primary devices inactive > Y days if (metadata.isPrimary && metadata.lastActivityMs) { const thresholdMs = config.primaryDeviceInactiveDays * 24 * 60 * 60 * 1000 const inactiveDuration = now - metadata.lastActivityMs if (inactiveDuration > thresholdMs) { return { cleanup: true, reason: `Primary device inactive for ${Math.floor(inactiveDuration / 86400000)} days (threshold: ${config.primaryDeviceInactiveDays} days)` } } return { cleanup: false, reason: `Primary device active within ${config.primaryDeviceInactiveDays} days` } } // No lastActivity tracked - don't cleanup yet (grace period) return { cleanup: false, reason: 'No activity tracked yet' } } /** * Run session cleanup * Returns statistics about cleanup operation */ const runCleanup = async (): Promise => { const startTime = Date.now() const stats: SessionCleanupStats = { totalScanned: 0, secondaryDevicesDeleted: 0, primaryDevicesDeleted: 0, lidOrphansDeleted: 0, totalDeleted: 0, durationMs: 0, errors: 0 } if (!config.enabled) { logger.info('Session cleanup is disabled') return stats } if (cleanupRunning) { logger.warn('Session cleanup already running, skipping') return stats } cleanupRunning = true logger.info('๐Ÿงน Starting session cleanup') try { // Get all session keys const sessionKeys = await getAllSessionKeys() stats.totalScanned = sessionKeys.length logger.info({ totalSessions: sessionKeys.length }, 'Scanning sessions for cleanup') // Get all activity timestamps (if tracker available) const activityMap = sessionActivityTracker ? await sessionActivityTracker.getAllActivities() : new Map() logger.debug({ trackedActivities: activityMap.size }, 'Loaded session activity timestamps') // Prepare bulk deletion const sessionsToDelete: string[] = [] const deletionReasons: { [key: string]: string } = {} // Process each session for (const signalAddr of sessionKeys) { try { const metadata = await parseSessionMetadata(signalAddr) if (!metadata) { stats.errors++ continue } // Add lastActivity from tracker metadata.lastActivityMs = activityMap.get(metadata.jid) const { cleanup, reason } = shouldCleanupSession(metadata, Date.now()) if (cleanup) { sessionsToDelete.push(signalAddr) deletionReasons[signalAddr] = reason // Update statistics if (metadata.isLID && !metadata.hasLIDMapping) { stats.lidOrphansDeleted++ } else if (!metadata.isPrimary) { stats.secondaryDevicesDeleted++ } else { stats.primaryDevicesDeleted++ } logger.debug( { jid: metadata.jid, signalAddr, reason, isLID: metadata.isLID, isPrimary: metadata.isPrimary }, 'Session marked for deletion' ) } } catch (error) { logger.warn({ error, signalAddr }, 'Error processing session for cleanup') stats.errors++ } } // Bulk delete sessions if (sessionsToDelete.length > 0) { logger.info({ count: sessionsToDelete.length }, '๐Ÿ—‘๏ธ Deleting orphaned/inactive sessions') try { // Prepare session updates (set to null = delete) const sessionUpdates: { [key: string]: null } = {} sessionsToDelete.forEach(addr => { sessionUpdates[addr] = null }) // Single atomic transaction for all deletions await keys.transaction(async () => { await keys.set({ session: sessionUpdates }) }, 'session-cleanup') stats.totalDeleted = sessionsToDelete.length logger.info( { deleted: stats.totalDeleted, lidOrphans: stats.lidOrphansDeleted, secondaryDevices: stats.secondaryDevicesDeleted, primaryDevices: stats.primaryDevicesDeleted, errors: stats.errors }, 'โœ… Session cleanup completed successfully' ) } catch (error) { logger.error({ error, count: sessionsToDelete.length }, 'โŒ Failed to delete sessions') stats.errors++ } } else { logger.info('No sessions to cleanup') } lastCleanupAt = Date.now() stats.durationMs = Date.now() - startTime return stats } catch (error) { logger.error({ error }, 'โŒ Session cleanup failed') stats.errors++ stats.durationMs = Date.now() - startTime return stats } finally { cleanupRunning = false } } /** * Calculate milliseconds until next cleanup time * Ensures cleanup runs at configured hour (default: 3am) */ const msUntilNextCleanup = (): number => { const now = new Date() const next = new Date() next.setHours(config.cleanupHour, 0, 0, 0) // If we're past cleanup hour today, schedule for tomorrow if (now.getHours() >= config.cleanupHour) { next.setDate(next.getDate() + 1) } return next.getTime() - now.getTime() } /** * Start periodic session cleanup * Runs at configured hour (default: 3am daily) */ const start = () => { if (!config.enabled) { logger.info('Session cleanup is disabled') return } if (cleanupInterval) { logger.warn('Session cleanup already started') return } logger.info( { enabled: config.enabled, intervalHours: config.intervalMs / (60 * 60 * 1000), cleanupHour: config.cleanupHour, secondaryDeviceInactiveDays: config.secondaryDeviceInactiveDays, primaryDeviceInactiveDays: config.primaryDeviceInactiveDays, lidOrphanHours: config.lidOrphanHours, cleanupOnStartup: config.cleanupOnStartup, autoCleanCorrupted: config.autoCleanCorrupted }, '๐Ÿงน Session cleanup scheduler started' ) // Run immediate cleanup on startup if enabled if (config.cleanupOnStartup) { logger.info('๐Ÿš€ Running cleanup on startup...') startupCleanupPromise = runCleanup() .catch(err => { logger.error({ err }, 'Cleanup on startup failed') }) .then(() => { startupCleanupPromise = null // Clear after completion }) } // Schedule first cleanup at configured hour const msUntilFirst = msUntilNextCleanup() logger.info( { msUntilFirst, nextCleanup: new Date(Date.now() + msUntilFirst).toISOString() }, 'โฐ First cleanup scheduled' ) initialTimeout = setTimeout(async () => { initialTimeout = null // Clear reference after execution // Wait for startup cleanup to complete (if still running) if (startupCleanupPromise) { logger.debug('Waiting for startup cleanup to complete before scheduled cleanup...') await startupCleanupPromise } // Run first cleanup await runCleanup() // Schedule recurring cleanup cleanupInterval = setInterval(async () => { await runCleanup() }, config.intervalMs) }, msUntilFirst) } /** * Stop periodic session cleanup */ const stop = () => { if (initialTimeout) { clearTimeout(initialTimeout) initialTimeout = null } if (cleanupInterval) { clearInterval(cleanupInterval) cleanupInterval = null logger.info('Session cleanup scheduler stopped') } } /** * Get cleanup statistics */ const getStats = () => ({ enabled: config.enabled, lastCleanupAt, cleanupRunning, config }) return { start, stop, runCleanup, getStats } } /** * Session cleanup manager type */ export type SessionCleanupManager = ReturnType