|
|
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<string, string>(); // 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<EvolutionGroup[]> {
|
|
|
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<void> {
|
|
|
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);
|
|
|
}
|
|
|
}
|