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

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for GuardDen."""

15
tests/conftest.py Normal file
View File

@@ -0,0 +1,15 @@
"""Pytest fixtures for GuardDen tests."""
import pytest
@pytest.fixture
def sample_guild_id() -> int:
"""Return a sample Discord guild ID."""
return 123456789012345678
@pytest.fixture
def sample_user_id() -> int:
"""Return a sample Discord user ID."""
return 987654321098765432

119
tests/test_ai.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests for AI services."""
import pytest
from guardden.services.ai.base import ContentCategory, ModerationResult
from guardden.services.ai.factory import NullProvider, create_ai_provider
class TestModerationResult:
"""Tests for ModerationResult dataclass."""
def test_severity_not_flagged(self) -> None:
"""Test severity is 0 when not flagged."""
result = ModerationResult(is_flagged=False, confidence=0.9)
assert result.severity == 0
def test_severity_with_confidence(self) -> None:
"""Test severity includes confidence."""
result = ModerationResult(
is_flagged=True,
confidence=0.8,
categories=[],
)
# 0.8 * 50 = 40
assert result.severity == 40
def test_severity_high_category(self) -> None:
"""Test severity with high-severity category."""
result = ModerationResult(
is_flagged=True,
confidence=0.5,
categories=[ContentCategory.HATE_SPEECH],
)
# 0.5 * 50 + 30 = 55
assert result.severity == 55
def test_severity_medium_category(self) -> None:
"""Test severity with medium-severity category."""
result = ModerationResult(
is_flagged=True,
confidence=0.5,
categories=[ContentCategory.HARASSMENT],
)
# 0.5 * 50 + 20 = 45
assert result.severity == 45
def test_severity_multiple_categories(self) -> None:
"""Test severity with multiple categories."""
result = ModerationResult(
is_flagged=True,
confidence=0.5,
categories=[ContentCategory.HATE_SPEECH, ContentCategory.VIOLENCE],
)
# 0.5 * 50 + 30 + 20 = 75
assert result.severity == 75
def test_severity_capped_at_100(self) -> None:
"""Test severity is capped at 100."""
result = ModerationResult(
is_flagged=True,
confidence=1.0,
categories=[
ContentCategory.HATE_SPEECH,
ContentCategory.SELF_HARM,
ContentCategory.SCAM,
],
)
# Would be 50 + 30 + 30 + 30 = 140, capped to 100
assert result.severity == 100
class TestNullProvider:
"""Tests for NullProvider."""
@pytest.fixture
def provider(self) -> NullProvider:
return NullProvider()
@pytest.mark.asyncio
async def test_moderate_text_returns_empty(self, provider: NullProvider) -> None:
"""Test moderate_text returns unflagged result."""
result = await provider.moderate_text("test content")
assert result.is_flagged is False
@pytest.mark.asyncio
async def test_analyze_image_returns_empty(self, provider: NullProvider) -> None:
"""Test analyze_image returns empty result."""
result = await provider.analyze_image("http://example.com/image.jpg")
assert result.is_nsfw is False
@pytest.mark.asyncio
async def test_analyze_phishing_returns_empty(self, provider: NullProvider) -> None:
"""Test analyze_phishing returns empty result."""
result = await provider.analyze_phishing("http://example.com")
assert result.is_phishing is False
class TestFactory:
"""Tests for AI provider factory."""
def test_create_null_provider(self) -> None:
"""Test creating null provider."""
provider = create_ai_provider("none")
assert isinstance(provider, NullProvider)
def test_create_anthropic_without_key(self) -> None:
"""Test creating anthropic provider without key raises error."""
with pytest.raises(ValueError, match="API key required"):
create_ai_provider("anthropic", None)
def test_create_openai_without_key(self) -> None:
"""Test creating openai provider without key raises error."""
with pytest.raises(ValueError, match="API key required"):
create_ai_provider("openai", None)
def test_create_unknown_provider(self) -> None:
"""Test creating unknown provider raises error."""
with pytest.raises(ValueError, match="Unknown AI provider"):
create_ai_provider("unknown", "key") # type: ignore

153
tests/test_automod.py Normal file
View File

