diff --git a/src/server.ts b/src/server.ts index 373f15f..e1357d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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') { diff --git a/src/services/admin.ts b/src/services/admin.ts new file mode 100644 index 0000000..3a26e57 --- /dev/null +++ b/src/services/admin.ts @@ -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 { + const raw = String(process.env.ADMIN_USERS || ''); + const set = new Set(); + 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 ', + '- /admin block-group ', + ].join('\n'); + } + + static async handle(ctx: AdminContext): Promise { + 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 + 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 + 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() }]; + } +} diff --git a/tests/unit/server/admin-approval.test.ts b/tests/unit/server/admin-approval.test.ts new file mode 100644 index 0000000..698cdc6 --- /dev/null +++ b/tests/unit/server/admin-approval.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/admin.test.ts b/tests/unit/services/admin.test.ts new file mode 100644 index 0000000..e2a87b0 --- /dev/null +++ b/tests/unit/services/admin.test.ts @@ -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 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); + }); +});