Implement GuardDen Discord moderation bot

Features:
- Core moderation: warn, kick, ban, timeout, strike system
- Automod: banned words filter, scam detection, anti-spam, link filtering
- AI moderation: Claude/OpenAI integration, NSFW detection, phishing analysis
- Verification system: button, captcha, math, emoji challenges
- Rate limiting system with configurable scopes
- Event logging: joins, leaves, message edits/deletes, voice activity
- Per-guild configuration with caching
- Docker deployment support

Bug fixes applied:
- Fixed await on session.delete() in guild_config.py
- Fixed memory leak in AI moderation message tracking (use deque)
- Added error handling to bot shutdown
- Added error handling to timeout command
- Removed unused Literal import
- Added prefix validation
- Added image analysis limit (3 per message)
- Fixed test mock for SQLAlchemy model
This commit is contained in:
2026-01-16 19:27:48 +01:00
parent ffe42b6d51
commit 4e16777f25
45 changed files with 5802 additions and 1 deletions

255
src/guardden/cogs/admin.py Normal file
View File

@@ -0,0 +1,255 @@
"""Admin commands for bot configuration."""
import logging
from typing import Literal
import discord
from discord.ext import commands
from guardden.bot import GuardDen
logger = logging.getLogger(__name__)
class Admin(commands.Cog):
"""Administrative commands for bot configuration."""
def __init__(self, bot: GuardDen) -> None:
self.bot = bot
async def cog_check(self, ctx: commands.Context) -> bool:
"""Ensure only administrators can use these commands."""
if not ctx.guild:
return False
return ctx.author.guild_permissions.administrator
@commands.group(name="config", invoke_without_command=True)
@commands.guild_only()
async def config(self, ctx: commands.Context) -> None:
"""View or modify bot configuration."""
config = await self.bot.guild_config.get_config(ctx.guild.id)
if not config:
await ctx.send("No configuration found. Run a config command to initialize.")
return
embed = discord.Embed(
title=f"Configuration for {ctx.guild.name}",
color=discord.Color.blue(),
)
# General settings
embed.add_field(name="Prefix", value=f"`{config.prefix}`", inline=True)
embed.add_field(name="Locale", value=config.locale, inline=True)
embed.add_field(name="\u200b", value="\u200b", inline=True)
# Channels
log_ch = ctx.guild.get_channel(config.log_channel_id) if config.log_channel_id else None
mod_log_ch = (
ctx.guild.get_channel(config.mod_log_channel_id) if config.mod_log_channel_id else None
)
welcome_ch = (
ctx.guild.get_channel(config.welcome_channel_id) if config.welcome_channel_id else None
)
embed.add_field(
name="Log Channel", value=log_ch.mention if log_ch else "Not set", inline=True
)
embed.add_field(
name="Mod Log Channel",
value=mod_log_ch.mention if mod_log_ch else "Not set",
inline=True,
)
embed.add_field(
name="Welcome Channel",
value=welcome_ch.mention if welcome_ch else "Not set",
inline=True,
)
# Features
features = []
if config.automod_enabled:
features.append("AutoMod")
if config.anti_spam_enabled:
features.append("Anti-Spam")
if config.link_filter_enabled:
features.append("Link Filter")
if config.ai_moderation_enabled:
features.append("AI Moderation")
if config.verification_enabled:
features.append("Verification")
embed.add_field(
name="Enabled Features",
value=", ".join(features) if features else "None",
inline=False,
)
await ctx.send(embed=embed)
@config.command(name="prefix")
@commands.guild_only()
async def config_prefix(self, ctx: commands.Context, prefix: str) -> None:
"""Set the command prefix for this server."""
if not prefix or not prefix.strip():
await ctx.send("Prefix cannot be empty or whitespace only.")
return
if len(prefix) > 10:
await ctx.send("Prefix must be 10 characters or less.")
return
await self.bot.guild_config.update_settings(ctx.guild.id, prefix=prefix)
await ctx.send(f"Command prefix set to `{prefix}`")
@config.command(name="logchannel")
@commands.guild_only()
async def config_log_channel(
self, ctx: commands.Context, channel: discord.TextChannel | None = None
) -> None:
"""Set the channel for general event logs."""
channel_id = channel.id if channel else None
await self.bot.guild_config.update_settings(ctx.guild.id, log_channel_id=channel_id)
if channel:
await ctx.send(f"Log channel set to {channel.mention}")
else:
await ctx.send("Log channel has been disabled.")
@config.command(name="modlogchannel")
@commands.guild_only()
async def config_mod_log_channel(
self, ctx: commands.Context, channel: discord.TextChannel | None = None
) -> None:
"""Set the channel for moderation action logs."""
channel_id = channel.id if channel else None
await self.bot.guild_config.update_settings(ctx.guild.id, mod_log_channel_id=channel_id)
if channel:
await ctx.send(f"Moderation log channel set to {channel.mention}")
else:
await ctx.send("Moderation log channel has been disabled.")
@config.command(name="welcomechannel")
@commands.guild_only()
async def config_welcome_channel(
self, ctx: commands.Context, channel: discord.TextChannel | None = None
) -> None:
"""Set the welcome channel for new members."""
channel_id = channel.id if channel else None
await self.bot.guild_config.update_settings(ctx.guild.id, welcome_channel_id=channel_id)
if channel:
await ctx.send(f"Welcome channel set to {channel.mention}")
else:
await ctx.send("Welcome channel has been disabled.")
@config.command(name="muterole")
@commands.guild_only()
async def config_mute_role(
self, ctx: commands.Context, role: discord.Role | None = None
) -> None:
"""Set the role to assign when muting members."""
role_id = role.id if role else None
await self.bot.guild_config.update_settings(ctx.guild.id, mute_role_id=role_id)
if role:
await ctx.send(f"Mute role set to {role.mention}")
else:
await ctx.send("Mute role has been cleared.")
@config.command(name="automod")
@commands.guild_only()
async def config_automod(self, ctx: commands.Context, enabled: bool) -> None:
"""Enable or disable automod features."""
await self.bot.guild_config.update_settings(ctx.guild.id, automod_enabled=enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(f"AutoMod has been {status}.")
@config.command(name="antispam")
@commands.guild_only()
async def config_antispam(self, ctx: commands.Context, enabled: bool) -> None:
"""Enable or disable anti-spam protection."""
await self.bot.guild_config.update_settings(ctx.guild.id, anti_spam_enabled=enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(f"Anti-spam has been {status}.")
@config.command(name="linkfilter")
@commands.guild_only()
async def config_linkfilter(self, ctx: commands.Context, enabled: bool) -> None:
"""Enable or disable link filtering."""
await self.bot.guild_config.update_settings(ctx.guild.id, link_filter_enabled=enabled)
status = "enabled" if enabled else "disabled"
await ctx.send(f"Link filter has been {status}.")
@commands.group(name="bannedwords", aliases=["bw"], invoke_without_command=True)
@commands.guild_only()
async def banned_words(self, ctx: commands.Context) -> None:
"""Manage banned words list."""
words = await self.bot.guild_config.get_banned_words(ctx.guild.id)
if not words:
await ctx.send("No banned words configured.")
return
embed = discord.Embed(
title="Banned Words",
color=discord.Color.red(),
)
for word in words[:25]: # Discord embed limit
word_type = "Regex" if word.is_regex else "Text"
embed.add_field(
name=f"#{word.id}: {word.pattern[:30]}",
value=f"Type: {word_type} | Action: {word.action}",
inline=True,
)
if len(words) > 25:
embed.set_footer(text=f"Showing 25 of {len(words)} banned words")
await ctx.send(embed=embed)
@banned_words.command(name="add")
@commands.guild_only()
async def banned_words_add(
self,
ctx: commands.Context,
pattern: str,
action: Literal["delete", "warn", "strike"] = "delete",
is_regex: bool = False,
) -> None:
"""Add a banned word or pattern."""
word = await self.bot.guild_config.add_banned_word(
guild_id=ctx.guild.id,
pattern=pattern,
added_by=ctx.author.id,
is_regex=is_regex,
action=action,
)
word_type = "regex pattern" if is_regex else "word"
await ctx.send(f"Added banned {word_type}: `{pattern}` (ID: {word.id}, Action: {action})")
@banned_words.command(name="remove", aliases=["delete"])
@commands.guild_only()
async def banned_words_remove(self, ctx: commands.Context, word_id: int) -> None:
"""Remove a banned word by ID."""
success = await self.bot.guild_config.remove_banned_word(ctx.guild.id, word_id)
if success:
await ctx.send(f"Removed banned word #{word_id}")
else:
await ctx.send(f"Banned word #{word_id} not found.")
@commands.command(name="sync")
@commands.is_owner()
async def sync_commands(self, ctx: commands.Context) -> None:
"""Sync slash commands (bot owner only)."""
await self.bot.tree.sync()
await ctx.send("Slash commands synced.")
async def setup(bot: GuardDen) -> None:
"""Load the Admin cog."""
await bot.add_cog(Admin(bot))