EveCogs/nexuscore/serverlog.py
everestmcarthur a76758f61a feat: NexusCore v1.0.0 — Ultimate all-in-one server management cog
8 fully-featured subsystems, 5,673 lines of code, 100+ commands:

🎫 Tickets — Panel-based creation, categories with modal questions, staff claim,
   priority system (low/med/high/urgent), HTML transcripts, feedback ratings, DM notifs

📋 Applications — Multi-type forms with paginated modals, accept/deny/interview
   review, auto-role, staff notes, auto-thread, cooldowns, age requirements

💡 Suggestions — Button voting, 8 status types, anonymous mode, auto-thread,
   staff responses, categories, top leaderboard, status-specific channels

🎭 Reaction Roles — Button/select/reaction modes, exclusive groups, role
   requirements, sticky roles, temporary roles, max picks, DM confirm

🎉 Giveaways — Button entry, role/age requirements, bonus entries, drop mode,
   multiple winners, reroll, scheduled end, DM notifications

📋 Logging — 20+ event types, per-event channels, message cache, invite tracking,
   ignore filters (channel/role/user/bot), bulk delete file export

🛡️ Moderation — Case system, warn auto-escalation, mute/kick/ban/softban/tempban,
   notes, channel/server lockdown, anti-raid, anti-nuke, automod (6 modules), appeals

🪙 Economy — Wallet+bank, daily/weekly streaks, work/crime/rob, coinflip/slots/blackjack,
   shop (items+roles), inventory, heist mini-game, pet system (6 types), leaderboard
2026-05-07 23:51:53 +00:00

426 lines
19 KiB
Python

