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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user