@ -5,17 +5,11 @@ import { ContactsService } from './services/contacts';
import { Migrator } from './db/migrator' ;
import { Migrator } from './db/migrator' ;
import { Metrics } from './services/metrics' ;
import { Metrics } from './services/metrics' ;
import { AllowedGroups } from './services/allowed-groups' ;
import { AllowedGroups } from './services/allowed-groups' ;
import { db , ensureUserExists } from './db' ;
import { db } from './db' ;
import { handleMetricsRequest } from './http/metrics' ;
import { handleMetricsRequest } from './http/metrics' ;
import { handleHealthRequest } from './http/health' ;
import { handleHealthRequest } from './http/health' ;
import { startServices } from './http/bootstrap' ;
import { startServices } from './http/bootstrap' ;
import { normalizeWhatsAppId , isGroupId } from './utils/whatsapp' ;
import { handleMessageUpsert as handleMessageUpsertFn } from './http/webhook-handler' ;
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' ;
// Bun is available globally when running under Bun runtime
// Bun is available globally when running under Bun runtime
declare global {
declare global {
@ -163,305 +157,7 @@ export class WebhookServer {
}
}
static async handleMessageUpsert ( data : any ) {
static async handleMessageUpsert ( data : any ) {
if ( ! data ? . key ? . remoteJid || ! data . message ) {
return await handleMessageUpsertFn ( data , WebhookServer . dbInstance ) ;
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 ) ;
}
}
}
}
}
static validateEnv() {
static validateEnv() {