from __future__ import annotations import asyncio import json import time from datetime import datetime, timezone from collections import defaultdict, deque from typing import Any import discord from discord.ext import commands from backup_manager import create_backup, restore_backup from config import settings from storage import storage DANGEROUS = { "administrator", "manage_guild", "manage_roles", "manage_channels", "ban_members", "kick_members", "manage_webhooks", } DEFAULT_THRESHOLDS = { "channel_delete": (3, 12), "role_delete": (3, 12), "channel_create": (6, 15), "role_create": (6, 15), "ban": (4, 15), "kick": (4, 15), "webhook": (3, 15), "dangerous_role_grant": (2, 20), "bot_add": (1, 30), } class SecurityEngine: def __init__(self, bot: commands.Bot) -> None: self.bot = bot self.counters: dict[tuple[int, int, str], deque[float]] = defaultdict(deque) self.command_task: asyncio.Task | None = None self.backup_task: asyncio.Task | None = None self.resource_task: asyncio.Task | None = None async def ready(self) -> None: for guild in self.bot.guilds: await storage.upsert_guild(guild) await storage.sync_guild_resources(guild) if not self.command_task or self.command_task.done(): self.command_task = asyncio.create_task(self.command_loop()) if not self.backup_task or self.backup_task.done(): self.backup_task = asyncio.create_task(self.backup_loop()) if not self.resource_task or self.resource_task.done(): self.resource_task = asyncio.create_task(self.resource_loop()) async def is_trusted(self, guild: discord.Guild, user_id: int) -> bool: if user_id in settings.service_owner_ids: return True if user_id == guild.owner_id: return True if self.bot.user and user_id == self.bot.user.id: return True return user_id in await storage.trusted_ids(guild.id) async def settings(self, guild: discord.Guild) -> dict[str, Any]: return await storage.settings_for(guild.id) async def action_log(self, guild: discord.Guild, action: str, actor: str, payload: dict, result=None): cfg = await storage.settings_for(guild.id) # All operational actions go to security log channel first. channel_id = str(cfg.get("log_channel_id") or cfg.get("web_log_channel_id") or "").strip() channel = guild.get_channel(int(channel_id)) if channel_id.isdigit() else None if not isinstance(channel, discord.TextChannel): print(f"[NovaShield LOG] Sin canal válido en {guild.name}. log_channel_id={channel_id!r}") return False perms = channel.permissions_for(guild.me) if not perms.view_channel or not perms.send_messages: print(f"[NovaShield LOG] Sin permisos para escribir en #{channel.name}") return False labels = { "backup_create": "💾 Backup creado", "backup_restore": "♻️ Backup restaurado", "emergency_on": "🚨 Lockdown Total activado", "emergency_off": "🔓 Lockdown Total restaurado", "selective_on": "🔒 Lockdown Selectivo activado", "selective_off": "🔓 Lockdown Selectivo restaurado", "toggle_protection": "🛡️ Protección modificada", "trusted_add": "✅ Usuario confiable agregado", "trusted_remove": "➖ Usuario confiable eliminado", "settings": "⚙️ Configuración modificada", } title = labels.get(action, f"🛡️ Acción: {action}") embed = discord.Embed( title=f"NovaShield Security — {title}", color=0x5865F2, timestamp=datetime.now(timezone.utc), ) embed.add_field(name="Servidor", value=f"{guild.name}\n`{guild.id}`", inline=False) embed.add_field(name="Ejecutado por", value=actor or "Panel Web", inline=True) if result is not None: result_text = str(result) if len(result_text) > 900: result_text = result_text[:897] + "..." embed.add_field(name="Resultado", value=f"`{result_text}`", inline=False) try: await channel.send(embed=embed) print(f"[NovaShield LOG] Enviado {action} -> #{channel.name}") return True except Exception as exc: print(f"[NovaShield LOG] ERROR enviando {action}: {exc}") return False async def web_log(self, guild: discord.Guild, action: str, payload: dict, result=None) -> None: settings = await storage.settings_for(guild.id) channel_id = str(settings.get("web_log_channel_id") or settings.get("log_channel_id") or "").strip() if not channel_id: return try: channel = guild.get_channel(int(channel_id)) except (TypeError, ValueError): channel = None if not isinstance(channel, discord.TextChannel): return labels = { "settings":"Configuración modificada", "toggle_protection":"Protección modificada", "backup_create":"Backup manual creado", "backup_restore":"Backup restaurado", "emergency_on":"Lockdown Total activado", "emergency_off":"Lockdown Total restaurado", "selective_on":"Lockdown Selectivo activado", "selective_off":"Lockdown Selectivo restaurado", "trusted_add":"Usuario confiable agregado", "trusted_remove":"Usuario confiable eliminado", } embed = discord.Embed( title="🌐 Acción desde Panel Web", description=f"**{labels.get(action, action)}**", color=0x5865F2, timestamp=datetime.now(timezone.utc), ) embed.add_field(name="Servidor", value=f"{guild.name}\n`{guild.id}`", inline=False) actor_name = str(payload.get("_actor_name") or "Panel Web") embed.add_field(name="Ejecutado por", value=actor_name, inline=True) if result is not None: text = str(result) if len(text) > 900: text = text[:897] + "..." embed.add_field(name="Resultado", value=f"`{text}`", inline=False) try: await channel.send(embed=embed) except (discord.Forbidden, discord.HTTPException): pass async def audit_actor(self, guild: discord.Guild, action: discord.AuditLogAction, target_id: int | None = None): await asyncio.sleep(0.8) try: async for entry in guild.audit_logs(limit=8, action=action): if target_id is None or getattr(entry.target, "id", None) == target_id: return entry.user except (discord.Forbidden, discord.HTTPException): return None return None async def alert_owner_critical(self, guild: discord.Guild, title: str, description: str) -> bool: owner = guild.owner if owner is None: try: owner = await guild.fetch_member(guild.owner_id) except Exception: owner = None if owner is None: return False embed = discord.Embed( title=f"🚨 NovaShield — {title}", description=description, color=0xED4245, timestamp=datetime.now(timezone.utc), ) embed.add_field(name="Servidor", value=f"{guild.name}\n`{guild.id}`", inline=False) try: await owner.send(embed=embed) return True except Exception: return False async def register(self, guild: discord.Guild, actor: discord.abc.User | None, event_type: str, target: str) -> None: if not actor: await storage.add_event(guild.id, event_type, "info", None, "Desconocido", target) return if await self.is_trusted(guild, actor.id): return cfg = await self.settings(guild) if bool(cfg.get("maintenance_mode", False)): await storage.add_event( guild.id, event_type, "info", actor.id, str(actor), target, {"maintenance": True, "response": "Ignorado por Modo Mantenimiento"}, False, ) return if not int(cfg.get("enabled", 1) or 0): return if not await storage.is_licensed(guild.id): return threshold, window = DEFAULT_THRESHOLDS.get(event_type, (3, 15)) threshold = int(cfg.get(f"{event_type}_threshold", threshold) or threshold) window = int(cfg.get(f"{event_type}_window", window) or window) key = (guild.id, actor.id, event_type) now = time.monotonic() q = self.counters[key] q.append(now) while q and now - q[0] > window: q.popleft() blocked = False severity = "warning" current_count = len(q) response = "Evento registrado" if current_count >= threshold: severity = "critical" if int(cfg.get("aggressive_mode", 1) or 0): blocked = await self.neutralize(guild, actor.id, event_type) response = "Actor neutralizado: roles peligrosos retirados" if blocked else "Intento de neutralización sin cambios" else: response = "Umbral crítico detectado; modo agresivo desactivado" q.clear() await storage.add_event( guild.id, event_type, severity, actor.id, str(actor), target, {"count": current_count, "threshold": threshold, "window": window, "response": response}, blocked, ) if severity == "critical": await self.alert_owner_critical( guild, "AMENAZA CRÍTICA DETECTADA", f"**Actor:** {actor}\n**Acción:** {event_type}\n**Objetivo:** {target}\n" f"**Respuesta:** {response}\n**Neutralizado:** {'Sí' if blocked else 'No'}" ) await self.send_simple_panel_log( guild, f"🚨 **AMENAZA CRÍTICA** | Actor: **{actor}** | Acción: `{event_type}` | " f"Neutralizado: **{'Sí' if blocked else 'No'}**" ) async def neutralize(self, guild: discord.Guild, actor_id: int, reason: str) -> bool: member = guild.get_member(actor_id) if not member: try: member = await guild.fetch_member(actor_id) except (discord.NotFound, discord.Forbidden, discord.HTTPException): return False if member.id == guild.owner_id or not guild.me: return False removable = [] for role in member.roles: if role.is_default() or role.managed: continue if role >= guild.me.top_role: continue perms = role.permissions if any(getattr(perms, name, False) for name in DANGEROUS): removable.append(role) if not removable: return False try: await member.remove_roles(*removable, reason=f"NovaShield Anti-Nuke: {reason}") return True except (discord.Forbidden, discord.HTTPException): return False async def full_lockdown(self, guild: discord.Guild, created_by: str) -> dict[str, Any]: backup_id = await create_backup(guild, "pre_emergency_lockdown", created_by) state: dict[str, Any] = {"backup_id": backup_id, "members": {}, "messages": []} removed_users = 0 removed_roles = 0 if not guild.me: raise RuntimeError("No se pudo resolver el bot en el servidor.") for member in guild.members: if member.bot and self.bot.user and member.id == self.bot.user.id: continue if member.id == guild.owner_id: continue manageable = [ r for r in member.roles if not r.is_default() and not r.managed and r < guild.me.top_role ] if not manageable: continue state["members"][str(member.id)] = [str(r.id) for r in manageable] try: await member.remove_roles(*manageable, reason="NovaShield emergency lockdown") removed_users += 1 removed_roles += len(manageable) except (discord.Forbidden, discord.HTTPException): pass message = ( "🛡️ **NOVASHIELD SECURITY — MODO EMERGENCIA**\n" "Se detectó una situación de seguridad. El servidor ha sido bloqueado temporalmente " "mientras el equipo administrativo revisa y restaura la configuración." ) for channel in guild.text_channels: if not channel.permissions_for(guild.me).send_messages: continue try: sent = await channel.send(message) state["messages"].append({"channel_id": str(channel.id), "message_id": str(sent.id)}) except (discord.Forbidden, discord.HTTPException): pass await storage.save_lockdown_state(guild.id, "full", state) return { "backup_id": backup_id, "users_affected": removed_users, "roles_removed": removed_roles, } async def restore_full_lockdown(self, guild: discord.Guild) -> dict[str, Any]: row = await storage.get_lockdown_state(guild.id, "full") if not row: raise RuntimeError("No hay un lockdown total activo.") state = dict(row["state"]) result = await restore_backup(guild, int(state["backup_id"])) for item in state.get("messages", []): channel = guild.get_channel(int(item["channel_id"])) if isinstance(channel, discord.TextChannel): try: msg = await channel.fetch_message(int(item["message_id"])) await msg.delete() except (discord.NotFound, discord.Forbidden, discord.HTTPException): pass await storage.clear_lockdown_state(guild.id, "full") return result async def selective_lockdown(self, guild: discord.Guild, user_ids: list[int], channel_ids: list[int], created_by: str) -> dict[str, Any]: if not guild.me: raise RuntimeError("No se pudo resolver el bot en el servidor.") state: dict[str, Any] = {"members": {}, "messages": []} removed_roles = 0 affected = 0 for uid in user_ids: if uid == guild.owner_id or (self.bot.user and uid == self.bot.user.id): continue member = guild.get_member(uid) if not member: try: member = await guild.fetch_member(uid) except (discord.NotFound, discord.Forbidden, discord.HTTPException): continue manageable = [ r for r in member.roles if not r.is_default() and not r.managed and r < guild.me.top_role ] if not manageable: continue state["members"][str(uid)] = [str(r.id) for r in manageable] try: await member.remove_roles(*manageable, reason=f"NovaShield selective lockdown by {created_by}") affected += 1 removed_roles += len(manageable) except (discord.Forbidden, discord.HTTPException): pass notice = ( "🛡️ **NovaShield Security**\n" "Se aplicó un bloqueo selectivo de seguridad. " "Los accesos afectados serán restaurados cuando finalice la revisión." ) for cid in channel_ids: channel = guild.get_channel(cid) if isinstance(channel, discord.TextChannel) and channel.permissions_for(guild.me).send_messages: try: msg = await channel.send(notice) state["messages"].append({"channel_id": str(cid), "message_id": str(msg.id)}) except (discord.Forbidden, discord.HTTPException): pass await storage.save_lockdown_state(guild.id, "selective", state) return {"users_affected": affected, "roles_removed": removed_roles} async def restore_selective_lockdown(self, guild: discord.Guild) -> dict[str, Any]: row = await storage.get_lockdown_state(guild.id, "selective") if not row: raise RuntimeError("No hay un lockdown selectivo activo.") state = dict(row["state"]) restored_users = 0 restored_roles = 0 if not guild.me: raise RuntimeError("No se pudo resolver el bot en el servidor.") for uid, role_ids in state.get("members", {}).items(): member = guild.get_member(int(uid)) if not member: continue roles = [] for rid in role_ids: role = guild.get_role(int(rid)) if role and not role.managed and role < guild.me.top_role: roles.append(role) if not roles: continue try: await member.add_roles(*roles, reason="NovaShield restore selective lockdown") restored_users += 1 restored_roles += len(roles) except (discord.Forbidden, discord.HTTPException): pass for item in state.get("messages", []): channel = guild.get_channel(int(item["channel_id"])) if isinstance(channel, discord.TextChannel): try: msg = await channel.fetch_message(int(item["message_id"])) await msg.delete() except (discord.NotFound, discord.Forbidden, discord.HTTPException): pass await storage.clear_lockdown_state(guild.id, "selective") return {"users_restored": restored_users, "roles_restored": restored_roles} async def send_simple_panel_log(self, guild: discord.Guild, message: str): """Envía un mensaje simple al canal configurado. Sin embeds ni lógica extra.""" cfg = await storage.settings_for(guild.id) channel_id = str(cfg.get("log_channel_id") or "").strip() if not channel_id.isdigit(): return channel = guild.get_channel(int(channel_id)) if channel is None: try: channel = await guild.fetch_channel(int(channel_id)) except Exception: return try: await channel.send(message) except Exception as exc: print(f"[NovaShield] No se pudo mandar el log: {exc}") async def process_command(self, row: dict[str, Any]) -> None: guild = self.bot.get_guild(int(row["guild_id"])) if not guild: raise RuntimeError("El bot no está conectado a ese servidor.") payload = dict(row.get("payload") or {}) action = row["command_type"] actor = row.get("created_by_name") or "Web" if action == "announcement_send": cfg = await storage.settings_for(guild.id) channel_id = str(payload.get("channel_id") or cfg.get("announcement_channel_id") or "").strip() if not channel_id.isdigit(): raise RuntimeError("No hay un canal de novedades configurado.") channel = guild.get_channel(int(channel_id)) if channel is None: try: channel = await guild.fetch_channel(int(channel_id)) except Exception: channel = None if not isinstance(channel, discord.TextChannel): raise RuntimeError("El canal de novedades configurado no existe o no es un canal de texto.") perms = channel.permissions_for(guild.me) if not perms.view_channel or not perms.send_messages: raise RuntimeError("NovaShield necesita Ver canal + Enviar mensajes en el canal de novedades.") title = str(payload.get("title") or "Actualización NovaShield").strip()[:200] message = str(payload.get("message") or "").strip()[:4000] if not message: raise RuntimeError("El mensaje de la novedad está vacío.") embed = discord.Embed( title=f"📢 {title}", description=message, color=0x5865F2, timestamp=datetime.now(timezone.utc), ) embed.set_footer(text="NovaShield Security • Discord Protection & Recovery") sent = await channel.send(embed=embed) result = {"channel_id": channel_id, "message_id": str(sent.id), "sent": True} print(f"[NovaShield NOVEDAD] Enviada -> {guild.name} / #{channel.name}") elif action == "backup_create": backup_id = await create_backup(guild, "manual_web", actor) result = {"backup_id": backup_id} await self.send_simple_panel_log(guild, f"💾 **Backup creado** en **{guild.name}** correctamente.") elif action == "backup_restore": result = await restore_backup(guild, int(payload["backup_id"])) await self.send_simple_panel_log(guild, f"♻️ **Backup restaurado** en **{guild.name}** correctamente.") elif action == "emergency_on": result = await self.full_lockdown(guild, actor) await self.send_simple_panel_log(guild, f"🚨 **SERVIDOR BAJO ATAQUE** — Lockdown Total activado en **{guild.name}**.") await self.alert_owner_critical( guild, "LOCKDOWN DE EMERGENCIA ACTIVADO", f"Se activó el protocolo completo de emergencia.\nEjecutado por: **{actor}**" ) elif action == "emergency_off": result = await self.restore_full_lockdown(guild) elif action == "selective_on": result = await self.selective_lockdown( guild, [int(x) for x in payload.get("user_ids", [])], [int(x) for x in payload.get("channel_ids", [])], actor, ) elif action == "selective_off": result = await self.restore_selective_lockdown(guild) elif action == "settings": allowed = {"log_channel_id", "web_log_channel_id", "announcement_channel_id", "automatic_backups", "backup_interval_hours", "keep_backups"} changes = {k: v for k, v in payload.items() if k in allowed} await storage.update_panel_settings(guild.id, changes) result = changes elif action == "test_log": cfg = await storage.settings_for(guild.id) channel_id = str(payload.get("channel_id") or cfg.get("log_channel_id") or "").strip() channel = guild.get_channel(int(channel_id)) if channel_id.isdigit() else None if not isinstance(channel, discord.TextChannel): raise RuntimeError("El canal de logs configurado no existe.") perms = channel.permissions_for(guild.me) if not perms.view_channel or not perms.send_messages: raise RuntimeError("NovaShield no tiene Ver canal + Enviar mensajes en el canal configurado.") embed = discord.Embed( title="✅ NovaShield Security — Prueba de Logs", description="La conexión de logs funciona correctamente.", color=0x57F287, timestamp=datetime.now(timezone.utc), ) await channel.send(embed=embed) result = {"channel_id": channel_id, "sent": True} elif action == "toggle_protection": enabled = 1 if payload.get("enabled") else 0 await storage.update_settings(guild.id, {"enabled": bool(enabled)}) result = {"enabled": bool(enabled)} elif action == "maintenance": enabled = bool(payload.get("enabled")) await storage.update_panel_settings(guild.id, {"maintenance_mode": enabled}) result = {"maintenance_mode": enabled} await self.send_simple_panel_log( guild, f"🛠️ **Modo Mantenimiento {'ACTIVADO' if enabled else 'DESACTIVADO'}** en **{guild.name}**." ) elif action == "trusted_add": uid = str(int(payload["user_id"])) await storage.add_trusted(guild.id, int(uid), str(payload.get("label") or "")) result = {"user_id": uid} elif action == "trusted_remove": uid = str(int(payload["user_id"])) await storage.remove_trusted(guild.id, int(uid)) result = {"user_id": uid} else: raise RuntimeError("Comando no reconocido.") await storage.update_command(row["id"], {"status": "done", "result": result}) await self.action_log(guild, action, actor, payload, result) async def command_loop(self) -> None: await self.bot.wait_until_ready() while not self.bot.is_closed(): try: rows = await storage.pending_commands(10) for row in rows: await storage.update_command(row["id"], {"status": "running"}) try: await self.process_command(row) except Exception as exc: await storage.update_command(row["id"], {"status": "error", "error_text": str(exc)[:1000]}) except Exception as exc: print("Command loop error:", exc) await asyncio.sleep(2) async def resource_loop(self) -> None: await self.bot.wait_until_ready() while not self.bot.is_closed(): for guild in list(self.bot.guilds): try: await storage.sync_guild_resources(guild) except Exception as exc: print(f"Resource sync {guild.id}:", exc) await asyncio.sleep(30) async def backup_loop(self) -> None: await self.bot.wait_until_ready() while not self.bot.is_closed(): for guild in list(self.bot.guilds): try: if not await storage.is_licensed(guild.id): continue cfg = await self.settings(guild) if not int(cfg.get("automatic_backups", 1) or 0): continue hours = max(1, int(cfg.get("backup_interval_hours", 6) or 6)) latest_rows = await storage.backups_for(guild.id, 1) should = True if latest_rows: from datetime import datetime, timezone try: dt = datetime.fromisoformat(latest_rows[0]["created_at"]) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) age = datetime.now(timezone.utc).timestamp() - dt.timestamp() should = age >= hours * 3600 except Exception: should = True if should: backup_id = await create_backup(guild, "automatic", "NovaShield") await self.send_simple_panel_log( guild, f"🤖💾 **Backup automático #{backup_id} creado** en **{guild.name}** correctamente." ) except Exception as exc: print(f"Backup loop {guild.id}:", exc) await asyncio.sleep(300) def setup(bot: commands.Bot) -> SecurityEngine: engine = SecurityEngine(bot) @bot.listen() async def on_ready(): await engine.ready() print(f"NovaShield conectado como {bot.user} en {len(bot.guilds)} servidores.") @bot.listen() async def on_guild_join(guild: discord.Guild): await storage.upsert_guild(guild) await storage.sync_guild_resources(guild) @bot.listen() async def on_guild_channel_delete(channel: discord.abc.GuildChannel): await storage.sync_guild_resources(channel.guild) actor = await engine.audit_actor(channel.guild, discord.AuditLogAction.channel_delete, channel.id) await engine.register(channel.guild, actor, "channel_delete", channel.name) @bot.listen() async def on_guild_channel_create(channel: discord.abc.GuildChannel): await storage.sync_guild_resources(channel.guild) actor = await engine.audit_actor(channel.guild, discord.AuditLogAction.channel_create, channel.id) await engine.register(channel.guild, actor, "channel_create", channel.name) @bot.listen() async def on_guild_role_delete(role: discord.Role): actor = await engine.audit_actor(role.guild, discord.AuditLogAction.role_delete, role.id) await engine.register(role.guild, actor, "role_delete", role.name) @bot.listen() async def on_guild_role_create(role: discord.Role): actor = await engine.audit_actor(role.guild, discord.AuditLogAction.role_create, role.id) await engine.register(role.guild, actor, "role_create", role.name) @bot.listen() async def on_member_ban(guild: discord.Guild, user: discord.User | discord.Member): actor = await engine.audit_actor(guild, discord.AuditLogAction.ban, user.id) await engine.register(guild, actor, "ban", str(user)) @bot.listen() async def on_member_join(member: discord.Member): await storage.sync_guild_resources(member.guild) @bot.listen() async def on_member_remove(member: discord.Member): await storage.sync_guild_resources(member.guild) actor = await engine.audit_actor(member.guild, discord.AuditLogAction.kick, member.id) if actor: await engine.register(member.guild, actor, "kick", str(member)) @bot.listen() async def on_webhooks_update(channel: discord.abc.GuildChannel): actor = await engine.audit_actor(channel.guild, discord.AuditLogAction.webhook_create) await engine.register(channel.guild, actor, "webhook", channel.name) @bot.listen() async def on_member_update(before: discord.Member, after: discord.Member): before_ids = {r.id for r in before.roles} added = [r for r in after.roles if r.id not in before_ids] dangerous = [ r for r in added if any(getattr(r.permissions, name, False) for name in DANGEROUS) ] if dangerous: actor = await engine.audit_actor(after.guild, discord.AuditLogAction.member_role_update, after.id) await engine.register( after.guild, actor, "dangerous_role_grant", f"{after} <- {', '.join(r.name for r in dangerous)}", ) if after.bot and not before.bot: actor = await engine.audit_actor(after.guild, discord.AuditLogAction.bot_add, after.id) await engine.register(after.guild, actor, "bot_add", str(after)) return engine