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

View File

@@ -0,0 +1,466 @@
"""Moderation commands and automod features."""
import logging
import re
from datetime import datetime, timedelta, timezone
import discord
from discord.ext import commands
from sqlalchemy import func, select
from guardden.bot import GuardDen
from guardden.models import ModerationLog, Strike
logger = logging.getLogger(__name__)
def parse_duration(duration_str: str) -> timedelta | None:
"""Parse a duration string like '1h', '30m', '7d' into a timedelta."""
match = re.match(r"^(\d+)([smhdw])$", duration_str.lower())
if not match:
return None
amount = int(match.group(1))
unit = match.group(2)
units = {
"s": timedelta(seconds=amount),
"m": timedelta(minutes=amount),
"h": timedelta(hours=amount),
"d": timedelta(days=amount),
"w": timedelta(weeks=amount),
}
return units.get(unit)
class Moderation(commands.Cog):
"""Moderation commands for server management."""
def __init__(self, bot: GuardDen) -> None:
self.bot = bot
async def _log_action(
self,
guild: discord.Guild,
target: discord.Member | discord.User,
moderator: discord.Member | discord.User,
action: str,
reason: str | None = None,
duration: int | None = None,
channel: discord.TextChannel | None = None,
message: discord.Message | None = None,
is_automatic: bool = False,
) -> None:
"""Log a moderation action to the database."""
expires_at = None
if duration:
expires_at = datetime.now(timezone.utc) + timedelta(seconds=duration)
async with self.bot.database.session() as session:
log_entry = ModerationLog(
guild_id=guild.id,
target_id=target.id,
target_name=str(target),
moderator_id=moderator.id,
moderator_name=str(moderator),
action=action,
reason=reason,
duration=duration,
expires_at=expires_at,
channel_id=channel.id if channel else None,
message_id=message.id if message else None,
message_content=message.content if message else None,
is_automatic=is_automatic,
)
session.add(log_entry)
async def _get_strike_count(self, guild_id: int, user_id: int) -> int:
"""Get the total active strike count for a user."""
async with self.bot.database.session() as session:
result = await session.execute(
select(func.sum(Strike.points)).where(
Strike.guild_id == guild_id,
Strike.user_id == user_id,
Strike.is_active == True,
)
)
total = result.scalar()
return total or 0
async def _add_strike(
self,
guild: discord.Guild,
user: discord.Member,
moderator: discord.Member | discord.User,
reason: str,
points: int = 1,
) -> int:
"""Add a strike to a user and return their new total."""
async with self.bot.database.session() as session:
strike = Strike(
guild_id=guild.id,
user_id=user.id,
user_name=str(user),
moderator_id=moderator.id,
reason=reason,
points=points,
)
session.add(strike)
return await self._get_strike_count(guild.id, user.id)
@commands.command(name="warn")
@commands.has_permissions(kick_members=True)
@commands.guild_only()
async def warn(
self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided"
) -> None:
"""Warn a member."""
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
await ctx.send("You cannot warn someone with a higher or equal role.")
return
await self._log_action(ctx.guild, member, ctx.author, "warn", reason)
embed = discord.Embed(
title="Warning Issued",
description=f"{member.mention} has been warned.",
color=discord.Color.yellow(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
# Try to DM the user
try:
dm_embed = discord.Embed(
title=f"Warning in {ctx.guild.name}",
description=f"You have been warned.",
color=discord.Color.yellow(),
)
dm_embed.add_field(name="Reason", value=reason)
await member.send(embed=dm_embed)
except discord.Forbidden:
pass
@commands.command(name="strike")
@commands.has_permissions(kick_members=True)
@commands.guild_only()
async def strike(
self,
ctx: commands.Context,
member: discord.Member,
points: int = 1,
*,
reason: str = "No reason provided",
) -> None:
"""Add a strike to a member."""
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
await ctx.send("You cannot strike someone with a higher or equal role.")
return
total_strikes = await self._add_strike(ctx.guild, member, ctx.author, reason, points)
await self._log_action(ctx.guild, member, ctx.author, "strike", reason)
embed = discord.Embed(
title="Strike Added",
description=f"{member.mention} has received {points} strike(s).",
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.add_field(name="Total Strikes", value=str(total_strikes))
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
# Check for automatic actions based on strike thresholds
config = await self.bot.guild_config.get_config(ctx.guild.id)
if config and config.strike_actions:
for threshold, action_config in sorted(
config.strike_actions.items(), key=lambda x: int(x[0]), reverse=True
):
if total_strikes >= int(threshold):
action = action_config.get("action")
if action == "ban":
await ctx.invoke(
self.ban, member=member, reason=f"Automatic: {total_strikes} strikes"
)
elif action == "kick":
await ctx.invoke(
self.kick, member=member, reason=f"Automatic: {total_strikes} strikes"
)
elif action == "timeout":
duration = action_config.get("duration", 3600)
await ctx.invoke(
self.timeout,
member=member,
duration=f"{duration}s",
reason=f"Automatic: {total_strikes} strikes",
)
break
@commands.command(name="strikes")
@commands.has_permissions(kick_members=True)
@commands.guild_only()
async def strikes(self, ctx: commands.Context, member: discord.Member) -> None:
"""View strikes for a member."""
async with self.bot.database.session() as session:
result = await session.execute(
select(Strike)
.where(
Strike.guild_id == ctx.guild.id,
Strike.user_id == member.id,
Strike.is_active == True,
)
.order_by(Strike.created_at.desc())
.limit(10)
)
user_strikes = result.scalars().all()
total = await self._get_strike_count(ctx.guild.id, member.id)
embed = discord.Embed(
title=f"Strikes for {member}",
description=f"Total active strikes: **{total}**",
color=discord.Color.orange(),
)
if user_strikes:
for strike in user_strikes:
embed.add_field(
name=f"Strike #{strike.id} ({strike.points} pts)",
value=f"{strike.reason}\n*{strike.created_at.strftime('%Y-%m-%d')}*",
inline=False,
)
else:
embed.description = f"{member.mention} has no active strikes."
await ctx.send(embed=embed)
@commands.command(name="timeout", aliases=["mute"])
@commands.has_permissions(moderate_members=True)
@commands.guild_only()
async def timeout(
self,
ctx: commands.Context,
member: discord.Member,
duration: str = "1h",
*,
reason: str = "No reason provided",
) -> None:
"""Timeout a member (e.g., !timeout @user 1h Spamming)."""
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
await ctx.send("You cannot timeout someone with a higher or equal role.")
return
delta = parse_duration(duration)
if not delta:
await ctx.send("Invalid duration. Use format like: 30m, 1h, 7d")
return
if delta > timedelta(days=28):
await ctx.send("Timeout duration cannot exceed 28 days.")
return
try:
await member.timeout(delta, reason=f"{ctx.author}: {reason}")
except discord.Forbidden:
await ctx.send("I don't have permission to timeout this user.")
return
except discord.HTTPException as e:
await ctx.send(f"Failed to timeout user: {e}")
return
await self._log_action(
ctx.guild, member, ctx.author, "timeout", reason, int(delta.total_seconds())
)
embed = discord.Embed(
title="Member Timed Out",
description=f"{member.mention} has been timed out for {duration}.",
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
@commands.command(name="untimeout", aliases=["unmute"])
@commands.has_permissions(moderate_members=True)
@commands.guild_only()
async def untimeout(
self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided"
) -> None:
"""Remove timeout from a member."""
await member.timeout(None, reason=f"{ctx.author}: {reason}")
await self._log_action(ctx.guild, member, ctx.author, "unmute", reason)
embed = discord.Embed(
title="Timeout Removed",
description=f"{member.mention}'s timeout has been removed.",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
@commands.command(name="kick")
@commands.has_permissions(kick_members=True)
@commands.guild_only()
async def kick(
self, ctx: commands.Context, member: discord.Member, *, reason: str = "No reason provided"
) -> None:
"""Kick a member from the server."""
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
await ctx.send("You cannot kick someone with a higher or equal role.")
return
# Try to DM the user before kicking
try:
dm_embed = discord.Embed(
title=f"Kicked from {ctx.guild.name}",
description=f"You have been kicked from the server.",
color=discord.Color.red(),
)
dm_embed.add_field(name="Reason", value=reason)
await member.send(embed=dm_embed)
except discord.Forbidden:
pass
await member.kick(reason=f"{ctx.author}: {reason}")
await self._log_action(ctx.guild, member, ctx.author, "kick", reason)
embed = discord.Embed(
title="Member Kicked",
description=f"{member} has been kicked from the server.",
color=discord.Color.red(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
@commands.command(name="ban")
@commands.has_permissions(ban_members=True)
@commands.guild_only()
async def ban(
self,
ctx: commands.Context,
member: discord.Member | discord.User,
*,
reason: str = "No reason provided",
) -> None:
"""Ban a member from the server."""
if isinstance(member, discord.Member):
if member.top_role >= ctx.author.top_role and ctx.author != ctx.guild.owner:
await ctx.send("You cannot ban someone with a higher or equal role.")
return
# Try to DM the user before banning
try:
dm_embed = discord.Embed(
title=f"Banned from {ctx.guild.name}",
description=f"You have been banned from the server.",
color=discord.Color.dark_red(),
)
dm_embed.add_field(name="Reason", value=reason)
await member.send(embed=dm_embed)
except discord.Forbidden:
pass
await ctx.guild.ban(member, reason=f"{ctx.author}: {reason}", delete_message_days=0)
await self._log_action(ctx.guild, member, ctx.author, "ban", reason)
embed = discord.Embed(
title="Member Banned",
description=f"{member} has been banned from the server.",
color=discord.Color.dark_red(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
@commands.command(name="unban")
@commands.has_permissions(ban_members=True)
@commands.guild_only()
async def unban(
self, ctx: commands.Context, user_id: int, *, reason: str = "No reason provided"
) -> None:
"""Unban a user by their ID."""
try:
user = await self.bot.fetch_user(user_id)
await ctx.guild.unban(user, reason=f"{ctx.author}: {reason}")
await self._log_action(ctx.guild, user, ctx.author, "unban", reason)
embed = discord.Embed(
title="User Unbanned",
description=f"{user} has been unbanned.",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="Reason", value=reason, inline=False)
embed.set_footer(text=f"Moderator: {ctx.author}")
await ctx.send(embed=embed)
except discord.NotFound:
await ctx.send("User not found or not banned.")
except discord.Forbidden:
await ctx.send("I don't have permission to unban this user.")
@commands.command(name="purge", aliases=["clear"])
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def purge(self, ctx: commands.Context, amount: int) -> None:
"""Delete multiple messages at once (max 100)."""
if amount < 1 or amount > 100:
await ctx.send("Please specify a number between 1 and 100.")
return
deleted = await ctx.channel.purge(limit=amount + 1) # +1 to include the command message
msg = await ctx.send(f"Deleted {len(deleted) - 1} message(s).")
await msg.delete(delay=3)
@commands.command(name="modlogs", aliases=["history"])
@commands.has_permissions(kick_members=True)
@commands.guild_only()
async def modlogs(self, ctx: commands.Context, member: discord.Member | discord.User) -> None:
"""View moderation history for a user."""
async with self.bot.database.session() as session:
result = await session.execute(
select(ModerationLog)
.where(ModerationLog.guild_id == ctx.guild.id, ModerationLog.target_id == member.id)
.order_by(ModerationLog.created_at.desc())
.limit(10)
)
logs = result.scalars().all()
embed = discord.Embed(
title=f"Moderation History for {member}",
color=discord.Color.blue(),
)
if logs:
for log in logs:
value = f"**Reason:** {log.reason or 'None'}\n**By:** {log.moderator_name}\n*{log.created_at.strftime('%Y-%m-%d %H:%M')}*"
embed.add_field(name=f"{log.action.upper()} (#{log.id})", value=value, inline=False)
else:
embed.description = "No moderation history found."
await ctx.send(embed=embed)
async def setup(bot: GuardDen) -> None:
"""Load the Moderation cog."""
await bot.add_cog(Moderation(bot))