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 /** * Represents a group from the Evolution API response * * API returns an array of groups in this format: * [ * { * id: string, // Group ID in @g.us format (primary key) * subject: string, // Group name (displayed to users) * linkedParent?: string, // Parent community ID if group belongs to one * size?: number, // Current member count (unused in our system) * creation?: number, // Unix timestamp of group creation (unused) * desc?: string, // Group description text (unused) * // ...other fields exist but are ignored by our implementation * } * ] * * Required fields for our implementation: * - id (used as database primary key) * - subject (used as group display name) * - linkedParent (used for community filtering) */ type EvolutionGroup = { id: string; subject: string; linkedParent?: string; }; export class GroupSyncService { // Static property for DB instance injection (defaults to global db) static dbInstance: Database = db; /** * 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) { console.log('ℹ️ WHATSAPP_COMMUNITY_ID no definido - mostrando todas las comunidades'); const groups = await this.fetchGroupsFromAPI(); const communities = groups.filter(g => g.linkedParent); return { added: 0, updated: 0 }; // No sync when just listing } const groups = await this.fetchGroupsFromAPI(); console.log('ℹ️ Grupos crudos de la API:', JSON.stringify(groups, null, 2)); const communityGroups = groups.filter((group) => { const matches = group.linkedParent === communityId; console.log(`ℹ️ Grupo ${group.id} (${group.subject}):`, { linkedParent: group.linkedParent, matchesCommunity: matches, isCommunityItself: group.id === communityId }); return matches; }); console.log('ℹ️ Grupos que pasaron el filtro:', communityGroups.map(g => ({ id: g.id, name: g.subject, parent: g.linkedParent }))); const dbGroupsBefore = this.dbInstance.prepare('SELECT id, active FROM groups').all(); console.log('ℹ️ Grupos en DB antes de upsert:', dbGroupsBefore); const result = await this.upsertGroups(communityGroups); const dbGroupsAfter = this.dbInstance.prepare('SELECT id, active FROM groups').all(); console.log('ℹ️ Grupos en DB después de upsert:', dbGroupsAfter); return result; } 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}...`, // Log partial URL for security communityId: env.WHATSAPP_COMMUNITY_ID, time: new Date().toISOString() }); try { const response = await fetch(url, { method: 'GET', headers: { apikey: env.EVOLUTION_API_KEY, }, httpVersion: '2', timeout: 320000 // 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 rawResponse = await response.text(); console.log('ℹ️ Raw API response length:', rawResponse.length); // Parse response which could be either: // 1. Direct array of groups: [{group1}, {group2}] // 2. Or wrapped response: {status, message, response} let groups; try { const parsed = JSON.parse(rawResponse); if (Array.isArray(parsed)) { // Case 1: Direct array response groups = parsed; console.log('ℹ️ Received direct array of', groups.length, 'groups'); } else if (parsed.response && Array.isArray(parsed.response)) { // Case 2: Wrapped response if (parsed.status !== 'success') { throw new Error(`API error: ${parsed.message || 'Unknown error'}`); } groups = parsed.response; console.log('ℹ️ Received wrapped response with', groups.length, 'groups'); } else { throw new Error('Invalid API response format - expected array or wrapped response'); } } catch (e) { console.error('❌ Failed to parse API response:', { error: e instanceof Error ? e.message : String(e), responseSample: rawResponse.substring(0, 100) + '...' }); throw e; } if (!groups.length) { console.warn('⚠️ API returned empty group list'); } return groups; } catch (error) { console.error('❌ Failed to fetch groups:', { error: error instanceof Error ? error.message : String(e), stack: error instanceof Error ? error.stack : undefined }); throw error; } } private static cacheActiveGroups(): void { const groups = this.dbInstance.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 = this.dbInstance.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; } const communityId = env.WHATSAPP_COMMUNITY_ID; if (!communityId) { console.log('ℹ️ WHATSAPP_COMMUNITY_ID no definido - mostrando comunidades disponibles'); try { const allGroups = await this.fetchGroupsFromAPI(); const communities = allGroups.filter(g => g.linkedParent); if (communities.length === 0) { console.log('❌ No se encontraron comunidades (grupos con linkedParent)'); } else { console.log('\n📋 Comunidades disponibles (copia el ID completo):'); console.log('='.repeat(80)); console.log('Nombre'.padEnd(30), 'ID Comunidad'); console.log('-'.repeat(30), '-'.repeat(48)); communities.forEach(c => { console.log(c.subject.padEnd(30), c.id); }); console.log('='.repeat(80)); console.log('⚠️ ATENCIÓN: Estos IDs son sensibles. No los compartas públicamente.'); console.log(`\n⏳ El proceso terminará automáticamente en 120 segundos...`); // Cuenta regresiva de 120 segundos await new Promise(resolve => { setTimeout(resolve, 120000); const interval = setInterval(() => { const remaining = Math.ceil((120000 - (Date.now() - startTime)) / 1000); process.stdout.write(`\r⏳ Tiempo restante: ${remaining}s `); }, 1000); const startTime = Date.now(); }); console.log('\n\n✅ Listado completado. Por favor configura WHATSAPP_COMMUNITY_ID'); } process.exit(0); } catch (error) { console.error('❌ Error al obtener comunidades:', error instanceof Error ? error.message : error); process.exit(1); } } 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 = this.dbInstance.transaction(() => { // First mark all groups as inactive and update verification timestamp const inactiveResult = this.dbInstance.prepare(` UPDATE groups SET active = FALSE, last_verified = CURRENT_TIMESTAMP WHERE active = TRUE `).run(); console.log('ℹ️ Grupos marcados como inactivos:', { count: inactiveResult.changes, lastId: inactiveResult.lastInsertRowid }); for (const group of groups) { const existing = this.dbInstance.prepare('SELECT 1 FROM groups WHERE id = ?').get(group.id); console.log('Checking group:', group.id, 'exists:', !!existing); if (existing) { const updateResult = this.dbInstance.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 = this.dbInstance.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; } } /** * Checks if a given group ID is active based on the in-memory cache. * * @param groupId The group ID to check (e.g., '123456789@g.us'). * @returns True if the group is active, false otherwise. */ static isGroupActive(groupId: string): boolean { return activeGroupsCache.has(groupId); } }