import { db } from '../db'; // Environment variables will be mocked in tests const env = process.env; // In-memory cache for active groups const activeGroupsCache = new Map(); // groupId -> groupName type EvolutionGroup = { id: string; subject: string; linkedParent?: string; // Other fields from API response }; export class GroupSyncService { /** * Gets the sync interval duration in milliseconds. * * Priority: * 1. GROUP_SYNC_INTERVAL_MS environment variable if set * 2. Default 24 hour interval * * In development mode, enforces minimum 10 second interval * to prevent accidental excessive API calls. * * @returns {number} Sync interval in milliseconds */ private static get SYNC_INTERVAL_MS(): number { const interval = process.env.GROUP_SYNC_INTERVAL_MS ? Number(process.env.GROUP_SYNC_INTERVAL_MS) : 24 * 60 * 60 * 1000; // Default 24 hours // Ensure minimum 10 second interval in development if (process.env.NODE_ENV === 'development' && interval < 10000) { console.warn(`Sync interval too low (${interval}ms), using 10s minimum`); return 10000; } return interval; } private static lastSyncAttempt = 0; static async syncGroups(): Promise<{ added: number; updated: number }> { if (!this.shouldSync()) { return { added: 0, updated: 0 }; } try { const communityId = env.WHATSAPP_COMMUNITY_ID; if (!communityId) { throw new Error('WHATSAPP_COMMUNITY_ID is not set'); } const groups = await this.fetchGroupsFromAPI(); const communityGroups = groups.filter( (group) => group.linkedParent === communityId ); return await this.upsertGroups(communityGroups); } catch (error) { console.error('Group sync failed:', error); throw error; } finally { this.lastSyncAttempt = Date.now(); } } private static shouldSync(): boolean { const timeSinceLastSync = Date.now() - this.lastSyncAttempt; const shouldSync = timeSinceLastSync > this.SYNC_INTERVAL_MS; if (!shouldSync && process.env.NODE_ENV !== 'test') { const nextSyncIn = this.SYNC_INTERVAL_MS - timeSinceLastSync; console.debug(`Next sync available in ${Math.round(nextSyncIn/1000)} seconds`); } return shouldSync; } private static async fetchGroupsFromAPI(): Promise { const url = `${env.EVOLUTION_API_URL}/group/fetchAllGroups/${env.EVOLUTION_API_INSTANCE}?getParticipants=false`; console.log('ℹ️ Fetching groups from API:', { url: `${url.substring(0, 50)}...`, // Log partial URL for security communityId: env.WHATSAPP_COMMUNITY_ID, time: new Date().toISOString() }); try { const response = await fetch(url, { headers: { apikey: env.EVOLUTION_API_KEY, }, timeout: 120000 // 120 second timeout }); if (!response.ok) { const errorBody = await response.text().catch(() => 'Unable to read error body'); console.error('❌ API request failed:', { status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), body: errorBody }); throw new Error(`API request failed: ${response.status} ${response.statusText}`); } const data = await response.json(); console.log('ℹ️ API response:', { status: data.status, message: data.message, responseLength: data.response?.length || 0 }); if (data.status !== 'success') { throw new Error(`API error: ${data.message || 'Unknown error'}`); } return data.response; } catch (error) { console.error('❌ Failed to fetch groups:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined }); throw error; } } private static cacheActiveGroups(): void { const groups = db.prepare('SELECT id, name FROM groups WHERE active = TRUE').all(); activeGroupsCache.clear(); for (const group of groups) { activeGroupsCache.set(group.id, group.name); } console.log(`Cached ${activeGroupsCache.size} active groups`); } private static getActiveGroupsCount(): number { const result = db.prepare('SELECT COUNT(*) as count FROM groups WHERE active = TRUE').get(); return result?.count || 0; } static async checkInitialGroups(): Promise { const count = this.getActiveGroupsCount(); if (count > 0) { this.cacheActiveGroups(); console.log(`✅ Using ${count} existing groups from database`); return; } console.log('⚠️ No groups found in database - performing initial sync'); try { const { added } = await this.syncGroups(); if (added === 0) { throw new Error('Initial group sync completed but no groups were added'); } this.cacheActiveGroups(); console.log(`✅ Initial group sync completed - added ${added} groups`); } catch (error) { console.error('❌ Critical: Initial group sync failed - no groups available'); console.error(error instanceof Error ? error.message : 'Unknown error'); process.exit(1); } } private static async upsertGroups(groups: EvolutionGroup[]): Promise<{ added: number; updated: number }> { let added = 0; let updated = 0; const transactionResult = db.transaction(() => { // First mark all groups as inactive and update verification timestamp const inactiveResult = db.prepare(` UPDATE groups SET active = FALSE, last_verified = CURRENT_TIMESTAMP `).run(); console.log('Marked groups inactive:', inactiveResult); for (const group of groups) { const existing = db.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id); console.log('Checking group:', group.id, 'exists:', !!existing); if (existing) { const updateResult = db.prepare( 'UPDATE groups SET name = ?, active = TRUE, last_verified = CURRENT_TIMESTAMP WHERE id = ?' ).run(group.subject, group.id); console.log('Updated group:', group.id, 'result:', updateResult); updated++; } else { const insertResult = db.prepare( 'INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, TRUE)' ).run(group.id, env.WHATSAPP_COMMUNITY_ID, group.subject); console.log('Added group:', group.id, 'result:', insertResult); added++; } } return { added, updated }; }); try { const result = transactionResult(); console.log(`Group sync completed: ${result.added} added, ${result.updated} updated`); return result; } catch (error) { console.error('Error in upsertGroups:', error); throw error; } } }