feat: activar gating de grupos en CommandService y GroupSyncService

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
pull/1/head
borja 1 month ago
parent a03604d293
commit d747e7aa4b

@ -7,6 +7,7 @@ import { ContactsService } from './contacts';
import { ICONS } from '../utils/icons';
import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too
@ -1060,6 +1061,21 @@ export class CommandService {
return [];
}
// Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente
if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try {
if (!AllowedGroups.isAllowed(context.groupId)) {
return [];
}
} catch {
// Si falla el check, ser permisivos
}
}
}
try {
return await this.processTareaCommand(context);
} catch (error) {

@ -3,6 +3,7 @@ import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
// In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -794,6 +795,21 @@ export class GroupSyncService {
* Sincroniza miembros para un grupo concreto (útil tras detectar un grupo nuevo).
*/
public static async syncMembersForGroup(groupId: string): Promise<{ added: number; updated: number; deactivated: number }> {
// Gating en modo 'enforce': solo sincronizar miembros para grupos permitidos
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try {
(AllowedGroups as any).dbInstance = this.dbInstance;
if (!AllowedGroups.isAllowed(groupId)) {
return { added: 0, updated: 0, deactivated: 0 };
}
} catch {
// Si el check falla, seguimos sin bloquear
}
}
} catch {}
try {
const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId);
return this.reconcileGroupMembers(groupId, snapshot);

@ -9,6 +9,7 @@ import { ResponseQueue } from '../../src/services/response-queue';
import { IdentityService } from '../../src/services/identity';
import { GroupSyncService } from '../../src/services/group-sync';
import { RemindersService } from '../../src/services/reminders';
import { AllowedGroups } from '../../src/services/allowed-groups';
/**
* Crea una DB en memoria y aplica initializeDatabase() con todas las migraciones.
@ -45,5 +46,13 @@ export function resetServices(): void {
}
/**
* Nota: en Etapa 1 añadiremos seedAllowed() aquí cuando exista AllowedGroups.
* Marca como 'allowed' los groupIds indicados en la DB provista.
*/
export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void {
(AllowedGroups as any).dbInstance = db;
for (const gid of groupIds) {
const g = String(gid || '').trim();
if (!g) continue;
try { AllowedGroups.setStatus(g, 'allowed'); } catch {}
}
}

@ -0,0 +1,115 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { WebhookServer } from '../../../src/server';
import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue';
import { AllowedGroups } from '../../../src/services/allowed-groups';
import { GroupSyncService } from '../../../src/services/group-sync';
let testDb: Database;
let originalAdd: any;
let simulatedQueue: any[] = [];
const SimulatedResponseQueue = {
async add(responses: any[]) {
simulatedQueue.push(...responses);
},
clear() { simulatedQueue = []; },
get() { return simulatedQueue; }
};
const createTestRequest = (payload: any) =>
new Request('http://localhost:3007', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
describe('WebhookServer - enforce gating (modo=enforce)', () => {
const envBackup = process.env;
beforeAll(() => {
testDb = new Database(':memory:');
initializeDatabase(testDb);
originalAdd = (ResponseQueue as any).add;
});
afterAll(() => {
(ResponseQueue as any).add = originalAdd;
testDb.close();
});
beforeEach(() => {
process.env = {
...envBackup,
NODE_ENV: 'test',
GROUP_GATING_MODE: 'enforce'
};
SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb;
(AllowedGroups as any).dbInstance = testDb;
// Limpiar tablas relevantes
testDb.exec('DELETE FROM response_queue');
testDb.exec('DELETE FROM allowed_groups');
testDb.exec('DELETE FROM users');
});
afterEach(() => {
process.env = envBackup;
});
test('bloquea mensaje de grupo no permitido (no se encolan respuestas)', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'blocked-group@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ayuda' }
}
};
const res = await WebhookServer.handleRequest(createTestRequest(payload));
expect(res.status).toBe(200);
// No debe haber respuestas encoladas (retorno temprano)
expect(SimulatedResponseQueue.get().length).toBe(0);
// allowed_groups no contiene allowed para ese grupo
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'blocked-group@g.us'`).get() as any;
expect(row).toBeUndefined();
});
test('permite mensaje en grupo allowed y procesa comando', async () => {
// Sembrar grupo como allowed
testDb.exec(`
INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at)
VALUES ('allowed-group@g.us', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
`);
// Marcar el grupo como activo en la caché para evitar retorno temprano por "grupo inactivo" en tests
GroupSyncService.activeGroupsCache.set('allowed-group@g.us', 'Allowed Group');
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'allowed-group@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/t ayuda' }
}
};
const res = await WebhookServer.handleRequest(createTestRequest(payload));
expect(res.status).toBe(200);
// Debe haberse encolado al menos una respuesta (ayuda)
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
});
});

@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { makeMemDb } from '../../helpers/db';
import { CommandService } from '../../../src/services/command';
import { AllowedGroups } from '../../../src/services/allowed-groups';
describe('CommandService - gating en modo enforce', () => {
const envBackup = process.env;
beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
const memdb = makeMemDb();
(CommandService as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
});
afterEach(() => {
process.env = envBackup;
});
it('bloquea comandos en grupo no permitido (desconocido)', async () => {
const res = await CommandService.handle({
sender: '34600123456',
groupId: 'g1@g.us',
message: '/t ayuda',
mentions: []
});
expect(Array.isArray(res)).toBe(true);
expect(res.length).toBe(0);
});
it('permite comandos en grupo permitido', async () => {
AllowedGroups.setStatus('g2@g.us', 'allowed', 'G2');
const res = await CommandService.handle({
sender: '34600123456',
groupId: 'g2@g.us',
message: '/t ayuda',
mentions: []
});
expect(res.length).toBeGreaterThan(0);
expect(res[0].recipient).toBe('34600123456');
});
});

@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { makeMemDb } from '../../helpers/db';
import { GroupSyncService } from '../../../src/services/group-sync';
import { AllowedGroups } from '../../../src/services/allowed-groups';
describe('GroupSyncService - gating en syncMembersForGroup (enforce)', () => {
const envBackup = process.env;
beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
const memdb = makeMemDb();
(GroupSyncService as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
// Stub fetchGroupMembersFromAPI para no hacer red
(GroupSyncService as any).fetchGroupMembersFromAPI = async (_groupId: string) => {
return [{ userId: '34600111111', isAdmin: false }];
};
});
afterEach(() => {
process.env = envBackup;
});
it('no sincroniza miembros para grupo no allowed', async () => {
const res = await GroupSyncService.syncMembersForGroup('na@g.us');
expect(res).toEqual({ added: 0, updated: 0, deactivated: 0 });
const db = (GroupSyncService as any).dbInstance;
const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'na@g.us'`).get() as any;
expect(Number(row?.c || 0)).toBe(0);
});
it('sincroniza miembros para grupo allowed', async () => {
AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK');
const res = await GroupSyncService.syncMembersForGroup('ok@g.us');
expect(res.added + res.updated + res.deactivated).toBeGreaterThan(0);
const db = (GroupSyncService as any).dbInstance;
const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'ok@g.us'`).get() as any;
expect(Number(row?.c || 0)).toBeGreaterThan(0);
});
});
Loading…
Cancel
Save