feat: agrega AdminService y comandos /admin con bypass del gating

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

@ -15,6 +15,7 @@ import { Metrics } from './services/metrics';
import { MaintenanceService } from './services/maintenance';
import { IdentityService } from './services/identity';
import { AllowedGroups } from './services/allowed-groups';
import { AdminService } from './services/admin';
// Bun is available globally when running under Bun runtime
declare global {
@ -299,6 +300,9 @@ export class WebhookServer {
return;
}
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
@ -311,13 +315,13 @@ export class WebhookServer {
if (!exists) {
try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
return;
if (!isAdminCmd) return;
}
} catch {
// Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente
try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
return;
if (!isAdminCmd) return;
}
}
}
@ -329,7 +333,7 @@ export class WebhookServer {
if (gatingMode2 === 'enforce') {
try {
const allowed = AllowedGroups.isAllowed(remoteJid);
if (!allowed) {
if (!allowed && !isAdminCmd) {
try { Metrics.inc('messages_blocked_group_total'); } catch {}
return;
}
@ -339,6 +343,21 @@ export class WebhookServer {
}
}
// Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo
if (messageTextTrimmed.startsWith('/admin')) {
try { (AdminService as any).dbInstance = WebhookServer.dbInstance; } catch {}
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const adminResponses = await AdminService.handle({
sender: normalizedSenderId,
groupId: remoteJid,
message: messageText
});
if (adminResponses.length > 0) {
await ResponseQueue.add(adminResponses);
}
return;
}
// Check/ensure group exists (allow DMs always)
if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) {
// En tests, mantener comportamiento anterior: ignorar mensajes de grupos inactivos
@ -360,7 +379,7 @@ export class WebhookServer {
}
// Forward to command service only if it's a text-ish message and starts with /t or /tarea
const messageTextTrimmed = messageText.trim();
// messageTextTrimmed computed earlier
if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) {
// Rate limiting básico por usuario (desactivado en tests)
if (process.env.NODE_ENV !== 'test') {

@ -0,0 +1,117 @@
import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { AllowedGroups } from './allowed-groups';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
type AdminContext = {
sender: string; // normalized user id (digits only)
groupId: string; // raw JID (group or DM)
message: string; // raw message text
};
type AdminResponse = { recipient: string; message: string };
export class AdminService {
static dbInstance: Database = db;
private static admins(): Set<string> {
const raw = String(process.env.ADMIN_USERS || '');
const set = new Set<string>();
for (const token of raw.split(',').map(s => s.trim()).filter(Boolean)) {
const n = normalizeWhatsAppId(token);
if (n) set.add(n);
}
return set;
}
private static isAdmin(userId: string | null | undefined): boolean {
const n = normalizeWhatsAppId(userId || '');
if (!n) return false;
return this.admins().has(n);
}
private static help(): string {
return [
'Comandos de administración:',
'- /admin pendientes',
'- /admin habilitar-aquí',
'- /admin deshabilitar-aquí',
'- /admin allow-group <group_id@g.us>',
'- /admin block-group <group_id@g.us>',
].join('\n');
}
static async handle(ctx: AdminContext): Promise<AdminResponse[]> {
const sender = normalizeWhatsAppId(ctx.sender);
if (!sender) return [];
if (!this.isAdmin(sender)) {
return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }];
}
// Asegurar acceso a la misma DB para AllowedGroups
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const raw = String(ctx.message || '').trim();
const lower = raw.toLowerCase();
if (!lower.startsWith('/admin')) {
return [];
}
const rest = lower.slice('/admin'.length).trim();
// /admin pendientes
if (rest === 'pendientes') {
const rows = AllowedGroups.listByStatus('pending');
if (!rows || rows.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n');
return [{
recipient: sender,
message: `Grupos pendientes (${rows.length}):\n${list}`
}];
}
// /admin habilitar-aquí
if (rest === 'habilitar-aquí' || rest === 'habilitar-aqui') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
AllowedGroups.setStatus(ctx.groupId, 'allowed');
return [{ recipient: sender, message: `✅ Grupo habilitado: ${ctx.groupId}` }];
}
// /admin deshabilitar-aquí
if (rest === 'deshabilitar-aquí' || rest === 'deshabilitar-aqui') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
AllowedGroups.setStatus(ctx.groupId, 'blocked');
return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }];
}
// /admin allow-group <jid>
if (rest.startsWith('allow-group ')) {
const arg = rest.slice('allow-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
AllowedGroups.setStatus(arg, 'allowed');
return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }];
}
// /admin block-group <jid>
if (rest.startsWith('block-group ')) {
const arg = rest.slice('block-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
AllowedGroups.setStatus(arg, 'blocked');
return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }];
}
// Ayuda por defecto
return [{ recipient: sender, message: this.help() }];
}
}

