From e430fc1d4af3727148b2024e54bb087de1d1574f Mon Sep 17 00:00:00 2001 From: brobert Date: Mon, 10 Nov 2025 14:53:42 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20extrae=20handleMessageUpsert=20a=20?= =?UTF-8?q?webhook-handler.ts=20y=20=C3=BAsalo=20en=20WebhookServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/http/webhook-handler.ts | 324 ++++++++++++++++++++++++++++++++++++ src/server.ts | 310 +--------------------------------- 2 files changed, 327 insertions(+), 307 deletions(-) create mode 100644 src/http/webhook-handler.ts diff --git a/src/http/webhook-handler.ts b/src/http/webhook-handler.ts new file mode 100644 index 0000000..07842a2 --- /dev/null +++ b/src/http/webhook-handler.ts @@ -0,0 +1,324 @@ +import type { Database } from 'bun:sqlite'; +import { ensureUserExists } from '../db'; +import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; +import { IdentityService } from '../services/identity'; +import { AllowedGroups } from '../services/allowed-groups'; +import { GroupSyncService } from '../services/group-sync'; +import { ResponseQueue } from '../services/response-queue'; +import { AdminService } from '../services/admin'; +import { CommandService } from '../services/command'; +import { TaskService } from '../tasks/service'; +import { RateLimiter } from '../services/rate-limit'; +import { Metrics } from '../services/metrics'; + +function getMessageText(message: any): string { + if (!message || typeof message !== 'object') return ''; + const text = + message.conversation || + message?.extendedTextMessage?.text || + message?.imageMessage?.caption || + message?.videoMessage?.caption || + ''; + return typeof text === 'string' ? text.trim() : ''; +} + +export async function handleMessageUpsert(data: any, db: Database): Promise { + if (!data?.key?.remoteJid || !data.message) { + if (process.env.NODE_ENV !== 'test') { + console.log('⚠️ Invalid message format - missing required fields'); + console.log(data); + } + return; + } + + const messageText = getMessageText(data.message); + if (!messageText) { + if (process.env.NODE_ENV !== 'test') { + console.log('⚠️ Empty or unsupported message content'); + } + return; + } + + // Determine sender depending on context (group vs DM) and ignore non-user messages + const remoteJid = data.key.remoteJid; + const participant = data.key.participant; + const fromMe = !!data.key.fromMe; + + // Ignore broadcasts/status + if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) { + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Ignoring broadcast/status message'); + } + return; + } + + // Ignore our own messages + if (fromMe) { + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Ignoring message sent by the bot (fromMe=true)'); + } + return; + } + + // Compute sender JID based on chat type (prefer participantAlt when available due to Baileys change) + const senderRaw = isGroupId(remoteJid) + ? (data.key.participantAlt || participant) + : remoteJid; + + // Aprender mapping alias→número cuando vienen ambos y difieren (participant vs participantAlt) + if (isGroupId(remoteJid)) { + const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null; + const p = typeof participant === 'string' ? participant : null; + if (pAlt && p) { + try { + const nAlt = normalizeWhatsAppId(pAlt); + const n = normalizeWhatsAppId(p); + if (process.env.NODE_ENV !== 'test') { + console.log('[A0] message.key participants', { + participant: p, + participantAlt: pAlt, + normalized_participant: n, + normalized_participantAlt: nAlt, + alias_upsert: !!(nAlt && n && nAlt !== n) + }); + } + if (nAlt && n && nAlt !== n) { + IdentityService.upsertAlias(p, pAlt, 'message.key'); + } + } catch {} + } + } + + // Normalize sender ID for consistency and validation + const normalizedSenderId = normalizeWhatsAppId(senderRaw); + if (!normalizedSenderId) { + if (process.env.NODE_ENV !== 'test') { + console.debug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe }); + } + return; + } + + // Avoid processing messages from the bot number + if (process.env.CHATBOT_PHONE_NUMBER && normalizedSenderId === process.env.CHATBOT_PHONE_NUMBER) { + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Ignoring message from the bot number'); + } + return; + } + + // Ensure user exists in database (swallow DB errors to keep webhook 200) + let userId: string | null = null; + try { + userId = ensureUserExists(senderRaw, db); + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + console.error('⚠️ Error ensuring user exists, ignoring message:', e); + } + return; + } + if (!userId) { + if (process.env.NODE_ENV !== 'test') { + console.log('⚠️ Failed to ensure user exists, ignoring message'); + } + return; + } + + const messageTextTrimmed = messageText.trim(); + const isAdminCmd = messageTextTrimmed.startsWith('/admin'); + + // A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM) + if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') { + const base = (process.env.WEB_BASE_URL || '').trim(); + const msg = base + ? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace." + : "Listo, ya puedes reclamar/ser responsable."; + try { + await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]); + } catch {} + return; + } + + // Etapa 2: Descubrimiento seguro de grupos (modo 'discover') + if (isGroupId(remoteJid)) { + try { AllowedGroups.dbInstance = db; } catch {} + const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (gatingMode === 'discover') { + try { + const exists = db + .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`) + .get(remoteJid); + if (!exists) { + try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} + try { AllowedGroups.dbInstance = db; } catch {} + try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + try { + const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; + if (notify && !isAdminCmd) { + const admins = AdminService.getAdmins(); + if (admins.length > 0) { + const info = remoteJid; + const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; + await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); + } + } + } catch {} + if (!isAdminCmd) return; + } + } catch { + // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente + try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} + try { AllowedGroups.dbInstance = db; } catch {} + try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + try { + const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; + if (notify && !isAdminCmd) { + const admins = AdminService.getAdmins(); + if (admins.length > 0) { + const info = remoteJid; + const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; + await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); + } + } + } catch {} + if (!isAdminCmd) return; + } + } + } + + // Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos + if (isGroupId(remoteJid)) { + try { AllowedGroups.dbInstance = db; } catch {} + const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (gatingMode2 === 'enforce') { + try { + const allowed = AllowedGroups.isAllowed(remoteJid); + if (!allowed && !isAdminCmd) { + try { Metrics.inc('messages_blocked_group_total'); } catch {} + return; + } + } catch { + // Si falla el check por cualquier motivo, ser conservadores y permitir + } + } + } + + // Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo + if (messageTextTrimmed.startsWith('/admin')) { + try { AdminService.dbInstance = db; } catch {} + try { AllowedGroups.dbInstance = db; } 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 + if (process.env.NODE_ENV === 'test') { + return; + } + if (process.env.NODE_ENV !== 'test') { + console.log('ℹ️ Group not active in cache — ensuring group (no immediate members sync)'); + } + try { + GroupSyncService.ensureGroupExists(data.key.remoteJid); + try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {} + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + console.error('⚠️ Failed to ensure group on-the-fly:', e); + } + } + } + + // Forward to command service only if it's a text-ish message and starts with /t or /tarea + if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { + // Rate limiting básico por usuario (desactivado en tests) + if (process.env.NODE_ENV !== 'test') { + const allowed = RateLimiter.checkAndConsume(normalizedSenderId); + if (!allowed) { + // Notificar como máximo una vez por minuto + if (RateLimiter.shouldNotify(normalizedSenderId)) { + await ResponseQueue.add([{ + recipient: normalizedSenderId, + message: `Has superado el límite de ${((() => { const v = Number(process.env.RATE_LIMIT_PER_MIN); return Number.isFinite(v) && v > 0 ? v : 15; })())} comandos por minuto. Inténtalo de nuevo en un momento.` + }]); + } + return; + } + } + // Extraer menciones desde el mensaje (varios formatos) + const mentions = data.message?.contextInfo?.mentionedJid + || data.message?.extendedTextMessage?.contextInfo?.mentionedJid + || data.message?.imageMessage?.contextInfo?.mentionedJid + || data.message?.videoMessage?.contextInfo?.mentionedJid + || []; + + // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) + CommandService.dbInstance = db; + TaskService.dbInstance = db; + + // Delegar el manejo del comando + const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; + const participantForKey = typeof data?.key?.participantAlt === 'string' + ? data.key.participantAlt + : (typeof data?.key?.participant === 'string' ? data.key.participant : null); + const outcome = await CommandService.handleWithOutcome({ + sender: normalizedSenderId, + groupId: data.key.remoteJid, + message: messageText, + mentions, + messageId: messageId || undefined, + participant: participantForKey || undefined, + fromMe: !!data?.key?.fromMe + }); + const responses = outcome.responses; + + // Encolar respuestas si las hay + if (responses.length > 0) { + await ResponseQueue.add(responses); + } + + // Reaccionar al mensaje del comando con outcome explícito + try { + const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); + const enabled = ['true','1','yes','on'].includes(reactionsEnabled); + if (!enabled) return; + + if (!messageId) return; + + const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); + const isGroup = isGroupId(data.key.remoteJid); + if (scope !== 'all' && !isGroup) return; + + // Respetar gating 'enforce' + try { AllowedGroups.dbInstance = db; } catch {} + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce' && isGroup) { + try { + if (!AllowedGroups.isAllowed(data.key.remoteJid)) { + return; + } + } catch {} + } + + const emoji = outcome.ok ? '🤖' : '⚠️'; + const participant = typeof data?.key?.participantAlt === 'string' + ? data.key.participantAlt + : (typeof data?.key?.participant === 'string' ? data.key.participant : undefined); + await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji, { participant, fromMe: !!data?.key?.fromMe }); + } catch (e) { + // No romper el flujo por errores de reacción + if (process.env.NODE_ENV !== 'test') { + console.warn('⚠️ Reaction enqueue failed:', e); + } + } + } +} diff --git a/src/server.ts b/src/server.ts index c7a5183..7966cb7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,17 +5,11 @@ import { ContactsService } from './services/contacts'; import { Migrator } from './db/migrator'; import { Metrics } from './services/metrics'; import { AllowedGroups } from './services/allowed-groups'; -import { db, ensureUserExists } from './db'; +import { db } from './db'; import { handleMetricsRequest } from './http/metrics'; import { handleHealthRequest } from './http/health'; import { startServices } from './http/bootstrap'; -import { normalizeWhatsAppId, isGroupId } from './utils/whatsapp'; -import { IdentityService } from './services/identity'; -import { ResponseQueue } from './services/response-queue'; -import { AdminService } from './services/admin'; -import { CommandService } from './services/command'; -import { TaskService } from './tasks/service'; -import { RateLimiter } from './services/rate-limit'; +import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler'; // Bun is available globally when running under Bun runtime declare global { @@ -163,305 +157,7 @@ export class WebhookServer { } static async handleMessageUpsert(data: any) { - if (!data?.key?.remoteJid || !data.message) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Invalid message format - missing required fields'); - console.log(data); - } - return; - } - - const messageText = WebhookServer.getMessageText(data.message); - if (!messageText) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Empty or unsupported message content'); - } - return; - } - - // Determine sender depending on context (group vs DM) and ignore non-user messages - const remoteJid = data.key.remoteJid; - const participant = data.key.participant; - const fromMe = !!data.key.fromMe; - - // Ignore broadcasts/status - if (remoteJid === 'status@broadcast' || (typeof remoteJid === 'string' && remoteJid.endsWith('@broadcast'))) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring broadcast/status message'); - } - return; - } - - // Ignore our own messages - if (fromMe) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring message sent by the bot (fromMe=true)'); - } - return; - } - - // Compute sender JID based on chat type (prefer participantAlt when available due to Baileys change) - const senderRaw = isGroupId(remoteJid) - ? (data.key.participantAlt || participant) - : remoteJid; - - // Aprender mapping alias→número cuando vienen ambos y difieren (participant vs participantAlt) - if (isGroupId(remoteJid)) { - const pAlt = typeof data.key.participantAlt === 'string' ? data.key.participantAlt : null; - const p = typeof participant === 'string' ? participant : null; - if (pAlt && p) { - try { - const nAlt = normalizeWhatsAppId(pAlt); - const n = normalizeWhatsAppId(p); - if (process.env.NODE_ENV !== 'test') { - console.log('[A0] message.key participants', { - participant: p, - participantAlt: pAlt, - normalized_participant: n, - normalized_participantAlt: nAlt, - alias_upsert: !!(nAlt && n && nAlt !== n) - }); - } - if (nAlt && n && nAlt !== n) { - IdentityService.upsertAlias(p, pAlt, 'message.key'); - } - } catch {} - } - } - - // Normalize sender ID for consistency and validation - const normalizedSenderId = normalizeWhatsAppId(senderRaw); - if (!normalizedSenderId) { - if (process.env.NODE_ENV !== 'test') { - console.debug('⚠️ Invalid sender ID, ignoring message', { remoteJid, participant, fromMe }); - } - return; - } - - // Avoid processing messages from the bot number - if (process.env.CHATBOT_PHONE_NUMBER && normalizedSenderId === process.env.CHATBOT_PHONE_NUMBER) { - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Ignoring message from the bot number'); - } - return; - } - - // Ensure user exists in database (swallow DB errors to keep webhook 200) - let userId: string | null = null; - try { - userId = ensureUserExists(senderRaw, WebhookServer.dbInstance); - } catch (e) { - if (process.env.NODE_ENV !== 'test') { - console.error('⚠️ Error ensuring user exists, ignoring message:', e); - } - return; - } - if (!userId) { - if (process.env.NODE_ENV !== 'test') { - console.log('⚠️ Failed to ensure user exists, ignoring message'); - } - return; - } - - const messageTextTrimmed = messageText.trim(); - const isAdminCmd = messageTextTrimmed.startsWith('/admin'); - - // A4: Primer DM "activar" — alta/confirmación idempotente (solo en DM) - if (!isGroupId(remoteJid) && messageTextTrimmed === 'activar') { - const base = (process.env.WEB_BASE_URL || '').trim(); - const msg = base - ? "Listo, ya puedes reclamar/ser responsable y acceder a la web. Para acceder a la web, envía '/t web' y abre el enlace." - : "Listo, ya puedes reclamar/ser responsable."; - try { - await ResponseQueue.add([{ recipient: normalizedSenderId, message: msg }]); - } catch {} - return; - } - - // Etapa 2: Descubrimiento seguro de grupos (modo 'discover') - if (isGroupId(remoteJid)) { - try { AllowedGroups.dbInstance = WebhookServer.dbInstance; } catch {} - const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (gatingMode === 'discover') { - try { - const exists = WebhookServer.dbInstance - .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`) - .get(remoteJid); - if (!exists) { - try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} - try { AllowedGroups.dbInstance = WebhookServer.dbInstance; } catch {} - try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} - try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - try { - const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; - if (notify && !isAdminCmd) { - const admins = AdminService.getAdmins(); - if (admins.length > 0) { - const info = remoteJid; - const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; - await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); - } - } - } catch {} - if (!isAdminCmd) return; - } - } catch { - // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente - try { await GroupSyncService.ensureGroupLabelAndName(remoteJid); } catch {} - try { AllowedGroups.dbInstance = WebhookServer.dbInstance; } catch {} - try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} - try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - try { - const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; - if (notify && !isAdminCmd) { - const admins = AdminService.getAdmins(); - if (admins.length > 0) { - const info = remoteJid; - const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; - await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); - } - } - } catch {} - if (!isAdminCmd) return; - } - } - } - - // Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos - if (isGroupId(remoteJid)) { - try { AllowedGroups.dbInstance = WebhookServer.dbInstance; } catch {} - const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (gatingMode2 === 'enforce') { - try { - const allowed = AllowedGroups.isAllowed(remoteJid); - if (!allowed && !isAdminCmd) { - try { Metrics.inc('messages_blocked_group_total'); } catch {} - return; - } - } catch { - // Si falla el check por cualquier motivo, ser conservadores y permitir - } - } - } - - // Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo - if (messageTextTrimmed.startsWith('/admin')) { - try { AdminService.dbInstance = WebhookServer.dbInstance; } catch {} - try { AllowedGroups.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 - if (process.env.NODE_ENV === 'test') { - return; - } - if (process.env.NODE_ENV !== 'test') { - console.log('ℹ️ Group not active in cache — ensuring group (no immediate members sync)'); - } - try { - GroupSyncService.ensureGroupExists(data.key.remoteJid); - try { GroupSyncService.upsertMemberSeen(data.key.remoteJid, normalizedSenderId); } catch {} - } catch (e) { - if (process.env.NODE_ENV !== 'test') { - console.error('⚠️ Failed to ensure group on-the-fly:', e); - } - } - } - - // Forward to command service only if it's a text-ish message and starts with /t or /tarea - // 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') { - const allowed = RateLimiter.checkAndConsume(normalizedSenderId); - if (!allowed) { - // Notificar como máximo una vez por minuto - if (RateLimiter.shouldNotify(normalizedSenderId)) { - await ResponseQueue.add([{ - recipient: normalizedSenderId, - message: `Has superado el límite de ${((() => { const v = Number(process.env.RATE_LIMIT_PER_MIN); return Number.isFinite(v) && v > 0 ? v : 15; })())} comandos por minuto. Inténtalo de nuevo en un momento.` - }]); - } - return; - } - } - // Extraer menciones desde el mensaje (varios formatos) - const mentions = data.message?.contextInfo?.mentionedJid - || data.message?.extendedTextMessage?.contextInfo?.mentionedJid - || data.message?.imageMessage?.contextInfo?.mentionedJid - || data.message?.videoMessage?.contextInfo?.mentionedJid - || []; - - // Asegurar que CommandService y TaskService usen la misma DB (tests/producción) - CommandService.dbInstance = WebhookServer.dbInstance; - TaskService.dbInstance = WebhookServer.dbInstance; - - // Delegar el manejo del comando - const messageId = typeof data?.key?.id === 'string' ? data.key.id : null; - const participantForKey = typeof data?.key?.participantAlt === 'string' - ? data.key.participantAlt - : (typeof data?.key?.participant === 'string' ? data.key.participant : null); - const outcome = await CommandService.handleWithOutcome({ - sender: normalizedSenderId, - groupId: data.key.remoteJid, - message: messageText, - mentions, - messageId: messageId || undefined, - participant: participantForKey || undefined, - fromMe: !!data?.key?.fromMe - }); - const responses = outcome.responses; - - // Encolar respuestas si las hay - if (responses.length > 0) { - await ResponseQueue.add(responses); - } - - // Reaccionar al mensaje del comando con outcome explícito - try { - const reactionsEnabled = String(process.env.REACTIONS_ENABLED || 'false').toLowerCase(); - const enabled = ['true','1','yes','on'].includes(reactionsEnabled); - if (!enabled) return; - - if (!messageId) return; - - const scope = String(process.env.REACTIONS_SCOPE || 'groups').toLowerCase(); - const isGroup = isGroupId(data.key.remoteJid); - if (scope !== 'all' && !isGroup) return; - - // Respetar gating 'enforce' - try { AllowedGroups.dbInstance = WebhookServer.dbInstance; } catch {} - const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); - if (mode === 'enforce' && isGroup) { - try { - if (!AllowedGroups.isAllowed(data.key.remoteJid)) { - return; - } - } catch {} - } - - const emoji = outcome.ok ? '🤖' : '⚠️'; - const participant = typeof data?.key?.participantAlt === 'string' - ? data.key.participantAlt - : (typeof data?.key?.participant === 'string' ? data.key.participant : undefined); - await ResponseQueue.enqueueReaction(data.key.remoteJid, messageId, emoji, { participant, fromMe: !!data?.key?.fromMe }); - } catch (e) { - // No romper el flujo por errores de reacción - if (process.env.NODE_ENV !== 'test') { - console.warn('⚠️ Reaction enqueue failed:', e); - } - } - } + return await handleMessageUpsertFn(data, WebhookServer.dbInstance); } static validateEnv() {