@@ -0,0 +1,153 @@
"""Tests for the automod service."""
import pytest
from guardden.models import BannedWord
from guardden.services.automod import AutomodService
@pytest.fixture
def automod() -> AutomodService:
"""Create an automod service instance."""
return AutomodService()
class TestBannedWords:
"""Tests for banned word filtering."""
def test_simple_match(self, automod: AutomodService) -> None:
"""Test simple text matching."""
banned = [_make_banned_word("badword")]
result = automod.check_banned_words("This contains badword in it", banned)
assert result is not None
assert result.should_delete
def test_case_insensitive(self, automod: AutomodService) -> None:
"""Test case insensitive matching."""
banned = [_make_banned_word("BadWord")]
result = automod.check_banned_words("this contains BADWORD here", banned)
assert result is not None
def test_no_match(self, automod: AutomodService) -> None:
"""Test no match returns None."""
banned = [_make_banned_word("badword")]
result = automod.check_banned_words("This is a clean message", banned)
assert result is None
def test_regex_pattern(self, automod: AutomodService) -> None:
"""Test regex pattern matching."""
banned = [_make_banned_word(r"bad\w+", is_regex=True)]
result = automod.check_banned_words("This is badword and badstuff", banned)
assert result is not None
def test_action_warn(self, automod: AutomodService) -> None:
"""Test warn action is set."""
banned = [_make_banned_word("badword", action="warn")]
result = automod.check_banned_words("badword", banned)
assert result is not None
assert result.should_warn
def test_action_strike(self, automod: AutomodService) -> None:
"""Test strike action is set."""
banned = [_make_banned_word("badword", action="strike")]
result = automod.check_banned_words("badword", banned)
assert result is not None
assert result.should_strike
class TestScamDetection:
"""Tests for scam/phishing detection."""
def test_discord_nitro_scam(self, automod: AutomodService) -> None:
"""Test detection of fake Discord Nitro links."""
result = automod.check_scam_links("Free nitro at discord-nitro.gift")
assert result is not None
assert result.should_delete
def test_steam_scam(self, automod: AutomodService) -> None:
"""Test detection of Steam scam patterns."""
result = automod.check_scam_links("Check out this steam-community-giveaway.xyz")
assert result is not None
def test_legitimate_discord_link(self, automod: AutomodService) -> None:
"""Test that legitimate Discord links pass."""
result = automod.check_scam_links("Join us at discord.gg/example")
assert result is None
def test_suspicious_tld_with_keyword(self, automod: AutomodService) -> None:
"""Test suspicious TLD with impersonation keyword."""
result = automod.check_scam_links("Visit discord-verify.xyz to claim")
assert result is not None
def test_normal_url(self, automod: AutomodService) -> None:
"""Test normal URLs pass."""
result = automod.check_scam_links("Check out https://github.com/example")
assert result is None
class TestInviteLinks:
"""Tests for Discord invite link detection."""
def test_discord_gg_invite(self, automod: AutomodService) -> None:
"""Test discord.gg invite detection."""
result = automod.check_invite_links("Join discord.gg/example", allow_invites=False)
assert result is not None
assert result.should_delete
def test_discordapp_invite(self, automod: AutomodService) -> None:
"""Test discordapp.com invite detection."""
result = automod.check_invite_links(
"Join https://discordapp.com/invite/abc123", allow_invites=False
)
assert result is not None
def test_allowed_invites(self, automod: AutomodService) -> None:
"""Test invites pass when allowed."""
result = automod.check_invite_links("Join discord.gg/example", allow_invites=True)
assert result is None
class TestCapsDetection:
"""Tests for excessive caps detection."""
def test_excessive_caps(self, automod: AutomodService) -> None:
"""Test detection of all caps message."""
result = automod.check_all_caps("THIS IS ALL CAPS MESSAGE HERE")
assert result is not None
def test_normal_caps(self, automod: AutomodService) -> None:
"""Test normal message passes."""
result = automod.check_all_caps("This is a Normal Message with Some Caps")
assert result is None
def test_short_message_ignored(self, automod: AutomodService) -> None:
"""Test short messages are ignored."""
result = automod.check_all_caps("HI THERE")
assert result is None
class MockBannedWord:
"""Mock BannedWord for testing without database."""
def __init__(
self,
pattern: str,
is_regex: bool = False,
action: str = "delete",
) -> None:
self.id = 1
self.guild_id = 123
self.pattern = pattern
self.is_regex = is_regex
self.action = action
self.reason = None
self.added_by = 456
def _make_banned_word(
pattern: str,
is_regex: bool = False,
action: str = "delete",
) -> MockBannedWord:
"""Create a mock BannedWord object for testing."""
return MockBannedWord(pattern, is_regex, action)

130
tests/test_ratelimit.py Normal file
View File

