/** * ============================================ * πŸ›‘οΈ moderation.js β€” ModeraciΓ³n AutΓ³noma * ============================================ */ import { callAI } from './ai.js'; import { buildModerationPrompt } from './prompt.js'; import { sanitizeOutput, maskSensitiveData } from './security.js'; import * as db from './db.js'; import { readConfig } from './utils.js'; const config = readConfig(); // Rate limit por usuario: evitar flood de anΓ‘lisis const analysisCache = new Map(); // userId β†’ lastAnalyzedAt // ── DetecciΓ³n rΓ‘pida sin IA ─────────────────────────────────────────────────── const SPAM_PATTERNS = [ { pattern: /(.)\1{8,}/, type: 'spam_chars', severity: 'low' }, { pattern: /discord\.gg\//i, type: 'invite_link', severity: 'medium' }, { pattern: /(https?:\/\/\S+){3,}/, type: 'link_flood', severity: 'medium' }, { pattern: /\b(n+i+g+|f+a+g+)\b/i, type: 'slur', severity: 'high' }, ]; const TOXICITY_KEYWORDS = [ 'ojalΓ‘ te mueras', 'mΓ‘tate', 'suicΓ­date', 'eres una mierda', 'te voy a matar', ]; // Contador de mensajes por usuario (ventana de 10s) const messageWindow = new Map(); // userId β†’ [timestamps] // Limpiar messageWindow cada 2 minutos para evitar memory leak setInterval(() => { const cutoff = Date.now() - 15000; for (const [uid, times] of messageWindow.entries()) { const fresh = times.filter(t => t > cutoff); if (fresh.length === 0) messageWindow.delete(uid); else messageWindow.set(uid, fresh); } }, 120000); function isSpamming(userId) { const now = Date.now(); const window = 10000; // 10 segundos const maxMsgs = 6; if (!messageWindow.has(userId)) messageWindow.set(userId, []); const times = messageWindow.get(userId).filter(t => now - t < window); times.push(now); messageWindow.set(userId, times); return times.length >= maxMsgs; } // ── AnΓ‘lisis principal ──────────────────────────────────────────────────────── /** * Analiza un mensaje y decide si requiere acciΓ³n de moderaciΓ³n. * @returns {object|null} AcciΓ³n a tomar, o null si todo estΓ‘ bien */ export async function analyzeMessage(message) { const { content, author, channel } = message; if (!content || author.bot) return null; // Cooldown por usuario (mΓ‘x 1 anΓ‘lisis IA cada 5s) const lastAnalyzed = analysisCache.get(author.id) ?? 0; const canAnalyzeAI = Date.now() - lastAnalyzed > 5000; // ── DetecciΓ³n rΓ‘pida sin IA ─────────────────────────────────────────────── // Spam por velocidad if (isSpamming(author.id)) { return { action : 'timeout', severity : 'medium', reason : 'Spam de mensajes', ruleViolated: 'spam', duration : 5 * 60 * 1000, // 5 minutos message : `${author.username} para con el spam bro, timeout de 5 min`, auto : true, }; } // Patrones de spam/toxicidad hardcoded for (const { pattern, type, severity } of SPAM_PATTERNS) { if (pattern.test(content)) { if (severity === 'high') { return { action : 'timeout', severity, reason : `Contenido prohibido: ${type}`, ruleViolated: type, duration : 60 * 60 * 1000, // 1 hora message : `eso no va aquΓ­ ${author.username}`, auto : true, }; } if (severity === 'medium' && canAnalyzeAI) { // Marcar para anΓ‘lisis IA pero devolver aviso leve ya return { action : 'warn', severity: 'low', reason : `Posible ${type}`, message : `cuidado con los links ${author.username}`, auto : true, }; } } } // Toxicidad hardcoded const lowerContent = content.toLowerCase(); for (const phrase of TOXICITY_KEYWORDS) { if (lowerContent.includes(phrase)) { if (canAnalyzeAI) { analysisCache.set(author.id, Date.now()); return await analyzeWithAI(message); } } } // ── AnΓ‘lisis IA para casos ambiguos ────────────────────────────────────── // Solo si el mensaje tiene cierta longitud y no se analizΓ³ hace poco if (canAnalyzeAI && content.length > 30) { const conflictScore = detectConflictSignals(content); if (conflictScore > config.behavior.moderationThreshold) { analysisCache.set(author.id, Date.now()); return await analyzeWithAI(message); } } return null; } async function analyzeWithAI(message) { const { content, author, channel } = message; const userProfile = await db.getUserWithRoles(author.id) ?? {}; const modHistory = await db.getUserModHistory(author.id, 3); try { const raw = await callAI([ { role: 'system', content: 'Eres Zelin analizando moderaciΓ³n. Responde SOLO JSON vΓ‘lido.' }, { role: 'user', content: buildModerationPrompt(content, { ...userProfile, modHistory }) }, ], 'fast', 300); const clean = raw.replace(/```json|```/g, '').trim(); const result = JSON.parse(clean); if (!result.requiresAction) return null; return { action : result.action, severity : result.severity, reason : result.reason, ruleViolated: result.ruleViolated, duration : result.action === 'timeout' ? 30 * 60 * 1000 : null, message : sanitizeOutput(result.message ?? ''), auto : true, }; } catch { return null; } } // ── Detectar seΓ±ales de conflicto en texto ──────────────────────────────────── export function detectConflictSignals(content) { const lower = content.toLowerCase(); let score = 0; const signals = [ [/\b(idiota|estΓΊpido|imbΓ©cil|inΓΊtil)\b/i, 0.4], [/\b(te odio|te detesto)\b/i, 0.5], [/[!?]{3,}/, 0.2], [/[A-Z]{5,}/, 0.15], [/\b(mentiroso|tramposo|cheater|hack)\b/i, 0.35], [/\b(ban|reportar|report)\b/i, 0.2], ]; for (const [pattern, weight] of signals) { if (pattern.test(content)) score += weight; } return Math.min(score, 1.0); } // ── Detectar sentimiento general ────────────────────────────────────────────── export function detectSentiment(content) { const lower = content.toLowerCase(); const positiveWords = ['bien', 'genial', 'chΓ©vere', 'crack', 'bueno', 'gracias', 'jajaja', 'xd', ':)', '❀️', 'πŸ”₯']; const negativeWords = ['malo', 'pΓ©simo', 'odio', 'aburrido', 'que rollo', 'pfff', '😀', '😑']; const conflictWords = ['mentira', 'trampa', 'ban', 'reporte', 'idiota', 'estΓΊpido']; let pos = 0, neg = 0, conf = 0; for (const w of positiveWords) if (lower.includes(w)) pos++; for (const w of negativeWords) if (lower.includes(w)) neg++; for (const w of conflictWords) if (lower.includes(w)) conf++; if (conf > 0) return 'conflict'; if (pos > neg) return 'positive'; if (neg > pos) return 'negative'; return 'neutral'; } // ── Aplicar acciΓ³n de moderaciΓ³n ───────────────────────────────────────────── export async function applyModAction(message, action) { const { author, channel, guild } = message; // Guardar en DB siempre await db.logModAction({ action : action.action, targetUserId: author.id, channelId : channel.id, messageId : message.id, reason : action.reason, durationMs : action.duration ?? null, evidence : maskSensitiveData(message.content?.substring(0, 500)), ruleViolated: action.ruleViolated ?? null, auto : action.auto ?? true, }); // Ejecutar acciΓ³n try { if (action.action === 'warn' && action.message) { await channel.send(action.message); } if (action.action === 'timeout' && action.duration) { const member = guild.members.cache.get(author.id); if (member) { await member.timeout(action.duration, action.reason); if (action.message) await channel.send(action.message); } } if (action.action === 'delete_msg' || action.action === 'delete') { await message.delete().catch(() => {}); if (action.message) await channel.send(action.message); } } catch (err) { console.error('[Moderation] Error aplicando acciΓ³n:', err.message); // Notificar al staff si no pudimos actuar await notifyStaffModFailure(guild, action, author.username, err.message); } } async function notifyStaffModFailure(guild, action, username, error) { const staffChannel = guild.channels.cache.find(c => c.isTextBased?.() && ( c.name.includes('staff') || c.name.includes('moderac') || c.name.includes('admin') ) && !c.name.includes('ticket') && !c.name.includes('log') ); if (!staffChannel) return; await staffChannel.send( `⚠️ No pude ejecutar **${action.action}** sobre **${username}**: ${error}\nMotivo: ${action.reason}` ).catch(() => {}); } // ── Notificar caΓ­da del servidor MC al staff ────────────────────────────────── export async function notifyServerDown(guild) { const staffChannel = guild.channels.cache.find(c => c.isTextBased?.() && ( c.name.includes('staff') || c.name.includes('moderac') || c.name.includes('admin') ) && !c.name.includes('ticket') && !c.name.includes('log') ); if (!staffChannel) return; await staffChannel.send( `🚨 **El servidor de Minecraft estΓ‘ caΓ­do** (play.tomatesmp.pw no responde)\n\nRevisad el panel por favor.` ).catch(() => {}); } export async function notifyServerUp(guild) { const staffChannel = guild.channels.cache.find(c => c.isTextBased?.() && ( c.name.includes('staff') || c.name.includes('moderac') || c.name.includes('admin') ) && !c.name.includes('ticket') && !c.name.includes('log') ); if (!staffChannel) return; await staffChannel.send(`βœ… El servidor de Minecraft volviΓ³ online.`).catch(() => {}); }