@ -0,0 +1,111 @@
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';
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 - /admin aprobación en 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',
ADMIN_USERS: '1234567890'
};
SimulatedResponseQueue.clear();
(ResponseQueue as any).add = SimulatedResponseQueue.add;
WebhookServer.dbInstance = testDb;
testDb.exec('DELETE FROM response_queue');
testDb.exec('DELETE FROM allowed_groups');
testDb.exec('DELETE FROM users');
});
afterEach(() => {
process.env = envBackup;
});
test('admin puede habilitar el grupo actual incluso si no está allowed', async () => {
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'new-group@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/admin habilitar-aquí' }
}
};
const res = await WebhookServer.handleRequest(createTestRequest(payload));
expect(res.status).toBe(200);
// Debe haber respuesta de confirmación encolada
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
// El grupo debe figurar como allowed
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'new-group@g.us'`).get() as any;
expect(row && String(row.status)).toBe('allowed');
});
test('no admin no puede usar /admin', async () => {
process.env.ADMIN_USERS = '5555555555';
const payload = {
event: 'messages.upsert',
instance: 'test-instance',
data: {
key: {
remoteJid: 'another-group@g.us',
participant: '1234567890@s.whatsapp.net'
},
message: { conversation: '/admin habilitar-aquí' }
}
};
const res = await WebhookServer.handleRequest(createTestRequest(payload));
expect(res.status).toBe(200);
// Debe haber una respuesta indicando no autorizado
const out = SimulatedResponseQueue.get();
expect(out.length).toBe(1);
expect(String(out[0].message).toLowerCase()).toContain('no estás autorizado');
// Y el grupo no debe estar allowed
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'another-group@g.us'`).get() as any;
expect(row == null).toBe(true);
});
});

@ -0,0 +1,61 @@
import { describe, it, beforeEach, expect } from 'bun:test';
import { makeMemDb } from '../../helpers/db';
import { AdminService } from '../../../src/services/admin';
import { AllowedGroups } from '../../../src/services/allowed-groups';
describe('AdminService - comandos básicos', () => {
const envBackup = process.env;
beforeEach(() => {
process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' };
const memdb = makeMemDb();
(AdminService as any).dbInstance = memdb;
(AllowedGroups as any).dbInstance = memdb;
});
it('rechaza a usuarios no admin', async () => {
const out = await AdminService.handle({
sender: '34999888777',
groupId: 'g1@g.us',
message: '/admin pendientes'
});
expect(out.length).toBe(1);
expect(out[0].message.toLowerCase()).toContain('no estás autorizado');
});
it('lista pendientes', async () => {
AllowedGroups.upsertPending('a@g.us', 'A', 'tester');
AllowedGroups.upsertPending('b@g.us', 'B', 'tester');
const out = await AdminService.handle({
sender: '34600123456',
groupId: 'g1@g.us',
message: '/admin pendientes'
});
expect(out.length).toBe(1);
expect(out[0].message).toContain('Grupos pendientes');
expect(out[0].message).toContain('a@g.us');
expect(out[0].message).toContain('b@g.us');
});
it('habilitar-aquí en grupo', async () => {
const out = await AdminService.handle({
sender: '34600123456',
groupId: 'g1@g.us',
message: '/admin habilitar-aquí'
});
expect(out.length).toBe(1);
expect(AllowedGroups.isAllowed('g1@g.us')).toBe(true);
});
it('allow-group <jid> habilita explícitamente', async () => {
const out = await AdminService.handle({
sender: '34600123456',
groupId: '1234567890@s.whatsapp.net',
message: '/admin allow-group g2@g.us'
});
expect(out.length).toBe(1);
expect(AllowedGroups.isAllowed('g2@g.us')).toBe(true);
});
});
Loading…
Cancel
Save