|
|
|
|
@ -1,5 +1,6 @@
|
|
|
|
|
import type { Database } from 'bun:sqlite';
|
|
|
|
|
import { db } from '../db';
|
|
|
|
|
import { db, ensureUserExists } from '../db';
|
|
|
|
|
import { normalizeWhatsAppId } from '../utils/whatsapp';
|
|
|
|
|
// Environment variables will be mocked in tests
|
|
|
|
|
const env = process.env;
|
|
|
|
|
|
|
|
|
|
@ -336,4 +337,178 @@ export class GroupSyncService {
|
|
|
|
|
static isGroupActive(groupId: string): boolean {
|
|
|
|
|
return this.activeGroupsCache.has(groupId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch members for a single group from Evolution API. Uses a robust parser to accept multiple payload shapes.
|
|
|
|
|
private static async fetchGroupMembersFromAPI(groupId: string): Promise<Array<{ userId: string; isAdmin: boolean }>> {
|
|
|
|
|
const url = `${env.EVOLUTION_API_URL}/group/fetchAllGroups/${env.EVOLUTION_API_INSTANCE}?getParticipants=true`;
|
|
|
|
|
console.log('ℹ️ Fetching members via fetchAllGroups (participants=true):', { groupId });
|
|
|
|
|
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
headers: { apikey: env.EVOLUTION_API_KEY },
|
|
|
|
|
httpVersion: '2',
|
|
|
|
|
timeout: 320000
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const body = await response.text().catch(() => '');
|
|
|
|
|
throw new Error(`Failed to fetch groups with participants: ${response.status} ${response.statusText} - ${body.slice(0,200)}`);
|
|
|
|
|
}
|
|
|
|
|
const raw = await response.text();
|
|
|
|
|
let parsed: any;
|
|
|
|
|
try {
|
|
|
|
|
parsed = JSON.parse(raw);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('❌ Failed to parse members response JSON:', String(e));
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let groups: any[] = [];
|
|
|
|
|
if (Array.isArray(parsed)) {
|
|
|
|
|
groups = parsed;
|
|
|
|
|
} else if (parsed && Array.isArray(parsed.response)) {
|
|
|
|
|
groups = parsed.response;
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('Invalid response format for groups with participants');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const g = groups.find((g: any) => g?.id === groupId);
|
|
|
|
|
if (!g) {
|
|
|
|
|
console.warn(`⚠️ Group ${groupId} not present in fetchAllGroups(getParticipants=true) response`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
const participants = Array.isArray(g.participants) ? g.participants : [];
|
|
|
|
|
|
|
|
|
|
const result: Array<{ userId: string; isAdmin: boolean }> = [];
|
|
|
|
|
for (const p of participants) {
|
|
|
|
|
let jid: string | null = null;
|
|
|
|
|
let isAdmin = false;
|
|
|
|
|
|
|
|
|
|
if (typeof p === 'string') {
|
|
|
|
|
jid = p;
|
|
|
|
|
} else if (p && typeof p === 'object') {
|
|
|
|
|
jid = p.id || p.jid || p.user || p?.user?.id || null;
|
|
|
|
|
if (typeof p.isAdmin === 'boolean') {
|
|
|
|
|
isAdmin = p.isAdmin;
|
|
|
|
|
} else if (typeof p.admin === 'string') {
|
|
|
|
|
// common shapes: 'admin', 'superadmin' or null
|
|
|
|
|
isAdmin = p.admin === 'admin' || p.admin === 'superadmin';
|
|
|
|
|
} else if (typeof p.role === 'string') {
|
|
|
|
|
isAdmin = p.role.toLowerCase().includes('admin');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const norm = normalizeWhatsAppId(jid);
|
|
|
|
|
if (!norm) continue;
|
|
|
|
|
result.push({ userId: norm, isAdmin });
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reconciles current DB membership state for a group with a fresh snapshot.
|
|
|
|
|
* Idempotente y atómico por grupo.
|
|
|
|
|
*/
|
|
|
|
|
static reconcileGroupMembers(groupId: string, snapshot: Array<{ userId: string; isAdmin: boolean }>, nowIso?: string): { added: number; updated: number; deactivated: number } {
|
|
|
|
|
if (!groupId || !Array.isArray(snapshot)) {
|
|
|
|
|
throw new Error('Invalid arguments for reconcileGroupMembers');
|
|
|
|
|
}
|
|
|
|
|
const now = nowIso || new Date().toISOString().replace('T', ' ').replace('Z', '');
|
|
|
|
|
let added = 0, updated = 0, deactivated = 0;
|
|
|
|
|
|
|
|
|
|
// Build quick lookup from snapshot
|
|
|
|
|
const incoming = new Map<string, { isAdmin: boolean }>();
|
|
|
|
|
for (const m of snapshot) {
|
|
|
|
|
if (!m?.userId) continue;
|
|
|
|
|
incoming.set(m.userId, { isAdmin: !!m.isAdmin });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.dbInstance.transaction(() => {
|
|
|
|
|
// Load existing membership for group
|
|
|
|
|
const existingRows = this.dbInstance.prepare(`
|
|
|
|
|
SELECT user_id, is_admin, is_active
|
|
|
|
|
FROM group_members
|
|
|
|
|
WHERE group_id = ?
|
|
|
|
|
`).all(groupId) as Array<{ user_id: string; is_admin: number; is_active: number }>;
|
|
|
|
|
|
|
|
|
|
const existing = new Map(existingRows.map(r => [r.user_id, { isAdmin: !!r.is_admin, isActive: !!r.is_active }]));
|
|
|
|
|
|
|
|
|
|
// Upsert present members
|
|
|
|
|
for (const [userId, { isAdmin }] of incoming.entries()) {
|
|
|
|
|
// Ensure user exists (FK)
|
|
|
|
|
ensureUserExists(userId, this.dbInstance);
|
|
|
|
|
|
|
|
|
|
const row = existing.get(userId);
|
|
|
|
|
if (!row) {
|
|
|
|
|
// insert
|
|
|
|
|
this.dbInstance.prepare(`
|
|
|
|
|
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
|
|
|
|
|
VALUES (?, ?, ?, 1, ?, ?)
|
|
|
|
|
`).run(groupId, userId, isAdmin ? 1 : 0, now, now);
|
|
|
|
|
added++;
|
|
|
|
|
} else {
|
|
|
|
|
// update if needed
|
|
|
|
|
let roleChanged = row.isAdmin !== isAdmin;
|
|
|
|
|
if (!row.isActive || roleChanged) {
|
|
|
|
|
this.dbInstance.prepare(`
|
|
|
|
|
UPDATE group_members
|
|
|
|
|
SET is_active = 1,
|
|
|
|
|
is_admin = ?,
|
|
|
|
|
last_seen_at = ?,
|
|
|
|
|
last_role_change_at = CASE WHEN ? THEN ? ELSE last_role_change_at END
|
|
|
|
|
WHERE group_id = ? AND user_id = ?
|
|
|
|
|
`).run(isAdmin ? 1 : 0, now, roleChanged ? 1 : 0, roleChanged ? now : null, groupId, userId);
|
|
|
|
|
updated++;
|
|
|
|
|
} else {
|
|
|
|
|
// still update last_seen_at to reflect presence
|
|
|
|
|
this.dbInstance.prepare(`
|
|
|
|
|
UPDATE group_members
|
|
|
|
|
SET last_seen_at = ?
|
|
|
|
|
WHERE group_id = ? AND user_id = ?
|
|
|
|
|
`).run(now, groupId, userId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deactivate absent members
|
|
|
|
|
for (const [userId, state] of existing.entries()) {
|
|
|
|
|
if (!incoming.has(userId) && state.isActive) {
|
|
|
|
|
this.dbInstance.prepare(`
|
|
|
|
|
UPDATE group_members
|
|
|
|
|
SET is_active = 0,
|
|
|
|
|
last_seen_at = ?
|
|
|
|
|
WHERE group_id = ? AND user_id = ?
|
|
|
|
|
`).run(now, groupId, userId);
|
|
|
|
|
deactivated++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return { added, updated, deactivated };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sync members for all active groups by calling Evolution API and reconciling.
|
|
|
|
|
* Devuelve contadores agregados.
|
|
|
|
|
*/
|
|
|
|
|
static async syncMembersForActiveGroups(): Promise<{ groups: number; added: number; updated: number; deactivated: number }> {
|
|
|
|
|
// ensure cache is populated
|
|
|
|
|
if (this.activeGroupsCache.size === 0) {
|
|
|
|
|
this.cacheActiveGroups();
|
|
|
|
|
}
|
|
|
|
|
let groups = 0, added = 0, updated = 0, deactivated = 0;
|
|
|
|
|
for (const [groupId] of this.activeGroupsCache.entries()) {
|
|
|
|
|
try {
|
|
|
|
|
const snapshot = await this.fetchGroupMembersFromAPI(groupId);
|
|
|
|
|
const res = this.reconcileGroupMembers(groupId, snapshot);
|
|
|
|
|
groups++;
|
|
|
|
|
added += res.added;
|
|
|
|
|
updated += res.updated;
|
|
|
|
|
deactivated += res.deactivated;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(`❌ Failed to sync members for group ${groupId}:`, e instanceof Error ? e.message : String(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('ℹ️ Members sync summary:', { groups, added, updated, deactivated });
|
|
|
|
|
return { groups, added, updated, deactivated };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|