@@ -0,0 +1,130 @@
"""Tests for rate limiting service."""
import pytest
from guardden.services.ratelimit import (
RateLimitBucket,
RateLimitConfig,
RateLimiter,
RateLimitScope,
)
class TestRateLimitBucket:
"""Tests for RateLimitBucket."""
def test_not_limited_initially(self) -> None:
"""Test bucket is not limited when empty."""
bucket = RateLimitBucket(max_requests=3, window_seconds=60)
assert bucket.is_limited() is False
assert bucket.remaining() == 3
def test_limited_after_max_requests(self) -> None:
"""Test bucket is limited after max requests."""
bucket = RateLimitBucket(max_requests=3, window_seconds=60)
for _ in range(3):
bucket.record()
assert bucket.is_limited() is True
assert bucket.remaining() == 0
def test_remaining_decreases(self) -> None:
"""Test remaining count decreases."""
bucket = RateLimitBucket(max_requests=5, window_seconds=60)
bucket.record()
assert bucket.remaining() == 4
bucket.record()
assert bucket.remaining() == 3
class TestRateLimiter:
"""Tests for RateLimiter."""
@pytest.fixture
def limiter(self) -> RateLimiter:
return RateLimiter()
def test_check_not_limited(self, limiter: RateLimiter) -> None:
"""Test check returns not limited for new bucket."""
result = limiter.check("command", user_id=123, guild_id=456)
assert result.is_limited is False
def test_acquire_records_request(self, limiter: RateLimiter) -> None:
"""Test acquire records the request."""
# Configure a simple limit
limiter.configure(
"test",
RateLimitConfig(max_requests=2, window_seconds=60, scope=RateLimitScope.USER),
)
result1 = limiter.acquire("test", user_id=123)
assert result1.is_limited is False
assert result1.remaining == 1
result2 = limiter.acquire("test", user_id=123)
assert result2.is_limited is False
assert result2.remaining == 0
result3 = limiter.acquire("test", user_id=123)
assert result3.is_limited is True
def test_different_scopes(self, limiter: RateLimiter) -> None:
"""Test different scopes create different buckets."""
# User scope - shared across guilds
limiter.configure(
"user_action",
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.USER),
)
limiter.acquire("user_action", user_id=123, guild_id=1)
result = limiter.acquire("user_action", user_id=123, guild_id=2)
assert result.is_limited is True # Same user, different guild
# Member scope - per guild
limiter.configure(
"member_action",
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.MEMBER),
)
limiter.acquire("member_action", user_id=456, guild_id=1)
result = limiter.acquire("member_action", user_id=456, guild_id=2)
assert result.is_limited is False # Same user, different guild = different bucket
def test_reset(self, limiter: RateLimiter) -> None:
"""Test resetting a bucket."""
limiter.configure(
"test",
RateLimitConfig(max_requests=1, window_seconds=60, scope=RateLimitScope.USER),
)
limiter.acquire("test", user_id=123)
assert limiter.acquire("test", user_id=123).is_limited is True
limiter.reset("test", user_id=123)
assert limiter.acquire("test", user_id=123).is_limited is False
def test_unknown_action(self, limiter: RateLimiter) -> None:
"""Test unknown action returns not limited."""
result = limiter.acquire("unknown_action", user_id=123)
assert result.is_limited is False
assert result.remaining == 999
def test_guild_scope(self, limiter: RateLimiter) -> None:
"""Test guild-scoped rate limiting."""
limiter.configure(
"guild_action",
RateLimitConfig(max_requests=2, window_seconds=60, scope=RateLimitScope.GUILD),
)
# Different users in same guild share the limit
limiter.acquire("guild_action", user_id=1, guild_id=100)
limiter.acquire("guild_action", user_id=2, guild_id=100)
result = limiter.acquire("guild_action", user_id=3, guild_id=100)
assert result.is_limited is True
# Different guild is not limited
result = limiter.acquire("guild_action", user_id=1, guild_id=200)
assert result.is_limited is False

48
tests/test_utils.py Normal file
View File

@@ -0,0 +1,48 @@
"""Tests for utility functions."""
from datetime import timedelta
import pytest
from guardden.cogs.moderation import parse_duration
class TestParseDuration:
"""Tests for the parse_duration function."""
def test_parse_seconds(self) -> None:
"""Test parsing seconds."""
assert parse_duration("30s") == timedelta(seconds=30)
assert parse_duration("1s") == timedelta(seconds=1)
def test_parse_minutes(self) -> None:
"""Test parsing minutes."""
assert parse_duration("5m") == timedelta(minutes=5)
assert parse_duration("30m") == timedelta(minutes=30)
def test_parse_hours(self) -> None:
"""Test parsing hours."""
assert parse_duration("1h") == timedelta(hours=1)
assert parse_duration("24h") == timedelta(hours=24)
def test_parse_days(self) -> None:
"""Test parsing days."""
assert parse_duration("7d") == timedelta(days=7)
assert parse_duration("1d") == timedelta(days=1)
def test_parse_weeks(self) -> None:
"""Test parsing weeks."""
assert parse_duration("2w") == timedelta(weeks=2)
assert parse_duration("1w") == timedelta(weeks=1)
def test_invalid_format(self) -> None:
"""Test invalid duration formats."""
assert parse_duration("invalid") is None
assert parse_duration("") is None
assert parse_duration("10") is None
assert parse_duration("abc") is None
def test_case_insensitive(self) -> None:
"""Test that parsing is case insensitive."""
assert parse_duration("1H") == timedelta(hours=1)
assert parse_duration("30M") == timedelta(minutes=30)

