zelin-bot / src /moderation.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* πŸ›‘οΈ 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(() => {});
}