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,301 @@
"""Automod service for content filtering and spam detection."""
import logging
import re
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
import discord
from guardden.models import BannedWord
logger = logging.getLogger(__name__)
# Known scam/phishing patterns
SCAM_PATTERNS = [
# Discord scam patterns
r"discord(?:[-.]?(?:gift|nitro|free|claim|steam))[\w.-]*\.(?!com|gg)[a-z]{2,}",
r"(?:free|claim|get)[-.\s]?(?:discord[-.\s]?)?nitro",
r"(?:steam|discord)[-.\s]?community[-.\s]?(?:giveaway|gift)",
# Generic phishing
r"(?:verify|confirm)[-.\s]?(?:your)?[-.\s]?account",
r"(?:suspended|locked|limited)[-.\s]?account",
r"click[-.\s]?(?:here|this)[-.\s]?(?:to[-.\s]?)?(?:verify|claim|get)",
# Crypto scams
r"(?:free|claim|airdrop)[-.\s]?(?:crypto|bitcoin|eth|nft)",
r"(?:double|2x)[-.\s]?your[-.\s]?(?:crypto|bitcoin|eth)",
]
# Suspicious TLDs often used in phishing
SUSPICIOUS_TLDS = {
".xyz",
".top",
".club",
".work",
".click",
".link",
".info",
".ru",
".cn",
".tk",
".ml",
".ga",
".cf",
".gq",
}
# URL pattern for extraction
URL_PATTERN = re.compile(
r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+[^\s]*|"
r"(?:www\.)?[-\w]+\.(?:com|org|net|io|gg|co|me|tv|xyz|top|club|work|click|link|info|ru|cn)[^\s]*",
re.IGNORECASE,
)
class SpamRecord(NamedTuple):
"""Record of a message for spam tracking."""
content_hash: str
timestamp: datetime
@dataclass
class UserSpamTracker:
"""Tracks spam behavior for a single user."""
messages: list[SpamRecord] = field(default_factory=list)
mention_count: int = 0
last_mention_time: datetime | None = None
duplicate_count: int = 0
last_action_time: datetime | None = None
def cleanup(self, max_age: timedelta = timedelta(minutes=1)) -> None:
"""Remove old messages from tracking."""
cutoff = datetime.now(timezone.utc) - max_age
self.messages = [m for m in self.messages if m.timestamp > cutoff]
@dataclass
class AutomodResult:
"""Result of automod check."""
should_delete: bool = False
should_warn: bool = False
should_strike: bool = False
should_timeout: bool = False
timeout_duration: int = 0 # seconds
reason: str = ""
matched_filter: str = ""
class AutomodService:
"""Service for automatic content moderation."""
def __init__(self) -> None:
# Compile scam patterns
self._scam_patterns = [re.compile(p, re.IGNORECASE) for p in SCAM_PATTERNS]
# Per-guild, per-user spam tracking
# Structure: {guild_id: {user_id: UserSpamTracker}}
self._spam_trackers: dict[int, dict[int, UserSpamTracker]] = defaultdict(
lambda: defaultdict(UserSpamTracker)
)
# Spam thresholds
self.message_rate_limit = 5 # messages per window
self.message_rate_window = 5 # seconds
self.duplicate_threshold = 3 # same message count
self.mention_limit = 5 # mentions per message
self.mention_rate_limit = 10 # mentions per window
self.mention_rate_window = 60 # seconds
def _get_content_hash(self, content: str) -> str:
"""Get a normalized hash of message content for duplicate detection."""
# Normalize: lowercase, remove extra spaces, remove special chars
normalized = re.sub(r"[^\w\s]", "", content.lower())
normalized = re.sub(r"\s+", " ", normalized).strip()
return normalized
def check_banned_words(
self, content: str, banned_words: list[BannedWord]
) -> AutomodResult | None:
"""Check message against banned words list."""
content_lower = content.lower()
for banned in banned_words:
matched = False
if banned.is_regex:
try:
if re.search(banned.pattern, content, re.IGNORECASE):
matched = True
except re.error:
logger.warning(f"Invalid regex pattern: {banned.pattern}")
continue
else:
if banned.pattern.lower() in content_lower:
matched = True
if matched:
result = AutomodResult(
should_delete=True,
reason=banned.reason or f"Matched banned word filter",
matched_filter=f"banned_word:{banned.id}",
)
if banned.action == "warn":
result.should_warn = True
elif banned.action == "strike":
result.should_strike = True
return result
return None
def check_scam_links(self, content: str) -> AutomodResult | None:
"""Check message for scam/phishing patterns."""
# Check for known scam patterns
for pattern in self._scam_patterns:
if pattern.search(content):
return AutomodResult(
should_delete=True,
should_warn=True,
reason="Message matched known scam/phishing pattern",
matched_filter="scam_pattern",
)
# Check URLs for suspicious TLDs
urls = URL_PATTERN.findall(content)
for url in urls:
url_lower = url.lower()
for tld in SUSPICIOUS_TLDS:
if tld in url_lower:
# Additional check: is it trying to impersonate a known domain?
impersonation_keywords = [
"discord",
"steam",
"nitro",
"gift",
"free",
"login",
"verify",
]
if any(kw in url_lower for kw in impersonation_keywords):
return AutomodResult(
should_delete=True,
should_warn=True,
reason=f"Suspicious link detected: {url[:50]}",
matched_filter="suspicious_link",
)
return None
def check_spam(
self, message: discord.Message, anti_spam_enabled: bool = True
) -> AutomodResult | None:
"""Check message for spam behavior."""
if not anti_spam_enabled:
return None
guild_id = message.guild.id
user_id = message.author.id
tracker = self._spam_trackers[guild_id][user_id]
now = datetime.now(timezone.utc)
# Cleanup old records
tracker.cleanup()
# Check message rate
content_hash = self._get_content_hash(message.content)
tracker.messages.append(SpamRecord(content_hash, now))
# Rate limit check
recent_window = now - timedelta(seconds=self.message_rate_window)
recent_messages = [m for m in tracker.messages if m.timestamp > recent_window]
if len(recent_messages) > self.message_rate_limit:
return AutomodResult(
should_delete=True,
should_timeout=True,
timeout_duration=60, # 1 minute timeout
reason=f"Sending messages too fast ({len(recent_messages)} in {self.message_rate_window}s)",
matched_filter="rate_limit",
)
# Duplicate message check
duplicate_count = sum(1 for m in tracker.messages if m.content_hash == content_hash)
if duplicate_count >= self.duplicate_threshold:
return AutomodResult(
should_delete=True,
should_warn=True,
reason=f"Duplicate message detected ({duplicate_count} times)",
matched_filter="duplicate",
)
# Mass mention check
mention_count = len(message.mentions) + len(message.role_mentions)
if message.mention_everyone:
mention_count += 100 # Treat @everyone as many mentions
if mention_count > self.mention_limit:
return AutomodResult(
should_delete=True,
should_timeout=True,
timeout_duration=300, # 5 minute timeout
reason=f"Mass mentions detected ({mention_count} mentions)",
matched_filter="mass_mention",
)
return None
def check_invite_links(self, content: str, allow_invites: bool = True) -> AutomodResult | None:
"""Check for Discord invite links."""
if allow_invites:
return None
invite_pattern = re.compile(
r"(?:https?://)?(?:www\.)?(?:discord\.(?:gg|io|me|li)|discordapp\.com/invite)/[\w-]+",
re.IGNORECASE,
)
if invite_pattern.search(content):
return AutomodResult(
should_delete=True,
reason="Discord invite links are not allowed",
matched_filter="invite_link",
)
return None
def check_all_caps(
self, content: str, threshold: float = 0.7, min_length: int = 10
) -> AutomodResult | None:
"""Check for excessive caps usage."""
# Only check messages with enough letters
letters = [c for c in content if c.isalpha()]
if len(letters) < min_length:
return None
caps_count = sum(1 for c in letters if c.isupper())
caps_ratio = caps_count / len(letters)
if caps_ratio > threshold:
return AutomodResult(
should_delete=True,
reason="Excessive caps usage",
matched_filter="caps",
)
return None
def reset_user_tracker(self, guild_id: int, user_id: int) -> None:
"""Reset spam tracking for a user."""
if guild_id in self._spam_trackers:
self._spam_trackers[guild_id].pop(user_id, None)
def cleanup_guild(self, guild_id: int) -> None:
"""Remove all tracking data for a guild."""
self._spam_trackers.pop(guild_id, None)