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.

336 lines
11 KiB
TypeScript

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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