142
tests/test_verification.py Normal file
View File

@@ -0,0 +1,142 @@
"""Tests for verification service."""
import pytest
from guardden.services.verification import (
ButtonChallengeGenerator,
CaptchaChallengeGenerator,
ChallengeType,
EmojiChallengeGenerator,
MathChallengeGenerator,
VerificationService,
)
class TestChallengeGenerators:
"""Tests for challenge generators."""
def test_button_challenge(self) -> None:
"""Test button challenge generation."""
gen = ButtonChallengeGenerator()
challenge = gen.generate()
assert challenge.challenge_type == ChallengeType.BUTTON
assert challenge.answer == "verified"
def test_captcha_challenge(self) -> None:
"""Test captcha challenge generation."""
gen = CaptchaChallengeGenerator(length=6)
challenge = gen.generate()
assert challenge.challenge_type == ChallengeType.CAPTCHA
assert len(challenge.answer) == 6
assert challenge.answer.isalnum()
def test_math_challenge(self) -> None:
"""Test math challenge generation."""
gen = MathChallengeGenerator()
challenge = gen.generate()
assert challenge.challenge_type == ChallengeType.MATH
# Answer should be a number
assert challenge.answer.lstrip("-").isdigit()
def test_emoji_challenge(self) -> None:
"""Test emoji challenge generation."""
gen = EmojiChallengeGenerator()
challenge = gen.generate()
assert challenge.challenge_type == ChallengeType.EMOJI
assert len(challenge.options) > 0
assert challenge.answer in challenge.options
class TestVerificationService:
"""Tests for verification service."""
@pytest.fixture
def service(self) -> VerificationService:
return VerificationService()
def test_create_challenge(self, service: VerificationService) -> None:
"""Test creating a challenge."""
pending = service.create_challenge(
user_id=123,
guild_id=456,
challenge_type=ChallengeType.BUTTON,
)
assert pending.user_id == 123
assert pending.guild_id == 456
assert pending.challenge.challenge_type == ChallengeType.BUTTON
def test_get_pending(self, service: VerificationService) -> None:
"""Test retrieving pending verification."""
service.create_challenge(123, 456, ChallengeType.BUTTON)
pending = service.get_pending(456, 123)
assert pending is not None
assert pending.user_id == 123
# Non-existent should return None
assert service.get_pending(456, 999) is None
def test_verify_correct(self, service: VerificationService) -> None:
"""Test successful verification."""
service.create_challenge(123, 456, ChallengeType.BUTTON)
success, message = service.verify(456, 123, "verified")
assert success is True
assert "successful" in message.lower()
# Should be removed after success
assert service.get_pending(456, 123) is None
def test_verify_incorrect(self, service: VerificationService) -> None:
"""Test failed verification."""
service.create_challenge(123, 456, ChallengeType.BUTTON)
success, message = service.verify(456, 123, "wrong")
assert success is False
assert "incorrect" in message.lower()
# Should still exist
assert service.get_pending(456, 123) is not None
def test_verify_max_attempts(self, service: VerificationService) -> None:
"""Test max attempts exceeded."""
service.create_challenge(123, 456, ChallengeType.BUTTON)
# Use up all attempts
for _ in range(3):
service.verify(456, 123, "wrong")
success, message = service.verify(456, 123, "verified")
assert success is False
assert "too many" in message.lower()
def test_verify_no_pending(self, service: VerificationService) -> None:
"""Test verification with no pending challenge."""
success, message = service.verify(456, 123, "verified")
assert success is False
assert "no pending" in message.lower()
def test_cancel(self, service: VerificationService) -> None:
"""Test canceling verification."""
service.create_challenge(123, 456, ChallengeType.BUTTON)
assert service.cancel(456, 123) is True
assert service.get_pending(456, 123) is None
# Cancel non-existent returns False
assert service.cancel(456, 123) is False
def test_pending_count(self, service: VerificationService) -> None:
"""Test pending count per guild."""
service.create_challenge(1, 456, ChallengeType.BUTTON)
service.create_challenge(2, 456, ChallengeType.BUTTON)
service.create_challenge(3, 789, ChallengeType.BUTTON)
assert service.get_pending_count(456) == 2
assert service.get_pending_count(789) == 1
assert service.get_pending_count(999) == 0