Spaces:
Paused
Paused
| /** | |
| * ============================================ | |
| * π‘οΈ 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(() => {}); | |
| } | |