feat: Complete cog and service rewrites

- Automod cog: 520 -> 100 lines (spam only, no commands)
- AI moderation cog: 664 -> 250 lines (images only, full cost controls)
- Automod service: 600+ -> 200 lines (spam only)
- All cost control measures implemented
- NSFW video domain blocking
- Rate limiting per guild and per user
- Image deduplication
- File size limits
- Configurable via YAML

Next: Update AI providers and models
This commit is contained in:
2026-01-27 19:17:18 +01:00
parent 08815a3dd0
commit d972f6f51c
3 changed files with 308 additions and 1509 deletions

View File

@@ -1,331 +1,81 @@
"""Automod cog for automatic content moderation."""
"""Automod cog for automatic spam detection - Minimal Version."""
import logging
from datetime import datetime, timedelta, timezone
from typing import Literal
import discord
from discord.ext import commands
from sqlalchemy import func, select
from guardden.bot import GuardDen
from guardden.models import ModerationLog, Strike
from guardden.services.automod import (
AutomodResult,
AutomodService,
SpamConfig,
normalize_domain,
)
from guardden.utils.notifications import send_moderation_notification
from guardden.utils.ratelimit import RateLimitExceeded
from guardden.services.automod import AutomodResult, AutomodService, SpamConfig
logger = logging.getLogger(__name__)
class Automod(commands.Cog):
"""Automatic content moderation."""
"""Automatic spam detection (no commands, no banned words)."""
def __init__(self, bot: GuardDen) -> None:
self.bot = bot
self.automod = AutomodService()
def cog_check(self, ctx: commands.Context) -> bool:
"""Optional owner allowlist for automod commands."""
if not ctx.guild:
return False
return self.bot.is_owner_allowed(ctx.author.id)
async def cog_before_invoke(self, ctx: commands.Context) -> None:
if not ctx.command:
return
result = self.bot.rate_limiter.acquire_command(
ctx.command.qualified_name,
user_id=ctx.author.id,
guild_id=ctx.guild.id if ctx.guild else None,
channel_id=ctx.channel.id,
)
if result.is_limited:
raise RateLimitExceeded(result.reset_after)
async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None:
if isinstance(error, RateLimitExceeded):
await ctx.send(
f"You're being rate limited. Try again in {error.retry_after:.1f} seconds."
)
def _spam_config(self, config) -> SpamConfig:
if not config:
return self.automod.default_spam_config
def _spam_config(self) -> SpamConfig:
"""Get spam config from YAML."""
config_loader = self.bot.config_loader
return SpamConfig(
message_rate_limit=config.message_rate_limit,
message_rate_window=config.message_rate_window,
duplicate_threshold=config.duplicate_threshold,
mention_limit=config.mention_limit,
mention_rate_limit=config.mention_rate_limit,
mention_rate_window=config.mention_rate_window,
message_rate_limit=config_loader.get_setting("automod.message_rate_limit", 5),
message_rate_window=config_loader.get_setting("automod.message_rate_window", 5),
duplicate_threshold=config_loader.get_setting("automod.duplicate_threshold", 3),
mention_limit=config_loader.get_setting("automod.mention_limit", 5),
mention_rate_limit=config_loader.get_setting("automod.mention_rate_limit", 10),
mention_rate_window=config_loader.get_setting("automod.mention_rate_window", 60),
)
async def _get_strike_count(self, guild_id: int, user_id: int) -> int:
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,
member: discord.Member,
reason: str,
) -> int:
async with self.bot.database.session() as session:
strike = Strike(
guild_id=guild.id,
user_id=member.id,
user_name=str(member),
moderator_id=self.bot.user.id if self.bot.user else 0,
reason=reason,
points=1,
)
session.add(strike)
return await self._get_strike_count(guild.id, member.id)
async def _apply_strike_actions(
self,
member: discord.Member,
total_strikes: int,
config,
) -> None:
if not config or not config.strike_actions:
return
for threshold, action_config in sorted(
config.strike_actions.items(), key=lambda item: int(item[0]), reverse=True
):
if total_strikes < int(threshold):
continue
action = action_config.get("action")
if action == "ban":
await member.ban(reason=f"Automod: {total_strikes} strikes")
elif action == "kick":
await member.kick(reason=f"Automod: {total_strikes} strikes")
elif action == "timeout":
duration = action_config.get("duration", 3600)
await member.timeout(
timedelta(seconds=duration),
reason=f"Automod: {total_strikes} strikes",
)
break
async def _log_database_action(
self,
message: discord.Message,
result: AutomodResult,
) -> None:
async with self.bot.database.session() as session:
action = "delete"
if result.should_timeout:
action = "timeout"
elif result.should_strike:
action = "strike"
elif result.should_warn:
action = "warn"
expires_at = None
if result.timeout_duration:
expires_at = datetime.now(timezone.utc) + timedelta(seconds=result.timeout_duration)
log_entry = ModerationLog(
guild_id=message.guild.id,
target_id=message.author.id,
target_name=str(message.author),
moderator_id=self.bot.user.id if self.bot.user else 0,
moderator_name=str(self.bot.user) if self.bot.user else "GuardDen",
action=action,
reason=result.reason,
duration=result.timeout_duration or None,
expires_at=expires_at,
channel_id=message.channel.id,
message_id=message.id,
message_content=message.content,
is_automatic=True,
)
session.add(log_entry)
async def _handle_violation(
self,
message: discord.Message,
result: AutomodResult,
) -> None:
"""Handle an automod violation."""
# Delete the message
"""Handle an automod violation by deleting the message."""
# Delete the message (no logging, no timeout, no DM)
if result.should_delete:
try:
await message.delete()
logger.info(
f"Automod deleted message from {message.author} in {message.guild.name}: {result.reason}"
)
except discord.Forbidden:
logger.warning(f"Cannot delete message in {message.guild}: missing permissions")
except discord.NotFound:
pass # Already deleted
# Apply timeout
if result.should_timeout and result.timeout_duration > 0:
try:
await message.author.timeout(
timedelta(seconds=result.timeout_duration),
reason=f"Automod: {result.reason}",
)
except discord.Forbidden:
logger.warning(f"Cannot timeout {message.author}: missing permissions")
# Log the action
await self._log_database_action(message, result)
await self._log_automod_action(message, result)
# Apply strike escalation if configured
if (result.should_warn or result.should_strike) and isinstance(
message.author, discord.Member
):
total = await self._add_strike(message.guild, message.author, result.reason)
config = await self.bot.guild_config.get_config(message.guild.id)
await self._apply_strike_actions(message.author, total, config)
# Notify the user
config = await self.bot.guild_config.get_config(message.guild.id)
embed = discord.Embed(
title=f"Message Removed in {message.guild.name}",
description=result.reason,
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
if result.should_timeout:
embed.add_field(
name="Timeout",
value=f"You have been timed out for {result.timeout_duration} seconds.",
)
# Use notification utility to send DM with in-channel fallback
if isinstance(message.channel, discord.TextChannel):
await send_moderation_notification(
user=message.author,
channel=message.channel,
embed=embed,
send_in_channel=config.send_in_channel_warnings if config else False,
)
async def _log_automod_action(
self,
message: discord.Message,
result: AutomodResult,
) -> None:
"""Log an automod action to the mod log channel."""
config = await self.bot.guild_config.get_config(message.guild.id)
if not config or not config.mod_log_channel_id:
return
channel = message.guild.get_channel(config.mod_log_channel_id)
if not channel or not isinstance(channel, discord.TextChannel):
return
embed = discord.Embed(
title="Automod Action",
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_author(
name=str(message.author),
icon_url=message.author.display_avatar.url,
)
embed.add_field(name="Filter", value=result.matched_filter, inline=True)
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
embed.add_field(name="Reason", value=result.reason, inline=False)
if message.content:
content = (
message.content[:500] + "..." if len(message.content) > 500 else message.content
)
embed.add_field(name="Message Content", value=f"```{content}```", inline=False)
actions = []
if result.should_delete:
actions.append("Message deleted")
if result.should_warn:
actions.append("User warned")
if result.should_strike:
actions.append("Strike added")
if result.should_timeout:
actions.append(f"Timeout ({result.timeout_duration}s)")
embed.add_field(name="Actions Taken", value=", ".join(actions) or "None", inline=False)
embed.set_footer(text=f"User ID: {message.author.id}")
await channel.send(embed=embed)
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Check all messages for automod violations."""
# Ignore DMs, bots, and empty messages
"""Check all messages for spam violations."""
# Skip DMs, bots, and empty messages
if not message.guild or message.author.bot or not message.content:
return
# Ignore users with manage_messages permission
if isinstance(message.author, discord.Member):
if message.author.guild_permissions.manage_messages:
return
# Get guild config
config = await self.bot.guild_config.get_config(message.guild.id)
if not config or not config.automod_enabled:
# Get config from YAML
config = self.bot.config_loader
if not config.get_setting("automod.enabled", True):
return
# Check if user is whitelisted
if message.author.id in config.whitelisted_user_ids:
return
result: AutomodResult | None = None
# Check banned words
banned_words = await self.bot.guild_config.get_banned_words(message.guild.id)
if banned_words:
result = self.automod.check_banned_words(message.content, banned_words)
spam_config = self._spam_config(config)
# Check scam links (if link filter enabled)
if not result and config.link_filter_enabled:
result = self.automod.check_scam_links(
message.content,
allowlist=config.scam_allowlist,
)
# Check spam
if not result and config.anti_spam_enabled:
# Check spam ONLY (no banned words, no scam links, no invites)
if config.get_setting("automod.anti_spam_enabled", True):
spam_config = self._spam_config()
result = self.automod.check_spam(
message,
anti_spam_enabled=True,
spam_config=spam_config,
)
# Check invite links (if link filter enabled)
if not result and config.link_filter_enabled:
result = self.automod.check_invite_links(message.content, allow_invites=False)
# Handle violation if found
if result:
logger.info(
f"Automod triggered in {message.guild.name}: "
f"{result.matched_filter} by {message.author}"
)
await self._handle_violation(message, result)
if result:
await self._handle_violation(message, result)
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
"""Check edited messages for automod violations."""
"""Check edited messages for spam violations."""
# Only check if content changed
if before.content == after.content:
return
@@ -333,186 +83,6 @@ class Automod(commands.Cog):
# Reuse on_message logic
await self.on_message(after)
@commands.group(name="automod", invoke_without_command=True)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_cmd(self, ctx: commands.Context) -> None:
"""View automod status and configuration."""
config = await self.bot.guild_config.get_config(ctx.guild.id)
embed = discord.Embed(
title="Automod Configuration",
color=discord.Color.blue(),
)
embed.add_field(
name="Automod Enabled",
value="✅ Yes" if config and config.automod_enabled else "❌ No",
inline=True,
)
embed.add_field(
name="Anti-Spam",
value="✅ Yes" if config and config.anti_spam_enabled else "❌ No",
inline=True,
)
embed.add_field(
name="Link Filter",
value="✅ Yes" if config and config.link_filter_enabled else "❌ No",
inline=True,
)
spam_config = self._spam_config(config)
# Show thresholds
embed.add_field(
name="Rate Limit",
value=f"{spam_config.message_rate_limit} msgs / {spam_config.message_rate_window}s",
inline=True,
)
embed.add_field(
name="Duplicate Threshold",
value=f"{spam_config.duplicate_threshold} same messages",
inline=True,
)
embed.add_field(
name="Mention Limit",
value=f"{spam_config.mention_limit} per message",
inline=True,
)
embed.add_field(
name="Mention Rate",
value=f"{spam_config.mention_rate_limit} mentions / {spam_config.mention_rate_window}s",
inline=True,
)
banned_words = await self.bot.guild_config.get_banned_words(ctx.guild.id)
embed.add_field(
name="Banned Words",
value=f"{len(banned_words)} configured",
inline=True,
)
await ctx.send(embed=embed)
@automod_cmd.command(name="threshold")
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_threshold(
self,
ctx: commands.Context,
setting: Literal[
"message_rate_limit",
"message_rate_window",
"duplicate_threshold",
"mention_limit",
"mention_rate_limit",
"mention_rate_window",
],
value: int,
) -> None:
"""Update a single automod threshold."""
if value <= 0:
await ctx.send("Threshold values must be positive.")
return
await self.bot.guild_config.update_settings(ctx.guild.id, **{setting: value})
await ctx.send(f"Updated `{setting}` to {value}.")
@automod_cmd.group(name="allowlist", invoke_without_command=True)
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_allowlist(self, ctx: commands.Context) -> None:
"""Show the scam link allowlist."""
config = await self.bot.guild_config.get_config(ctx.guild.id)
allowlist = sorted(config.scam_allowlist) if config else []
if not allowlist:
await ctx.send("No allowlisted domains configured.")
return
formatted = "\n".join(f"- `{domain}`" for domain in allowlist[:20])
await ctx.send(f"Allowed domains:\n{formatted}")
@automod_allowlist.command(name="add")
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_allowlist_add(self, ctx: commands.Context, domain: str) -> None:
"""Add a domain to the scam link allowlist."""
normalized = normalize_domain(domain)
if not normalized:
await ctx.send("Provide a valid domain or URL to allowlist.")
return
config = await self.bot.guild_config.get_config(ctx.guild.id)
allowlist = list(config.scam_allowlist) if config else []
if normalized in allowlist:
await ctx.send(f"`{normalized}` is already allowlisted.")
return
allowlist.append(normalized)
await self.bot.guild_config.update_settings(ctx.guild.id, scam_allowlist=allowlist)
await ctx.send(f"Added `{normalized}` to the allowlist.")
@automod_allowlist.command(name="remove")
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_allowlist_remove(self, ctx: commands.Context, domain: str) -> None:
"""Remove a domain from the scam link allowlist."""
normalized = normalize_domain(domain)
config = await self.bot.guild_config.get_config(ctx.guild.id)
allowlist = list(config.scam_allowlist) if config else []
if normalized not in allowlist:
await ctx.send(f"`{normalized}` is not in the allowlist.")
return
allowlist.remove(normalized)
await self.bot.guild_config.update_settings(ctx.guild.id, scam_allowlist=allowlist)
await ctx.send(f"Removed `{normalized}` from the allowlist.")
@automod_cmd.command(name="test")
@commands.has_permissions(administrator=True)
@commands.guild_only()
async def automod_test(self, ctx: commands.Context, *, text: str) -> None:
"""Test a message against automod filters (does not take action)."""
config = await self.bot.guild_config.get_config(ctx.guild.id)
results = []
# Check banned words
banned_words = await self.bot.guild_config.get_banned_words(ctx.guild.id)
result = self.automod.check_banned_words(text, banned_words)
if result:
results.append(f"**Banned Words**: {result.reason}")
# Check scam links
result = self.automod.check_scam_links(
text, allowlist=config.scam_allowlist if config else []
)
if result:
results.append(f"**Scam Detection**: {result.reason}")
# Check invite links
result = self.automod.check_invite_links(text, allow_invites=False)
if result:
results.append(f"**Invite Links**: {result.reason}")
# Check caps
result = self.automod.check_all_caps(text)
if result:
results.append(f"**Excessive Caps**: {result.reason}")
embed = discord.Embed(
title="Automod Test Results",
color=discord.Color.red() if results else discord.Color.green(),
)
if results:
embed.description = "\n".join(results)
else:
embed.description = "✅ No violations detected"
await ctx.send(embed=embed)
async def setup(bot: GuardDen) -> None:
"""Load the Automod cog."""