"""NexusCore — Comprehensive server logging with message cache, event routing, ignore filters."""
from __future__ import annotations
import asyncio
import collections
import datetime
from typing import Optional
import discord
from redbot.core import Config, commands
from .utils import Clr, safe_send, ts_now, ts_relative
# ── Defaults ───────────────────────────────────────────────────────────────
LOG_DEFAULTS_GUILD = {
"enabled": False,
"default_channel": None,
"channels": {},
# event_type -> channel_id (override per event)
# "message_edit", "message_delete", "bulk_delete",
# "member_join", "member_leave", "member_ban", "member_unban",
# "member_update", "role_create", "role_delete", "role_update",
# "channel_create", "channel_delete", "channel_update",
# "voice", "invite", "emoji", "sticker", "thread",
# "server_update", "mod_action"
"ignore_channels": [],
"ignore_roles": [],
"ignore_users": [],
"ignore_bots": True,
"log_attachments": True,
"log_embeds": False,
"message_cache_size": 1000,
"compact_mode": False,
"invite_tracking": True,
"invites_cache": {}, # code -> {uses, inviter_id}
}
EVENT_TYPES = [
"message_edit", "message_delete", "bulk_delete",
"member_join", "member_leave", "member_ban", "member_unban",
"member_update", "role_create", "role_delete", "role_update",
"channel_create", "channel_delete", "channel_update",
"voice", "invite", "emoji", "sticker", "thread",
"server_update", "mod_action",
]
# ── Mixin ──────────────────────────────────────────────────────────────────
class ServerLogMixin:
"""Server logging mixin — handles all Discord events for audit logging."""
def _init_logging(self, bot):
self.log_config = Config.get_conf(
None, identifier=900006, cog_name="NexusCoreLog"
)
self.log_config.register_guild(**LOG_DEFAULTS_GUILD)
self._msg_cache = {} # guild_id -> collections.OrderedDict of msg_id -> {content, author, channel, attachments}
self._invite_cache = {} # guild_id -> {code: uses}
self.bot = bot
def _get_cache(self, guild_id: int) -> collections.OrderedDict:
if guild_id not in self._msg_cache:
self._msg_cache[guild_id] = collections.OrderedDict()
return self._msg_cache[guild_id]
async def _get_log_channel(self, guild: discord.Guild, event_type: str) -> discord.TextChannel | None:
data = await self.log_config.guild(guild).all()
if not data["enabled"]:
return None
ch_id = data["channels"].get(event_type) or data["default_channel"]
if not ch_id:
return None
return guild.get_channel(ch_id)
async def _should_ignore(self, guild: discord.Guild, *, channel=None, member=None) -> bool:
data = await self.log_config.guild(guild).all()
if channel and channel.id in data["ignore_channels"]:
return True
if member:
if data["ignore_bots"] and member.bot:
return True
if member.id in data["ignore_users"]:
return True
if any(r.id in data["ignore_roles"] for r in getattr(member, "roles", [])):
return True
return False
# ── Message cache ──────────────────────────────────────────────────────
async def _cache_message(self, message: discord.Message):
if not message.guild:
return
cache = self._get_cache(message.guild.id)
cache[message.id] = {
"content": message.content,
"author_id": message.author.id,
"author_str": str(message.author),
"channel_id": message.channel.id,
"attachments": [a.url for a in message.attachments],
"embeds": len(message.embeds),
}
data = await self.log_config.guild(message.guild).all()
max_size = data.get("message_cache_size", 1000)
while len(cache) > max_size:
cache.popitem(last=False)
# ── Event handlers ─────────────────────────────────────────────────────
async def _log_message_edit(self, before: discord.Message, after: discord.Message):
if not after.guild or before.content == after.content:
return
if await self._should_ignore(after.guild, channel=after.channel, member=after.author):
return
log_ch = await self._get_log_channel(after.guild, "message_edit")
if not log_ch:
return
embed = discord.Embed(
title="✏️ Message Edited",
colour=discord.Colour(0x3498DB),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(
name=str(after.author),
icon_url=after.author.display_avatar.url if after.author.display_avatar else None,
)
embed.add_field(name="Before", value=(before.content or "*empty*")[:1024], inline=False)
embed.add_field(name="After", value=(after.content or "*empty*")[:1024], inline=False)
embed.add_field(name="Channel", value=after.channel.mention, inline=True)
embed.add_field(name="Author", value=after.author.mention, inline=True)
embed.add_field(name="Jump", value=f"[Click]({after.jump_url})", inline=True)
embed.set_footer(text=f"Message ID: {after.id} · User ID: {after.author.id}")
await safe_send(log_ch, embed=embed)
async def _log_message_delete(self, message: discord.Message):
if not message.guild:
return
if await self._should_ignore(message.guild, channel=message.channel, member=message.author):
return
log_ch = await self._get_log_channel(message.guild, "message_delete")
if not log_ch:
return
content = message.content or ""
# Try cache if content is empty
if not content:
cached = self._get_cache(message.guild.id).get(message.id)
if cached:
content = cached.get("content", "")
embed = discord.Embed(
title="🗑️ Message Deleted",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(
name=str(message.author),
icon_url=message.author.display_avatar.url if message.author.display_avatar else None,
)
if content:
embed.add_field(name="Content", value=content[:1024], inline=False)
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
embed.add_field(name="Author", value=message.author.mention, inline=True)
data = await self.log_config.guild(message.guild).all()
if data["log_attachments"] and message.attachments:
att_text = "\n".join(f"[{a.filename}]({a.url})" for a in message.attachments[:5])
embed.add_field(name="Attachments", value=att_text, inline=False)
embed.set_footer(text=f"Message ID: {message.id} · User ID: {message.author.id}")
await safe_send(log_ch, embed=embed)
async def _log_bulk_delete(self, messages: list[discord.Message]):
if not messages or not messages[0].guild:
return
guild = messages[0].guild
log_ch = await self._get_log_channel(guild, "bulk_delete")
if not log_ch:
return
embed = discord.Embed(
title="🗑️ Bulk Message Delete",
description=f"**{len(messages)}** messages deleted in {messages[0].channel.mention}",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
# Build text file of deleted messages
lines = []
for m in messages[:200]:
ts = m.created_at.strftime("%H:%M:%S")
lines.append(f"[{ts}] {m.author}: {m.content or '[no content]'}")
content_txt = "\n".join(lines)
import io
file = discord.File(io.BytesIO(content_txt.encode()), filename="bulk_delete.txt")
await safe_send(log_ch, embed=embed, file=file)
async def _log_member_join(self, member: discord.Member):
if await self._should_ignore(member.guild, member=member):
return
log_ch = await self._get_log_channel(member.guild, "member_join")
if not log_ch:
return
embed = discord.Embed(
title="📥 Member Joined",
colour=discord.Colour(0x2ECC71),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(name=str(member), icon_url=member.display_avatar.url if member.display_avatar else None)
embed.add_field(name="User", value=f"{member.mention} (`{member.id}`)", inline=True)
embed.add_field(name="Account Created", value=ts_relative(int(member.created_at.timestamp())), inline=True)
embed.add_field(name="Member #", value=str(member.guild.member_count), inline=True)
age_days = (datetime.datetime.now(datetime.timezone.utc) - member.created_at).days
if age_days < 7:
embed.add_field(name="⚠️ New Account", value=f"Created {age_days} day(s) ago", inline=False)
embed.set_thumbnail(url=member.display_avatar.url if member.display_avatar else None)
# Invite tracking
data = await self.log_config.guild(member.guild).all()
if data.get("invite_tracking"):
try:
invites = await member.guild.invites()
old_cache = self._invite_cache.get(member.guild.id, {})
for inv in invites:
old_uses = old_cache.get(inv.code, 0)
if inv.uses > old_uses:
embed.add_field(name="Invited by", value=f"{inv.inviter.mention if inv.inviter else 'Unknown'} (code: `{inv.code}`)", inline=False)
break
self._invite_cache[member.guild.id] = {inv.code: inv.uses for inv in invites}
except discord.HTTPException:
pass
await safe_send(log_ch, embed=embed)
async def _log_member_leave(self, member: discord.Member):
if await self._should_ignore(member.guild, member=member):
return
log_ch = await self._get_log_channel(member.guild, "member_leave")
if not log_ch:
return
embed = discord.Embed(
title="📤 Member Left",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(name=str(member), icon_url=member.display_avatar.url if member.display_avatar else None)
embed.add_field(name="User", value=f"{member.mention} (`{member.id}`)", inline=True)
if member.joined_at:
embed.add_field(name="Joined", value=ts_relative(int(member.joined_at.timestamp())), inline=True)
roles = [r.mention for r in member.roles if r != member.guild.default_role]
if roles:
embed.add_field(name="Roles", value=", ".join(roles[:20]), inline=False)
await safe_send(log_ch, embed=embed)
async def _log_member_ban(self, guild: discord.Guild, user: discord.User):
log_ch = await self._get_log_channel(guild, "member_ban")
if not log_ch:
return
embed = discord.Embed(
title="🔨 Member Banned",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(name=str(user), icon_url=user.display_avatar.url if user.display_avatar else None)
embed.add_field(name="User", value=f"{user.mention} (`{user.id}`)", inline=True)
try:
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.ban):
if entry.target and entry.target.id == user.id:
embed.add_field(name="Banned by", value=entry.user.mention if entry.user else "Unknown", inline=True)
if entry.reason:
embed.add_field(name="Reason", value=entry.reason[:1024], inline=False)
break
except discord.HTTPException:
pass
await safe_send(log_ch, embed=embed)
async def _log_member_unban(self, guild: discord.Guild, user: discord.User):
log_ch = await self._get_log_channel(guild, "member_unban")
if not log_ch:
return
embed = discord.Embed(
title="🔓 Member Unbanned",
description=f"{user.mention} (`{user.id}`)",
colour=discord.Colour(0x2ECC71),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
await safe_send(log_ch, embed=embed)
async def _log_member_update(self, before: discord.Member, after: discord.Member):
if await self._should_ignore(after.guild, member=after):
return
log_ch = await self._get_log_channel(after.guild, "member_update")
if not log_ch:
return
changes = []
if before.nick != after.nick:
changes.append(("Nickname", before.nick or "*None*", after.nick or "*None*"))
added_roles = set(after.roles) - set(before.roles)
removed_roles = set(before.roles) - set(after.roles)
if added_roles:
changes.append(("Roles Added", "", ", ".join(r.mention for r in added_roles)))
if removed_roles:
changes.append(("Roles Removed", "", ", ".join(r.mention for r in removed_roles)))
if before.communication_disabled_until != after.communication_disabled_until:
if after.communication_disabled_until and after.communication_disabled_until > datetime.datetime.now(datetime.timezone.utc):
changes.append(("Timed Out", "No", ts_relative(int(after.communication_disabled_until.timestamp()))))
elif before.communication_disabled_until:
changes.append(("Timeout Removed", "", ""))
if not changes:
return
embed = discord.Embed(
title="👤 Member Updated",
colour=discord.Colour(0xF39C12),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
embed.set_author(name=str(after), icon_url=after.display_avatar.url if after.display_avatar else None)
for name, old, new in changes:
if old:
embed.add_field(name=name, value=f"`{old}` → `{new}`", inline=False)
else:
embed.add_field(name=name, value=new, inline=False)
await safe_send(log_ch, embed=embed)
async def _log_voice(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
if await self._should_ignore(member.guild, member=member):
return
log_ch = await self._get_log_channel(member.guild, "voice")
if not log_ch:
return
if before.channel is None and after.channel:
desc = f"🔊 **{member}** joined **{after.channel.name}**"
colour = discord.Colour(0x2ECC71)
elif before.channel and after.channel is None:
desc = f"🔇 **{member}** left **{before.channel.name}**"
colour = discord.Colour(0xE74C3C)
elif before.channel and after.channel and before.channel != after.channel:
desc = f"🔀 **{member}** moved from **{before.channel.name}** → **{after.channel.name}**"
colour = discord.Colour(0xF39C12)
elif before.self_mute != after.self_mute:
desc = f"{'🔇' if after.self_mute else '🔊'} **{member}** {'self-muted' if after.self_mute else 'self-unmuted'}"
colour = discord.Colour(0x95A5A6)
elif before.self_deaf != after.self_deaf:
desc = f"{'🔇' if after.self_deaf else '🔊'} **{member}** {'self-deafened' if after.self_deaf else 'self-undeafened'}"
colour = discord.Colour(0x95A5A6)
elif before.mute != after.mute:
desc = f"{'🔇' if after.mute else '🔊'} **{member}** was {'server muted' if after.mute else 'server unmuted'}"
colour = discord.Colour(0xE67E22)
elif before.deaf != after.deaf:
desc = f"{'🔇' if after.deaf else '🔊'} **{member}** was {'server deafened' if after.deaf else 'server undeafened'}"
colour = discord.Colour(0xE67E22)
else:
return
embed = discord.Embed(description=desc, colour=colour, timestamp=datetime.datetime.now(datetime.timezone.utc))
embed.set_footer(text=f"User ID: {member.id}")
await safe_send(log_ch, embed=embed)
async def _log_channel_create(self, channel: discord.abc.GuildChannel):
log_ch = await self._get_log_channel(channel.guild, "channel_create")
if not log_ch:
return
embed = discord.Embed(
title="📁 Channel Created",
description=f"{channel.mention} (`{channel.name}`)\nType: {str(channel.type).title()}",
colour=discord.Colour(0x2ECC71),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
await safe_send(log_ch, embed=embed)
async def _log_channel_delete(self, channel: discord.abc.GuildChannel):
log_ch = await self._get_log_channel(channel.guild, "channel_delete")
if not log_ch:
return
embed = discord.Embed(
title="📁 Channel Deleted",
description=f"`{channel.name}` ({str(channel.type).title()})",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
await safe_send(log_ch, embed=embed)
async def _log_role_create(self, role: discord.Role):
log_ch = await self._get_log_channel(role.guild, "role_create")
if not log_ch:
return
embed = discord.Embed(
title="🏷️ Role Created",
description=f"{role.mention} (`{role.name}`)\nColour: `{role.colour}`",
colour=role.colour if role.colour.value else discord.Colour(0x2ECC71),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
await safe_send(log_ch, embed=embed)
async def _log_role_delete(self, role: discord.Role):
log_ch = await self._get_log_channel(role.guild, "role_delete")
if not log_ch:
return
embed = discord.Embed(
title="🏷️ Role Deleted",
description=f"`{role.name}` · Members: {len(role.members)}",
colour=discord.Colour(0xE74C3C),
timestamp=datetime.datetime.now(datetime.timezone.utc),
)
await safe_send(log_ch, embed=embed)
async def _cache_invites(self, guild: discord.Guild):
try:
invites = await guild.invites()
self._invite_cache[guild.id] = {inv.code: inv.uses for inv in invites}
except discord.HTTPException:
pass