dev #11
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,6 +37,9 @@ env/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Configuration (contains sensitive data)
|
||||
config.yml
|
||||
|
||||
# Data
|
||||
data/
|
||||
*.db
|
||||
|
||||
74
CLAUDE.md
74
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
GuardDen is a Discord moderation bot built with discord.py, PostgreSQL, and optional AI integration (Claude/OpenAI). Self-hosted with Docker support.
|
||||
GuardDen is a minimal, cost-conscious Discord moderation bot focused on spam detection and NSFW image filtering. Built with discord.py, PostgreSQL, and optional AI integration (Claude/OpenAI) for image analysis only. Self-hosted with Docker support.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -19,7 +19,7 @@ python -m guardden
|
||||
pytest
|
||||
|
||||
# Run single test
|
||||
pytest tests/test_verification.py::TestVerificationService::test_verify_correct
|
||||
pytest tests/test_automod.py::TestAutomodService::test_spam_detection
|
||||
|
||||
# Lint and format
|
||||
ruff check src tests
|
||||
@@ -30,73 +30,48 @@ mypy src
|
||||
|
||||
# Docker deployment
|
||||
docker compose up -d
|
||||
|
||||
# Database migrations
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/guardden/bot.py` - Main bot class (`GuardDen`) extending `commands.Bot`, manages lifecycle and services
|
||||
- `src/guardden/config.py` - Pydantic settings loaded from environment variables (prefix: `GUARDDEN_`)
|
||||
- `src/guardden/models/` - SQLAlchemy 2.0 async models for PostgreSQL
|
||||
- `src/guardden/services/` - Business logic (database, guild config, automod, AI, verification, rate limiting)
|
||||
- `src/guardden/cogs/` - Discord command groups (events, moderation, admin, automod, ai_moderation, verification)
|
||||
- `src/guardden/models/guild.py` - SQLAlchemy 2.0 async models for guilds and settings
|
||||
- `src/guardden/services/` - Business logic (database, guild config, automod, AI, rate limiting)
|
||||
- `src/guardden/cogs/` - Discord command groups (automod, ai_moderation, owner)
|
||||
- `config.yml` - Single YAML file for bot configuration
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- All database operations use async SQLAlchemy with `asyncpg`
|
||||
- Guild configurations are cached in `GuildConfigService._cache`
|
||||
- Guild configurations loaded from single `config.yml` file (not per-guild)
|
||||
- Discord snowflake IDs stored as `BigInteger` in PostgreSQL
|
||||
- Moderation actions logged to `ModerationLog` table with automatic strike escalation
|
||||
- Environment variables: `GUARDDEN_DISCORD_TOKEN`, `GUARDDEN_DATABASE_URL`
|
||||
- No moderation logging or strike system
|
||||
- Environment variables: `GUARDDEN_DISCORD_TOKEN`, `GUARDDEN_DATABASE_URL`, AI keys
|
||||
|
||||
## Automod System
|
||||
|
||||
- `AutomodService` in `services/automod.py` handles rule-based content filtering
|
||||
- Checks run in order: banned words → scam links → spam → invite links
|
||||
- `AutomodService` in `services/automod.py` handles spam detection
|
||||
- Checks: message rate limit → duplicate messages → mass mentions
|
||||
- Spam tracking uses per-guild, per-user trackers with automatic cleanup
|
||||
- Scam detection uses compiled regex patterns in `SCAM_PATTERNS` list
|
||||
- Results return `AutomodResult` dataclass with actions to take
|
||||
- **Whitelist**: Users in `GuildSettings.whitelisted_user_ids` bypass ALL automod checks
|
||||
- Users with "Manage Messages" permission also bypass automod
|
||||
- Everyone gets moderated (no whitelist, no bypass for permissions)
|
||||
|
||||
## AI Moderation System
|
||||
|
||||
- `services/ai/` contains provider abstraction and implementations
|
||||
- `AIProvider` base class defines interface: `moderate_text()`, `analyze_image()`, `analyze_phishing()`
|
||||
- `AIProvider` base class defines interface: `analyze_image()` only
|
||||
- `AnthropicProvider` and `OpenAIProvider` implement the interface
|
||||
- `NullProvider` used when AI is disabled (returns empty results)
|
||||
- Factory pattern via `create_ai_provider(provider, api_key)`
|
||||
- `ModerationResult` includes severity scoring based on confidence + category weights
|
||||
- Sensitivity setting (0-100) adjusts thresholds per guild
|
||||
- **NSFW-Only Filtering** (default: `True`): When enabled, only sexual content is filtered; violence, harassment, etc. are allowed
|
||||
- Filtering controlled by `nsfw_only_filtering` field in `GuildSettings`
|
||||
- **Whitelist**: Users in `GuildSettings.whitelisted_user_ids` bypass ALL AI moderation checks
|
||||
|
||||
## Verification System
|
||||
|
||||
- `VerificationService` in `services/verification.py` manages challenges
|
||||
- Challenge types: button, captcha, math, emoji (via `ChallengeGenerator` classes)
|
||||
- `PendingVerification` tracks user challenges with expiry and attempt limits
|
||||
- Discord UI components in `cogs/verification.py`: `VerifyButton`, `EmojiButton`, `CaptchaModal`
|
||||
- Background task cleans up expired verifications every 5 minutes
|
||||
|
||||
## Rate Limiting System
|
||||
|
||||
- `RateLimiter` in `services/ratelimit.py` provides general-purpose rate limiting
|
||||
- Scopes: USER (global), MEMBER (per-guild), CHANNEL, GUILD
|
||||
- `@ratelimit()` decorator for easy command rate limiting
|
||||
- `get_rate_limiter()` returns singleton instance
|
||||
- Default limits configured for commands, moderation, verification, messages
|
||||
|
||||
## Notification System
|
||||
|
||||
- `utils/notifications.py` contains `send_moderation_notification()` utility
|
||||
- Handles sending moderation warnings to users with DM → in-channel fallback
|
||||
- **In-Channel Warnings** (default: `False`): Optional PUBLIC channel messages when DMs fail
|
||||
- **IMPORTANT**: In-channel messages are PUBLIC, visible to all users (Discord API limitation)
|
||||
- Temporary messages auto-delete after 10 seconds to minimize clutter
|
||||
- Used by automod, AI moderation, and manual moderation commands
|
||||
- Controlled by `send_in_channel_warnings` field in `GuildSettings`
|
||||
- Disabled by default for privacy reasons
|
||||
- `ImageAnalysisResult` includes NSFW categories, severity, confidence
|
||||
- Sensitivity setting (0-100) adjusts thresholds
|
||||
- **NSFW-Only Filtering** (default: `True`): Only sexual content is filtered
|
||||
- **Cost Controls**: Rate limiting, deduplication, file size limits, max images per message
|
||||
- `AIRateLimiter` in `services/ai_rate_limiter.py` tracks usage
|
||||
|
||||
## Adding New Cogs
|
||||
|
||||
@@ -110,10 +85,3 @@ docker compose up -d
|
||||
2. Implement `AIProvider` abstract class
|
||||
3. Add to factory in `services/ai/factory.py`
|
||||
4. Add config option in `config.py`
|
||||
|
||||
## Adding New Challenge Type
|
||||
|
||||
1. Create new `ChallengeGenerator` subclass in `services/verification.py`
|
||||
2. Add to `ChallengeType` enum
|
||||
3. Register in `VerificationService._generators`
|
||||
4. Create corresponding UI components in `cogs/verification.py` if needed
|
||||
|
||||
867
README.md
867
README.md
@@ -1,652 +1,453 @@
|
||||
# GuardDen
|
||||
|
||||
GuardDen is a comprehensive Discord moderation bot designed to protect your community while maintaining a warm, welcoming environment. Built with privacy and self-hosting in mind, GuardDen combines AI-powered content filtering with traditional moderation tools to create a safe space for your members.
|
||||
A lightweight, cost-conscious Discord moderation bot focused on automated protection against spam and NSFW content. Built for self-hosting with minimal resource usage and predictable AI costs.
|
||||
|
||||
## Overview
|
||||
|
||||
GuardDen is a minimal Discord bot designed for small to medium servers (1-2 guilds) that need automated moderation without the complexity of full-featured moderation systems. It focuses on two core areas:
|
||||
|
||||
1. **Spam Detection** - Automatic rate limiting, duplicate detection, and mass mention protection
|
||||
2. **NSFW Content Filtering** - AI-powered image analysis with aggressive cost controls
|
||||
|
||||
**What GuardDen is NOT:**
|
||||
- Not a full moderation suite (no manual mod commands, logging, or strike systems)
|
||||
- Not a verification/captcha system
|
||||
- Not a chat moderation bot (no text analysis, banned words, or scam detection)
|
||||
|
||||
**Target Users:**
|
||||
- Small community servers that need automated spam + NSFW protection
|
||||
- Budget-conscious server owners (~$5-25/month AI costs)
|
||||
- Self-hosters who want a simple, maintainable bot
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Moderation
|
||||
- **Warn, Kick, Ban, Timeout** - Standard moderation commands with logging
|
||||
- **Strike System** - Configurable point-based system with automatic escalation
|
||||
- **Moderation History** - Track all actions taken against users
|
||||
- **Bulk Message Deletion** - Purge up to 100 messages at once
|
||||
| Feature | Description | Cost |
|
||||
|---------|-------------|------|
|
||||
| **Spam Detection** | Rate limiting, duplicate messages, mass mentions | Free |
|
||||
| **NSFW Image Detection** | AI-powered analysis of images/GIFs using Claude or GPT | ~$5-25/month |
|
||||
| **User Blocklist** | Block ALL media from specific users instantly | Free |
|
||||
| **NSFW Domain Blocking** | Instant blocking of known NSFW video domains | Free |
|
||||
| **Cost Controls** | Rate limits, deduplication, file size limits | Built-in |
|
||||
| **Single Config File** | One YAML file for all settings | Easy |
|
||||
| **Owner Commands** | Status, reload, ping | Free |
|
||||
|
||||
### Automod
|
||||
- **Banned Words Filter** - Block words/phrases with regex support
|
||||
- **Scam Detection** - Automatic detection of phishing/scam links
|
||||
- **Anti-Spam** - Rate limiting, duplicate detection, mass mention protection
|
||||
- **Link Filtering** - Block Discord invites and suspicious URLs
|
||||
### Spam Detection
|
||||
|
||||
### AI Moderation
|
||||
- **Text Analysis** - AI-powered content moderation using Claude or GPT
|
||||
- **NSFW Image Detection** - Automatic flagging of inappropriate images
|
||||
- **NSFW-Only Filtering** - Enabled by default - only filters sexual content, allows violence/harassment
|
||||
- **Phishing Analysis** - AI-enhanced detection of scam URLs
|
||||
- **Configurable Sensitivity** - Adjust strictness per server (0-100)
|
||||
- **Public In-Channel Warnings** - Optional: sends temporary public channel messages when users have DMs disabled
|
||||
Automatically detects and deletes spam messages based on:
|
||||
- **Message Rate Limiting**: Max 5 messages per 5 seconds (configurable)
|
||||
- **Duplicate Detection**: Flags repeated identical messages
|
||||
- **Mass Mentions**: Limits @mentions per message and per time window
|
||||
- **Actions**: Deletes message, no notifications to user
|
||||
|
||||
### Verification System
|
||||
- **Multiple Challenge Types** - Button, captcha, math problems, emoji selection
|
||||
- **Automatic New Member Verification** - Challenge users on join
|
||||
- **Configurable Verified Role** - Auto-assign role on successful verification
|
||||
- **Rate Limited** - Prevents verification spam
|
||||
### NSFW Image Detection
|
||||
|
||||
### Logging
|
||||
- Member joins/leaves
|
||||
- Message edits and deletions
|
||||
- Voice channel activity
|
||||
- Ban/unban events
|
||||
- All moderation actions
|
||||
AI-powered analysis of images and GIFs with strict cost controls:
|
||||
- **Supported Providers**: Anthropic Claude, OpenAI GPT
|
||||
- **Content Types**: Image attachments, Discord GIF embeds (optional)
|
||||
- **NSFW Categories**: Suggestive, Partial Nudity, Nudity, Explicit
|
||||
- **Filtering Mode**: NSFW-only by default (only blocks sexual content)
|
||||
- **Cost Controls**:
|
||||
- 25 AI checks/hour/guild (default)
|
||||
- 5 AI checks/hour/user (default)
|
||||
- Image deduplication (tracks 1000 recent messages)
|
||||
- File size limit (skip > 3MB)
|
||||
- Max images per message (2 by default)
|
||||
- **Actions**: Deletes message, no notifications to user
|
||||
|
||||
### User Blocklist
|
||||
|
||||
Instantly delete ALL media from specific users:
|
||||
- **Blocks**: Images, GIFs, embeds, URLs
|
||||
- **No AI Cost**: Instant deletion without analysis
|
||||
- **Use Case**: Known problematic users, spam accounts
|
||||
|
||||
### NSFW Domain Blocking
|
||||
|
||||
Pre-configured list of known NSFW video domains:
|
||||
- Blocks: pornhub.com, xvideos.com, xnxx.com, etc.
|
||||
- **No AI Cost**: Pattern matching only
|
||||
- **Instant**: Deletes message immediately
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- Discord Bot Token (see setup below)
|
||||
- (Optional) Anthropic or OpenAI API key for AI features
|
||||
|
||||
### Discord Bot Setup
|
||||
| Requirement | Version | Purpose |
|
||||
|-------------|---------|---------|
|
||||
| Python | 3.11+ | Bot runtime |
|
||||
| PostgreSQL | 15+ | Database |
|
||||
| Discord Bot Token | - | Bot authentication |
|
||||
| AI API Key | (Optional) | Claude or OpenAI for NSFW detection |
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Click **New Application** and give it a name (e.g., "GuardDen")
|
||||
3. Go to the **Bot** tab and click **Add Bot**
|
||||
### 1. Discord Bot Setup
|
||||
|
||||
4. **Configure Bot Settings:**
|
||||
- Disable **Public Bot** if you only want yourself to add it
|
||||
- Copy the **Token** (click "Reset Token") - this is your `GUARDDEN_DISCORD_TOKEN`
|
||||
1. **Create Application**
|
||||
- Go to [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
- Click **New Application** → Name it (e.g., "GuardDen")
|
||||
- Go to **Bot** tab → **Add Bot**
|
||||
|
||||
5. **Enable Privileged Gateway Intents** (all three required):
|
||||
- **Presence Intent** - for user status tracking
|
||||
- **Server Members Intent** - for member join/leave events, verification
|
||||
- **Message Content Intent** - for reading messages (automod, AI moderation)
|
||||
2. **Get Bot Token**
|
||||
- Click **Reset Token** → Copy the token
|
||||
- Save as `GUARDDEN_DISCORD_TOKEN` in `.env`
|
||||
|
||||
6. **Generate Invite URL** - Go to **OAuth2** > **URL Generator**:
|
||||
3. **Enable Intents**
|
||||
- Enable **Message Content Intent** (required for reading messages)
|
||||
|
||||
**Scopes:**
|
||||
- `bot`
|
||||
- `applications.commands`
|
||||
|
||||
**Bot Permissions:**
|
||||
- Manage Roles
|
||||
- Kick Members
|
||||
- Ban Members
|
||||
4. **Generate Invite URL**
|
||||
- Go to **OAuth2** → **URL Generator**
|
||||
- Select scopes: `bot`
|
||||
- Select permissions:
|
||||
- Moderate Members (timeout)
|
||||
- Manage Channels
|
||||
- View Channels
|
||||
- Send Messages
|
||||
- Manage Messages
|
||||
- Embed Links
|
||||
- Attach Files
|
||||
- Read Message History
|
||||
- Add Reactions
|
||||
- Or use permission integer: `275415089216`
|
||||
- Copy generated URL and invite to your server
|
||||
|
||||
Or use permission integer: `1239943348294`
|
||||
### 2. Installation
|
||||
|
||||
7. Use the generated URL to invite the bot to your server
|
||||
**Option A: Docker (Recommended)**
|
||||
|
||||
### Docker Deployment (Recommended)
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.hiddenden.cafe/Hiddenden/GuardDen.git
|
||||
cd guardden
|
||||
```
|
||||
cd GuardDen
|
||||
|
||||
2. Create your environment file:
|
||||
```bash
|
||||
# Create configuration files
|
||||
cp .env.example .env
|
||||
# Edit .env and add your Discord token
|
||||
```
|
||||
cp config.example.yml config.yml
|
||||
|
||||
3. Start with Docker Compose:
|
||||
```bash
|
||||
# Edit .env - Add your Discord token
|
||||
nano .env
|
||||
|
||||
# Edit config.yml - Configure settings
|
||||
nano config.yml
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker logs guardden-bot -f
|
||||
```
|
||||
|
||||
### Local Development
|
||||
**Option B: Local Development**
|
||||
|
||||
1. Create a virtual environment:
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.hiddenden.cafe/Hiddenden/GuardDen.git
|
||||
cd GuardDen
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -e ".[dev,ai]"
|
||||
```
|
||||
|
||||
3. Set up environment variables:
|
||||
```bash
|
||||
# Create configuration files
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
cp config.example.yml config.yml
|
||||
|
||||
4. Start PostgreSQL (or use Docker):
|
||||
```bash
|
||||
# Edit configuration
|
||||
nano .env
|
||||
nano config.yml
|
||||
|
||||
# Start PostgreSQL (or use Docker)
|
||||
docker compose up db -d
|
||||
```
|
||||
|
||||
5. Run the bot:
|
||||
```bash
|
||||
# Run database migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start bot
|
||||
python -m guardden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
GuardDen now supports **file-based configuration** as the primary method for managing bot settings. This replaces Discord commands for configuration, providing better version control, easier management, and more reliable deployments.
|
||||
### Environment Variables (`.env`)
|
||||
|
||||
### File-Based Configuration (Recommended)
|
||||
| Variable | Required | Description | Default |
|
||||
|----------|----------|-------------|---------|
|
||||
| `GUARDDEN_DISCORD_TOKEN` | ✅ | Discord bot token | - |
|
||||
| `GUARDDEN_DATABASE_URL` | No | PostgreSQL connection URL | `postgresql://guardden:guardden@localhost:5432/guardden` |
|
||||
| `GUARDDEN_LOG_LEVEL` | No | Logging level (DEBUG/INFO/WARNING/ERROR) | `INFO` |
|
||||
| `GUARDDEN_AI_PROVIDER` | No | AI provider (`anthropic`/`openai`/`none`) | `none` |
|
||||
| `GUARDDEN_ANTHROPIC_API_KEY` | No* | Anthropic API key | - |
|
||||
| `GUARDDEN_OPENAI_API_KEY` | No* | OpenAI API key | - |
|
||||
|
||||
#### Directory Structure
|
||||
```
|
||||
config/
|
||||
├── guilds/
|
||||
│ ├── guild-123456789.yml # Per-server configuration
|
||||
│ ├── guild-987654321.yml
|
||||
│ └── default-template.yml # Template for new servers
|
||||
├── wordlists/
|
||||
│ ├── banned-words.yml # Custom banned words
|
||||
│ ├── domain-allowlists.yml # Allowed domains whitelist
|
||||
│ └── external-sources.yml # Managed wordlist sources
|
||||
├── schemas/
|
||||
│ ├── guild-schema.yml # Configuration validation
|
||||
│ └── wordlists-schema.yml
|
||||
└── templates/
|
||||
└── guild-default.yml # Default configuration template
|
||||
```
|
||||
*Required if `AI_PROVIDER` is set to `anthropic` or `openai`
|
||||
|
||||
#### Quick Start with File Configuration
|
||||
### Bot Configuration (`config.yml`)
|
||||
|
||||
1. **Create your first server configuration:**
|
||||
```bash
|
||||
python -m guardden.cli.config guild create 123456789012345678 "My Discord Server"
|
||||
```
|
||||
|
||||
2. **Edit the configuration file:**
|
||||
```bash
|
||||
nano config/guilds/guild-123456789012345678.yml
|
||||
```
|
||||
|
||||
3. **Customize settings (example):**
|
||||
```yaml
|
||||
# Basic server information
|
||||
guild_id: 123456789012345678
|
||||
name: "My Discord Server"
|
||||
# Bot Settings
|
||||
bot:
|
||||
prefix: "!"
|
||||
owner_ids:
|
||||
- 123456789012345678 # Your Discord user ID (for owner commands)
|
||||
|
||||
settings:
|
||||
# AI Moderation
|
||||
# Spam Detection
|
||||
automod:
|
||||
enabled: true
|
||||
anti_spam_enabled: true
|
||||
message_rate_limit: 5 # Max messages per window
|
||||
message_rate_window: 5 # Window in seconds
|
||||
duplicate_threshold: 3 # Duplicate messages to trigger
|
||||
mention_limit: 5 # Max mentions per message
|
||||
mention_rate_limit: 10 # Max mentions per window
|
||||
mention_rate_window: 60 # Mention window in seconds
|
||||
|
||||
# AI Moderation (NSFW Detection)
|
||||
ai_moderation:
|
||||
enabled: true
|
||||
sensitivity: 80 # 0-100 (higher = stricter)
|
||||
nsfw_only_filtering: true # Only block sexual content, allow violence
|
||||
nsfw_only_filtering: true # Only filter sexual content
|
||||
|
||||
# Automod settings
|
||||
automod:
|
||||
message_rate_limit: 5 # Max messages per 5 seconds
|
||||
scam_allowlist:
|
||||
- "discord.com"
|
||||
- "github.com"
|
||||
# Cost Controls
|
||||
max_checks_per_hour_per_guild: 25 # Conservative limit
|
||||
max_checks_per_user_per_hour: 5 # Prevent abuse
|
||||
max_images_per_message: 2 # Analyze max 2 images
|
||||
max_image_size_mb: 3 # Skip large files
|
||||
|
||||
# Feature Toggles
|
||||
check_embed_images: true # Check Discord GIFs
|
||||
check_video_thumbnails: false # Skip video thumbnails
|
||||
url_image_check_enabled: false # Skip URL downloads
|
||||
|
||||
# User Blocklist (instant deletion)
|
||||
blocked_user_ids:
|
||||
- 123456789012345678 # Discord user ID to block
|
||||
|
||||
# NSFW Domain Blocklist (instant blocking)
|
||||
nsfw_video_domains:
|
||||
- pornhub.com
|
||||
- xvideos.com
|
||||
- xnxx.com
|
||||
- redtube.com
|
||||
- youporn.com
|
||||
```
|
||||
|
||||
4. **Validate your configuration:**
|
||||
```bash
|
||||
python -m guardden.cli.config guild validate 123456789012345678
|
||||
```
|
||||
### Configuration Options Explained
|
||||
|
||||
5. **Start the bot** (configurations auto-reload):
|
||||
```bash
|
||||
python -m guardden
|
||||
```
|
||||
**Spam Detection:**
|
||||
- `message_rate_limit`: How many messages allowed in time window
|
||||
- `duplicate_threshold`: How many identical messages trigger spam detection
|
||||
- `mention_limit`: Max @mentions allowed per message
|
||||
|
||||
#### Configuration Management CLI
|
||||
**AI Moderation:**
|
||||
- `sensitivity`: Detection strictness (80 = balanced, 100 = very strict, 50 = lenient)
|
||||
- `nsfw_only_filtering`: `true` = only block sexual content (default), `false` = block all inappropriate content
|
||||
- `max_checks_per_hour_per_guild`: Hard limit on AI API calls per guild (cost control)
|
||||
- `max_checks_per_user_per_hour`: Per-user limit to prevent spam/abuse
|
||||
|
||||
**Guild Management:**
|
||||
```bash
|
||||
# List all configured servers
|
||||
python -m guardden.cli.config guild list
|
||||
**User Blocklist:**
|
||||
- Add Discord user IDs to instantly delete ALL their media
|
||||
- No AI cost - instant pattern matching
|
||||
- Useful for repeat offenders or spam bots
|
||||
|
||||
# Create new server configuration
|
||||
python -m guardden.cli.config guild create <guild_id> "Server Name"
|
||||
**Cost Estimation:**
|
||||
- Small server (< 100 users): ~$5-10/month
|
||||
- Medium server (100-500 users): ~$15-25/month
|
||||
- Large server (500+ users): Increase rate limits or disable embed checking
|
||||
|
||||
# Edit specific settings
|
||||
python -m guardden.cli.config guild edit <guild_id> ai_moderation.sensitivity 75
|
||||
python -m guardden.cli.config guild edit <guild_id> ai_moderation.nsfw_only_filtering true
|
||||
---
|
||||
|
||||
# Validate configurations
|
||||
python -m guardden.cli.config guild validate
|
||||
python -m guardden.cli.config guild validate <guild_id>
|
||||
|
||||
# Backup configuration
|
||||
python -m guardden.cli.config guild backup <guild_id>
|
||||
```
|
||||
|
||||
**Migration from Discord Commands:**
|
||||
```bash
|
||||
# Export existing Discord command settings to files
|
||||
python -m guardden.cli.config migrate from-database
|
||||
|
||||
# Verify migration was successful
|
||||
python -m guardden.cli.config migrate verify
|
||||
```
|
||||
|
||||
**Wordlist Management:**
|
||||
```bash
|
||||
# View wordlist status
|
||||
python -m guardden.cli.config wordlist info
|
||||
|
||||
# View available templates
|
||||
python -m guardden.cli.config template info
|
||||
```
|
||||
|
||||
#### Key Configuration Options
|
||||
|
||||
**AI Moderation Settings:**
|
||||
```yaml
|
||||
ai_moderation:
|
||||
enabled: true # Enable AI content analysis
|
||||
sensitivity: 80 # 0-100 scale (higher = stricter)
|
||||
confidence_threshold: 0.7 # 0.0-1.0 confidence required
|
||||
nsfw_only_filtering: true # true = only sexual content (DEFAULT), false = all content
|
||||
log_only: false # true = log only, false = take action
|
||||
|
||||
notifications:
|
||||
send_in_channel_warnings: false # Send temporary PUBLIC channel messages when DMs fail (DEFAULT: false)
|
||||
```
|
||||
|
||||
**NSFW-Only Filtering Guide (Default: Enabled):**
|
||||
- `true` = Only block sexual/nude content, allow violence and other content types **(DEFAULT)**
|
||||
- `false` = Block ALL inappropriate content (sexual, violence, harassment, hate speech)
|
||||
|
||||
**Public In-Channel Warnings (Default: Disabled):**
|
||||
- **IMPORTANT**: These messages are PUBLIC and visible to everyone in the channel, NOT private
|
||||
- When enabled and a user has DMs disabled, sends a temporary public message in the channel
|
||||
- Messages auto-delete after 10 seconds to minimize clutter
|
||||
- **Privacy Warning**: The user's violation and reason will be visible to all users for 10 seconds
|
||||
- Set to `true` only if you prefer public transparency over privacy
|
||||
|
||||
**Automod Configuration:**
|
||||
```yaml
|
||||
automod:
|
||||
message_rate_limit: 5 # Max messages per time window
|
||||
message_rate_window: 5 # Time window in seconds
|
||||
duplicate_threshold: 3 # Duplicate messages to trigger
|
||||
scam_allowlist: # Domains that bypass scam detection
|
||||
- "discord.com"
|
||||
- "github.com"
|
||||
```
|
||||
|
||||
**Banned Words Management:**
|
||||
Edit `config/wordlists/banned-words.yml`:
|
||||
```yaml
|
||||
global_patterns:
|
||||
- pattern: "badword"
|
||||
action: delete
|
||||
is_regex: false
|
||||
category: profanity
|
||||
|
||||
guild_patterns:
|
||||
123456789: # Specific server overrides
|
||||
- pattern: "server-specific-rule"
|
||||
action: warn
|
||||
override_global: false
|
||||
```
|
||||
|
||||
#### Hot-Reloading
|
||||
|
||||
Configuration changes are automatically detected and applied without restarting the bot:
|
||||
- ✅ Edit YAML files directly
|
||||
- ✅ Changes apply within seconds
|
||||
- ✅ Invalid configs are rejected with error logs
|
||||
- ✅ Automatic rollback on errors
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `GUARDDEN_DISCORD_TOKEN` | Your Discord bot token | Required |
|
||||
| `GUARDDEN_DISCORD_PREFIX` | Default command prefix | `!` |
|
||||
| `GUARDDEN_ALLOWED_GUILDS` | Comma-separated guild allowlist | (empty = all) |
|
||||
| `GUARDDEN_OWNER_IDS` | Comma-separated owner user IDs | (empty = admins) |
|
||||
| `GUARDDEN_DATABASE_URL` | PostgreSQL connection URL | `postgresql://guardden:guardden@localhost:5432/guardden` |
|
||||
| `GUARDDEN_LOG_LEVEL` | Logging level | `INFO` |
|
||||
| `GUARDDEN_AI_PROVIDER` | AI provider (anthropic/openai/none) | `none` |
|
||||
| `GUARDDEN_ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - |
|
||||
| `GUARDDEN_OPENAI_API_KEY` | OpenAI API key (if using GPT) | - |
|
||||
| `GUARDDEN_WORDLIST_ENABLED` | Enable managed wordlist sync | `true` |
|
||||
| `GUARDDEN_WORDLIST_UPDATE_HOURS` | Managed wordlist sync interval | `168` |
|
||||
| `GUARDDEN_WORDLIST_SOURCES` | JSON array of wordlist sources | (empty = defaults) |
|
||||
|
||||
### Per-Guild Settings
|
||||
|
||||
Each server can be configured via YAML files in `config/guilds/`:
|
||||
|
||||
**General Settings:**
|
||||
- Command prefix and locale
|
||||
- Channel IDs (log, moderation, welcome)
|
||||
- Role IDs (mute, verified, moderator)
|
||||
|
||||
**Content Moderation:**
|
||||
- AI moderation (enabled, sensitivity, NSFW-only mode)
|
||||
- Automod thresholds and rate limits
|
||||
- Banned words and domain allowlists
|
||||
- Strike system and escalation actions
|
||||
|
||||
**Member Verification:**
|
||||
- Verification challenges (button, captcha, math, emoji)
|
||||
- Auto-role assignment
|
||||
|
||||
**All settings support hot-reloading** - edit files and changes apply immediately!
|
||||
|
||||
## Commands
|
||||
|
||||
> **Note:** Configuration commands (`!config`, `!ai`, `!automod`, etc.) have been replaced with file-based configuration. See the [Configuration](#configuration) section above for managing settings via YAML files and the CLI tool.
|
||||
|
||||
### Moderation
|
||||
|
||||
| Command | Permission | Description |
|
||||
|---------|------------|-------------|
|
||||
| `!warn <user> [reason]` | Kick Members | Warn a user |
|
||||
| `!strike <user> [points] [reason]` | Kick Members | Add strikes to a user |
|
||||
| `!strikes <user>` | Kick Members | View user's strikes |
|
||||
| `!timeout <user> <duration> [reason]` | Moderate Members | Timeout a user (e.g., 1h, 30m, 7d) |
|
||||
| `!untimeout <user>` | Moderate Members | Remove timeout |
|
||||
| `!kick <user> [reason]` | Kick Members | Kick a user |
|
||||
| `!ban <user> [reason]` | Ban Members | Ban a user |
|
||||
| `!unban <user_id> [reason]` | Ban Members | Unban a user by ID |
|
||||
| `!purge <amount>` | Manage Messages | Delete multiple messages (max 100) |
|
||||
| `!modlogs <user>` | Kick Members | View moderation history |
|
||||
|
||||
### Configuration Management
|
||||
|
||||
Configuration is now managed via **YAML files** instead of Discord commands. Use the CLI tool:
|
||||
|
||||
```bash
|
||||
# Configuration Management CLI
|
||||
python -m guardden.cli.config guild create <guild_id> "Server Name"
|
||||
python -m guardden.cli.config guild list
|
||||
python -m guardden.cli.config guild edit <guild_id> <setting> <value>
|
||||
python -m guardden.cli.config guild validate [guild_id]
|
||||
|
||||
# Migration from old Discord commands
|
||||
python -m guardden.cli.config migrate from-database
|
||||
python -m guardden.cli.config migrate verify
|
||||
|
||||
# Wordlist management
|
||||
python -m guardden.cli.config wordlist info
|
||||
```
|
||||
|
||||
**Read-only Status Commands (Still Available):**
|
||||
## Owner Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!config` | View current configuration (read-only) |
|
||||
| `!ai` | View AI moderation settings (read-only) |
|
||||
| `!automod` | View automod status (read-only) |
|
||||
| `!bannedwords` | List banned words (read-only) |
|
||||
| `!status` | Show bot status (uptime, guilds, latency, AI provider) |
|
||||
| `!reload` | Reload all cogs (apply code changes without restart) |
|
||||
| `!ping` | Check bot latency |
|
||||
|
||||
**Configuration Examples:**
|
||||
**Note:** All configuration is done via `config.yml`. There are no in-Discord configuration commands.
|
||||
|
||||
```bash
|
||||
# Set AI sensitivity to 75 (0-100 scale)
|
||||
python -m guardden.cli.config guild edit 123456789 ai_moderation.sensitivity 75
|
||||
---
|
||||
|
||||
# Enable NSFW-only filtering (only block sexual content)
|
||||
python -m guardden.cli.config guild edit 123456789 ai_moderation.nsfw_only_filtering true
|
||||
## How It Works
|
||||
|
||||
# Add domain to scam allowlist
|
||||
Edit config/wordlists/domain-allowlists.yml
|
||||
### Detection Flow
|
||||
|
||||
# Add banned word pattern
|
||||
Edit config/wordlists/banned-words.yml
|
||||
```
|
||||
Message Received
|
||||
↓
|
||||
[1] User Blocklist Check (instant)
|
||||
↓ (if not blocked)
|
||||
[2] NSFW Domain Check (instant)
|
||||
↓ (if no NSFW domain)
|
||||
[3] Spam Detection (free)
|
||||
↓ (if not spam)
|
||||
[4] Has Images/Embeds?
|
||||
↓ (if yes)
|
||||
[5] AI Rate Limit Check
|
||||
↓ (if under limit)
|
||||
[6] Image Deduplication
|
||||
↓ (if not analyzed recently)
|
||||
[7] AI Analysis (cost)
|
||||
↓
|
||||
[8] Action: Delete if violation
|
||||
```
|
||||
|
||||
### Whitelist Management (Admin only)
|
||||
### Action Behavior
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!whitelist` | View all whitelisted users |
|
||||
| `!whitelist add @user` | Add a user to the whitelist (bypasses all moderation) |
|
||||
| `!whitelist remove @user` | Remove a user from the whitelist |
|
||||
| `!whitelist clear` | Clear the entire whitelist |
|
||||
When a violation is detected:
|
||||
- ✅ **Message deleted** immediately
|
||||
- ✅ **Action logged** to console/log file
|
||||
- ❌ **No DM sent** to user (silent)
|
||||
- ❌ **No timeout** applied (delete only)
|
||||
- ❌ **No moderation log** in Discord
|
||||
|
||||
**What is the whitelist?**
|
||||
- Whitelisted users bypass **ALL** moderation checks (automod and AI moderation)
|
||||
- Useful for trusted members, bots, or staff who need to post content that might trigger filters
|
||||
- Users with "Manage Messages" permission are already exempt from moderation
|
||||
### Cost Controls
|
||||
|
||||
### Diagnostics (Admin only)
|
||||
Multiple layers to keep AI costs predictable:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!health` | Check database and AI provider status |
|
||||
1. **User Blocklist** - Skip AI entirely for known bad actors
|
||||
2. **Domain Blocklist** - Skip AI for known NSFW domains
|
||||
3. **Rate Limiting** - Hard caps per guild and per user
|
||||
4. **Deduplication** - Don't re-analyze same message
|
||||
5. **File Size Limits** - Skip very large files
|
||||
6. **Max Images** - Limit images analyzed per message
|
||||
7. **Optional Features** - Disable embed checking to save costs
|
||||
|
||||
### Verification (Admin only)
|
||||
---
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!verify` | Request verification (for users) |
|
||||
| `!verify setup` | View verification setup status |
|
||||
| `!verify enable` | Enable verification for new members |
|
||||
| `!verify disable` | Disable verification |
|
||||
| `!verify role @role` | Set the verified role |
|
||||
| `!verify type <type>` | Set verification type (button/captcha/math/emoji) |
|
||||
| `!verify test [type]` | Test a verification challenge |
|
||||
| `!verify reset @user` | Reset verification for a user |
|
||||
## Development
|
||||
|
||||
## CI (Gitea Actions)
|
||||
|
||||
Workflows live under `.gitea/workflows/` and mirror the previous GitHub Actions
|
||||
pipeline for linting, tests, and Docker builds.
|
||||
|
||||
## Project Structure
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
guardden/
|
||||
├── src/guardden/
|
||||
│ ├── bot.py # Main bot class
|
||||
│ ├── config.py # Settings management
|
||||
│ ├── cogs/ # Discord command groups
|
||||
│ │ ├── admin.py # Configuration commands (read-only)
|
||||
│ │ ├── ai_moderation.py # AI-powered moderation
|
||||
│ │ ├── automod.py # Automatic moderation
|
||||
│ │ ├── events.py # Event logging
|
||||
│ │ ├── moderation.py # Moderation commands
|
||||
│ │ └── verification.py # Member verification
|
||||
│ ├── cogs/ # Discord command modules
|
||||
│ │ ├── automod.py # Spam detection
|
||||
│ │ ├── ai_moderation.py # NSFW image detection
|
||||
│ │ └── owner.py # Owner commands
|
||||
│ ├── models/ # Database models
|
||||
│ │ ├── guild.py # Guild settings, banned words
|
||||
│ │ └── moderation.py # Logs, strikes, notes
|
||||
│ │ └── guild.py # Guild settings
|
||||
│ ├── services/ # Business logic
|
||||
│ │ ├── ai/ # AI provider implementations
|
||||
│ │ ├── automod.py # Content filtering
|
||||
│ │ ├── database.py # DB connections
|
||||
│ │ ├── guild_config.py # Config caching
|
||||
│ │ ├── file_config.py # File-based configuration system
|
||||
│ │ ├── config_migration.py # Migration from DB to files
|
||||
│ │ ├── ratelimit.py # Rate limiting
|
||||
│ │ └── verification.py # Verification challenges
|
||||
│ └── cli/ # Command-line tools
|
||||
│ └── config.py # Configuration management CLI
|
||||
├── config/ # File-based configuration
|
||||
│ ├── guilds/ # Per-server configuration files
|
||||
│ ├── wordlists/ # Banned words and allowlists
|
||||
│ ├── schemas/ # Configuration validation schemas
|
||||
│ └── templates/ # Configuration templates
|
||||
│ │ ├── automod.py # Spam detection logic
|
||||
│ │ ├── config_loader.py # YAML config loading
|
||||
│ │ ├── ai_rate_limiter.py # Cost control
|
||||
│ │ └── database.py # DB connections
|
||||
│ └── __main__.py # Entry point
|
||||
├── config.yml # Bot configuration (not in git)
|
||||
├── config.example.yml # Configuration template
|
||||
├── .env # Environment variables (not in git)
|
||||
├── .env.example # Environment template
|
||||
├── tests/ # Test suite
|
||||
├── migrations/ # Database migrations
|
||||
├── docker-compose.yml # Docker deployment
|
||||
├── pyproject.toml # Dependencies
|
||||
├── README.md # This file
|
||||
└── MIGRATION.md # Migration guide for file-based config
|
||||
└── pyproject.toml # Dependencies
|
||||
```
|
||||
|
||||
## Verification System
|
||||
|
||||
GuardDen includes a verification system to protect your server from bots and raids.
|
||||
|
||||
### Challenge Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `button` | Simple button click (default, easiest) |
|
||||
| `captcha` | Text-based captcha code entry |
|
||||
| `math` | Solve a simple math problem |
|
||||
| `emoji` | Select the correct emoji from options |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a verified role in your server
|
||||
2. Configure the role permissions (verified members get full access)
|
||||
3. Set up verification:
|
||||
```
|
||||
!verify role @Verified
|
||||
!verify type captcha
|
||||
!verify enable
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. New member joins the server
|
||||
2. Bot sends verification challenge via DM (or channel if DMs disabled)
|
||||
3. Member completes the challenge
|
||||
4. Bot assigns the verified role
|
||||
5. Member gains access to the server
|
||||
|
||||
## AI Moderation
|
||||
|
||||
GuardDen supports AI-powered content moderation using either Anthropic's Claude or OpenAI's GPT models.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Set the AI provider in your environment:
|
||||
```bash
|
||||
GUARDDEN_AI_PROVIDER=anthropic # or "openai"
|
||||
GUARDDEN_ANTHROPIC_API_KEY=sk-ant-... # if using Claude
|
||||
GUARDDEN_OPENAI_API_KEY=sk-... # if using OpenAI
|
||||
```
|
||||
|
||||
2. Enable AI moderation per server:
|
||||
```
|
||||
!ai enable
|
||||
!ai sensitivity 50 # 0=lenient, 100=strict
|
||||
!ai nsfw true # Enable NSFW image detection
|
||||
```
|
||||
|
||||
### Content Categories
|
||||
|
||||
The AI analyzes content for:
|
||||
- **Harassment** - Personal attacks, bullying
|
||||
- **Hate Speech** - Discrimination, slurs
|
||||
- **Sexual Content** - Explicit material
|
||||
- **Violence** - Threats, graphic content
|
||||
- **Self-Harm** - Suicide/self-injury content
|
||||
- **Scams** - Phishing, fraud attempts
|
||||
- **Spam** - Promotional, low-quality content
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Messages are analyzed by the AI provider
|
||||
2. Results include confidence scores and severity ratings
|
||||
3. Actions are taken based on guild sensitivity settings
|
||||
4. All AI actions are logged to the mod log channel
|
||||
|
||||
### NSFW-Only Filtering Mode (Enabled by Default)
|
||||
|
||||
**Default Behavior:**
|
||||
GuardDen is configured to only filter sexual/NSFW content by default. This allows communities to have mature discussions about violence, politics, and controversial topics while still maintaining a "safe for work" environment.
|
||||
|
||||
**When enabled (DEFAULT):**
|
||||
- ✅ **Blocked:** Sexual content, nude images, explicit material
|
||||
- ❌ **Allowed:** Violence, harassment, hate speech, self-harm content
|
||||
|
||||
**When disabled (strict mode):**
|
||||
- ✅ **Blocked:** All inappropriate content categories
|
||||
|
||||
**To change to strict mode:**
|
||||
```yaml
|
||||
# Edit config/guilds/guild-<id>.yml
|
||||
ai_moderation:
|
||||
nsfw_only_filtering: false
|
||||
```
|
||||
|
||||
This default is useful for:
|
||||
- Gaming communities (violence in gaming discussions)
|
||||
- Mature discussion servers (politics, news)
|
||||
- Communities with specific content policies that allow violence but prohibit sexual material
|
||||
|
||||
### Public In-Channel Warnings (Disabled by Default)
|
||||
|
||||
**IMPORTANT PRIVACY NOTICE**: In-channel warnings are **PUBLIC** and visible to all users in the channel, NOT private messages. This is a Discord API limitation.
|
||||
|
||||
When enabled and users have DMs disabled, moderation warnings are sent as temporary public messages in the channel where the violation occurred.
|
||||
|
||||
**How it works:**
|
||||
1. Bot tries to DM the user about the violation
|
||||
2. If DM fails (user has DMs disabled):
|
||||
- If `send_in_channel_warnings: true`: Sends a **PUBLIC** temporary message in the channel mentioning the user
|
||||
- If `send_in_channel_warnings: false` (DEFAULT): Silent failure, no notification sent
|
||||
- Message includes violation reason and any timeout information
|
||||
- Message auto-deletes after 10 seconds
|
||||
3. If DM succeeds, no channel message is sent
|
||||
|
||||
**To enable in-channel warnings:**
|
||||
```yaml
|
||||
# Edit config/guilds/guild-<id>.yml
|
||||
notifications:
|
||||
send_in_channel_warnings: true
|
||||
```
|
||||
|
||||
**Considerations:**
|
||||
|
||||
**Pros:**
|
||||
- Users are always notified of moderation actions, even with DMs disabled
|
||||
- Public transparency about what content is not allowed
|
||||
- Educational for other members
|
||||
|
||||
**Cons:**
|
||||
- **NOT PRIVATE** - Violation details visible to all users for 10 seconds
|
||||
- May embarrass users publicly
|
||||
- Could expose sensitive moderation information
|
||||
- Privacy-conscious communities may prefer silent failures
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
pytest -v # Verbose output
|
||||
pytest tests/test_automod.py # Specific file
|
||||
pytest -k "test_scam" # Filter by name
|
||||
|
||||
# Run specific tests
|
||||
pytest tests/test_automod.py
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src/guardden --cov-report=html
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
ruff check src tests # Linting
|
||||
ruff format src tests # Formatting
|
||||
mypy src # Type checking
|
||||
# Linting
|
||||
ruff check src tests
|
||||
|
||||
# Formatting
|
||||
ruff format src tests
|
||||
|
||||
# Type checking
|
||||
mypy src
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
# Apply migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Create new migration
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
# Rollback one migration
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot won't start
|
||||
|
||||
**Error: `Config file not found: config.yml`**
|
||||
- Solution: Copy `config.example.yml` to `config.yml` and edit settings
|
||||
|
||||
**Error: `Discord token cannot be empty`**
|
||||
- Solution: Add `GUARDDEN_DISCORD_TOKEN` to `.env` file
|
||||
|
||||
**Error: `Cannot import name 'ModerationResult'`**
|
||||
- Solution: Pull latest changes and rebuild: `docker compose up -d --build`
|
||||
|
||||
### Bot doesn't respond to commands
|
||||
|
||||
**Check:**
|
||||
1. Bot is online in Discord
|
||||
2. Bot has correct permissions (Manage Messages, View Channels)
|
||||
3. Your user ID is in `owner_ids` in config.yml
|
||||
4. Check logs: `docker logs guardden-bot -f`
|
||||
|
||||
### AI not working
|
||||
|
||||
**Check:**
|
||||
1. `ai_moderation.enabled: true` in config.yml
|
||||
2. `GUARDDEN_AI_PROVIDER` set to `anthropic` or `openai` in .env
|
||||
3. API key is set in .env (`GUARDDEN_ANTHROPIC_API_KEY` or `GUARDDEN_OPENAI_API_KEY`)
|
||||
4. Check logs for API errors
|
||||
|
||||
### High AI costs
|
||||
|
||||
**Reduce costs by:**
|
||||
1. Lower `max_checks_per_hour_per_guild` in config.yml
|
||||
2. Set `check_embed_images: false` to skip GIF embeds
|
||||
3. Add known offenders to `blocked_user_ids` blocklist
|
||||
4. Increase `max_image_size_mb` to skip large files
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: Report bugs at https://github.com/anthropics/claude-code/issues
|
||||
- **Documentation**: See `docs/` directory
|
||||
- **Configuration Help**: Check `CLAUDE.md` for developer guidance
|
||||
- **Issues**: [Report bugs](https://git.hiddenden.cafe/Hiddenden/GuardDen/issues)
|
||||
- **Configuration**: See `CLAUDE.md` for developer guidance
|
||||
- **Testing**: See `TESTING_TODO.md` for test status
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] AI-powered content moderation (Claude/OpenAI integration)
|
||||
- [x] NSFW image detection
|
||||
- [x] NSFW-only filtering mode (default)
|
||||
- [x] Optional public in-channel warnings when DMs disabled
|
||||
- [x] Verification/captcha system
|
||||
- [x] Rate limiting
|
||||
- [ ] Voice channel moderation
|
||||
- [ ] Slash commands with true ephemeral messages
|
||||
- [ ] Custom notification templates
|
||||
- [ ] Advanced analytics dashboard
|
||||
- [ ] Per-guild configuration support
|
||||
- [ ] Slash commands
|
||||
- [ ] Custom NSFW thresholds per category
|
||||
- [ ] Whitelist for trusted image sources
|
||||
- [ ] Dashboard for viewing stats
|
||||
|
||||
35
TESTING_TODO.md
Normal file
35
TESTING_TODO.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Testing TODO for Minimal Bot
|
||||
|
||||
## Tests That Need Updates
|
||||
|
||||
The following test files reference removed features and need to be updated or removed:
|
||||
|
||||
### To Remove (Features Removed)
|
||||
- `tests/test_verification.py` - Verification system removed
|
||||
- `tests/test_file_config.py` - File-based per-guild config removed
|
||||
- `tests/test_ai.py` - ModerationResult and text moderation removed
|
||||
|
||||
### To Update (Features Changed)
|
||||
- `tests/conftest.py` - Remove imports for:
|
||||
- `BannedWord` (removed model)
|
||||
- `ModerationLog`, `Strike`, `UserNote` (removed models)
|
||||
- `GuildDefaults` (if removed from config)
|
||||
|
||||
### Tests That Should Still Work
|
||||
- `tests/test_automod.py` - Spam detection (core feature)
|
||||
- `tests/test_ratelimit.py` - Rate limiting (still used)
|
||||
- `tests/test_automod_security.py` - Security tests
|
||||
- `tests/test_utils.py` - Utility functions
|
||||
- `tests/test_nsfw_only_filtering.py` - NSFW filtering (core feature)
|
||||
- `tests/test_config.py` - Config loading
|
||||
- `tests/test_database_integration.py` - May need updates for removed models
|
||||
|
||||
## Quick Fix
|
||||
|
||||
For now, tests can be skipped for removed features. Full test suite cleanup needed later.
|
||||
|
||||
## Run Working Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_automod.py tests/test_nsfw_only_filtering.py -v
|
||||
```
|
||||
67
config.example.yml
Normal file
67
config.example.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
# GuardDen Configuration
|
||||
# Single YAML file for bot configuration
|
||||
|
||||
# Bot Settings
|
||||
bot:
|
||||
prefix: "!"
|
||||
owner_ids:
|
||||
# Add your Discord user ID here
|
||||
# Example: - 123456789012345678
|
||||
|
||||
# Spam Detection (No AI cost)
|
||||
automod:
|
||||
enabled: true
|
||||
anti_spam_enabled: true
|
||||
message_rate_limit: 5 # Max messages per window
|
||||
message_rate_window: 5 # Window in seconds
|
||||
duplicate_threshold: 3 # Duplicate messages trigger
|
||||
mention_limit: 5 # Max mentions per message
|
||||
mention_rate_limit: 10 # Max mentions per window
|
||||
mention_rate_window: 60 # Mention window in seconds
|
||||
|
||||
# AI Moderation (Images, GIFs only)
|
||||
ai_moderation:
|
||||
enabled: true
|
||||
sensitivity: 80 # 0-100, higher = stricter
|
||||
nsfw_only_filtering: true # Only filter sexual/nude content
|
||||
|
||||
# Cost Controls (Conservative: ~$25/month for 1-2 guilds)
|
||||
max_checks_per_hour_per_guild: 25 # Very conservative limit
|
||||
max_checks_per_user_per_hour: 5 # Prevent user abuse
|
||||
max_images_per_message: 2 # Check max 2 images per message
|
||||
max_image_size_mb: 3 # Skip images larger than 3MB
|
||||
|
||||
# Feature Toggles
|
||||
check_embed_images: true # Check GIFs from Discord picker (enabled per user request)
|
||||
check_video_thumbnails: false # Skip video thumbnails (disabled per user request)
|
||||
url_image_check_enabled: false # Skip URL image downloads (disabled per user request)
|
||||
|
||||
# User Blocklist (No AI cost)
|
||||
# Block all images, GIFs, embeds, and URLs from these users
|
||||
# Add Discord user IDs here
|
||||
blocked_user_ids:
|
||||
# Example: - 123456789012345678
|
||||
|
||||
# NSFW Video Domain Blocklist (No AI cost)
|
||||
# These domains are blocked instantly without AI analysis
|
||||
nsfw_video_domains:
|
||||
- pornhub.com
|
||||
- xvideos.com
|
||||
- xnxx.com
|
||||
- redtube.com
|
||||
- youporn.com
|
||||
- tube8.com
|
||||
- spankwire.com
|
||||
- keezmovies.com
|
||||
- extremetube.com
|
||||
- pornerbros.com
|
||||
- eporner.com
|
||||
- tnaflix.com
|
||||
- drtuber.com
|
||||
- upornia.com
|
||||
- perfectgirls.net
|
||||
- xhamster.com
|
||||
- hqporner.com
|
||||
- porn.com
|
||||
- sex.com
|
||||
- wetpussy.com
|
||||
68
config.yml
Normal file
68
config.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
# GuardDen Configuration
|
||||
# Single YAML file for bot configuration
|
||||
|
||||
# Bot Settings
|
||||
bot:
|
||||
prefix: "!"
|
||||
owner_ids:
|
||||
# Add your Discord user ID here
|
||||
# Example: - 123456789012345678
|
||||
|
||||
# Spam Detection (No AI cost)
|
||||
automod:
|
||||
enabled: true
|
||||
anti_spam_enabled: true
|
||||
message_rate_limit: 5 # Max messages per window
|
||||
message_rate_window: 5 # Window in seconds
|
||||
duplicate_threshold: 3 # Duplicate messages trigger
|
||||
mention_limit: 5 # Max mentions per message
|
||||
mention_rate_limit: 10 # Max mentions per window
|
||||
mention_rate_window: 60 # Mention window in seconds
|
||||
|
||||
# AI Moderation (Images, GIFs only)
|
||||
ai_moderation:
|
||||
enabled: true
|
||||
sensitivity: 80 # 0-100, higher = stricter
|
||||
nsfw_only_filtering: true # Only filter sexual/nude content
|
||||
|
||||
# Cost Controls (Conservative: ~$25/month for 1-2 guilds)
|
||||
max_checks_per_hour_per_guild: 25 # Very conservative limit
|
||||
max_checks_per_user_per_hour: 5 # Prevent user abuse
|
||||
max_images_per_message: 2 # Check max 2 images per message
|
||||
max_image_size_mb: 10 # Skip images larger than 10MB
|
||||
|
||||
# Feature Toggles
|
||||
check_embed_images: true # Check GIFs from Discord picker (enabled per user request)
|
||||
check_video_thumbnails: false # Skip video thumbnails (disabled per user request)
|
||||
url_image_check_enabled: false # Skip URL image downloads (disabled per user request)
|
||||
|
||||
# User Blocklist (No AI cost)
|
||||
# Block all images, GIFs, embeds, and URLs from these users
|
||||
# Add Discord user IDs here
|
||||
blocked_user_ids:
|
||||
- 552511874269315073
|
||||
# Example: - 123456789012345678
|
||||
|
||||
# NSFW Video Domain Blocklist (No AI cost)
|
||||
# These domains are blocked instantly without AI analysis
|
||||
nsfw_video_domains:
|
||||
- pornhub.com
|
||||
- xvideos.com
|
||||
- xnxx.com
|
||||
- redtube.com
|
||||
- youporn.com
|
||||
- tube8.com
|
||||
- spankwire.com
|
||||
- keezmovies.com
|
||||
- extremetube.com
|
||||
- pornerbros.com
|
||||
- eporner.com
|
||||
- tnaflix.com
|
||||
- drtuber.com
|
||||
- upornia.com
|
||||
- perfectgirls.net
|
||||
- xhamster.com
|
||||
- hqporner.com
|
||||
- porn.com
|
||||
- sex.com
|
||||
- wetpussy.com
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
volumes:
|
||||
- guardden_data:/app/data
|
||||
- guardden_logs:/app/logs
|
||||
- ./config.yml:/app/config.yml:ro
|
||||
networks:
|
||||
- guardden
|
||||
healthcheck:
|
||||
|
||||
214
migrations/versions/20260127_minimal_bot_cleanup.py
Normal file
214
migrations/versions/20260127_minimal_bot_cleanup.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Minimal bot cleanup - remove unused tables and columns.
|
||||
|
||||
Revision ID: 20260127_minimal_bot_cleanup
|
||||
Revises: 20260125_add_whitelist
|
||||
Create Date: 2026-01-27 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20260127_minimal_bot_cleanup"
|
||||
down_revision = "20260125_add_whitelist"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Remove tables and columns not needed for minimal bot."""
|
||||
# Drop unused tables
|
||||
op.drop_table("user_activity")
|
||||
op.drop_table("message_activity")
|
||||
op.drop_table("ai_checks")
|
||||
op.drop_table("banned_words")
|
||||
op.drop_table("user_notes")
|
||||
op.drop_table("strikes")
|
||||
op.drop_table("moderation_logs")
|
||||
|
||||
# Drop unused columns from guild_settings
|
||||
op.drop_column("guild_settings", "verification_enabled")
|
||||
op.drop_column("guild_settings", "verification_type")
|
||||
op.drop_column("guild_settings", "verified_role_id")
|
||||
op.drop_column("guild_settings", "strike_actions")
|
||||
op.drop_column("guild_settings", "mute_role_id")
|
||||
op.drop_column("guild_settings", "mod_role_ids")
|
||||
op.drop_column("guild_settings", "welcome_channel_id")
|
||||
op.drop_column("guild_settings", "whitelisted_user_ids")
|
||||
op.drop_column("guild_settings", "scam_allowlist")
|
||||
op.drop_column("guild_settings", "send_in_channel_warnings")
|
||||
op.drop_column("guild_settings", "ai_log_only")
|
||||
op.drop_column("guild_settings", "ai_confidence_threshold")
|
||||
op.drop_column("guild_settings", "log_channel_id")
|
||||
op.drop_column("guild_settings", "mod_log_channel_id")
|
||||
op.drop_column("guild_settings", "link_filter_enabled")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Restore removed tables and columns (WARNING: Data will be lost!)."""
|
||||
# Restore guild_settings columns
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("link_filter_enabled", sa.Boolean, nullable=False, default=False),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("mod_log_channel_id", sa.BigInteger, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("log_channel_id", sa.BigInteger, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("ai_confidence_threshold", sa.Float, nullable=False, default=0.7),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("ai_log_only", sa.Boolean, nullable=False, default=False),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("send_in_channel_warnings", sa.Boolean, nullable=False, default=False),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column(
|
||||
"scam_allowlist",
|
||||
postgresql.JSONB().with_variant(sa.JSON(), "sqlite"),
|
||||
nullable=False,
|
||||
default=list,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column(
|
||||
"whitelisted_user_ids",
|
||||
postgresql.JSONB().with_variant(sa.JSON(), "sqlite"),
|
||||
nullable=False,
|
||||
default=list,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("welcome_channel_id", sa.BigInteger, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column(
|
||||
"mod_role_ids",
|
||||
postgresql.JSONB().with_variant(sa.JSON(), "sqlite"),
|
||||
nullable=False,
|
||||
default=list,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("mute_role_id", sa.BigInteger, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column(
|
||||
"strike_actions",
|
||||
postgresql.JSONB().with_variant(sa.JSON(), "sqlite"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("verified_role_id", sa.BigInteger, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("verification_type", sa.String(20), nullable=False, default="button"),
|
||||
)
|
||||
op.add_column(
|
||||
"guild_settings",
|
||||
sa.Column("verification_enabled", sa.Boolean, nullable=False, default=False),
|
||||
)
|
||||
|
||||
# Restore tables (empty, data lost)
|
||||
op.create_table(
|
||||
"moderation_logs",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("moderator_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("action", sa.String(20), nullable=False),
|
||||
sa.Column("reason", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"strikes",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("reason", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_notes",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("moderator_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("note", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"banned_words",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("pattern", sa.Text, nullable=False),
|
||||
sa.Column("is_regex", sa.Boolean, nullable=False, default=False),
|
||||
sa.Column("action", sa.String(20), nullable=False, default="delete"),
|
||||
sa.Column("reason", sa.Text, nullable=True),
|
||||
sa.Column("source", sa.String(100), nullable=True),
|
||||
sa.Column("category", sa.String(20), nullable=True),
|
||||
sa.Column("managed", sa.Boolean, nullable=False, default=False),
|
||||
sa.Column("added_by", sa.BigInteger, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ai_checks",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("check_type", sa.String(20), nullable=False),
|
||||
sa.Column("flagged", sa.Boolean, nullable=False),
|
||||
sa.Column("confidence", sa.Float, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"message_activity",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("channel_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("message_count", sa.Integer, nullable=False),
|
||||
sa.Column("date", sa.Date, nullable=False),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_activity",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("guild_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger, nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime, nullable=False),
|
||||
sa.Column("message_count", sa.Integer, nullable=False, default=0),
|
||||
sa.ForeignKeyConstraint(["guild_id"], ["guilds.id"], ondelete="CASCADE"),
|
||||
)
|
||||
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "guardden"
|
||||
version = "0.1.0"
|
||||
description = "A comprehensive Discord moderation bot with AI-powered content filtering"
|
||||
description = "A minimal, cost-conscious Discord moderation bot for spam detection and NSFW image filtering"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"""Main bot class for GuardDen."""
|
||||
"""Main bot class for GuardDen - Minimal Version."""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import platform
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.config import Settings
|
||||
from guardden.services.ai import AIProvider, create_ai_provider
|
||||
from guardden.services.ai_rate_limiter import AIRateLimiter
|
||||
from guardden.services.config_loader import ConfigLoader
|
||||
from guardden.services.database import Database
|
||||
from guardden.services.ratelimit import RateLimiter
|
||||
from guardden.utils.logging import get_logger, get_logging_middleware, setup_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from guardden.services.guild_config import GuildConfigService
|
||||
from guardden.utils.logging import get_logger, setup_logging
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logging_middleware = get_logging_middleware()
|
||||
|
||||
|
||||
class GuardDen(commands.Bot):
|
||||
"""The main GuardDen Discord bot."""
|
||||
"""The main GuardDen Discord bot - Minimal spam & NSFW detection."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
@@ -30,55 +27,48 @@ class GuardDen(commands.Bot):
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
intents.voice_states = True
|
||||
|
||||
# Load config from YAML
|
||||
config_path = settings.config_file
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Config file not found: {config_path}\n"
|
||||
f"Please create config.yml from the template."
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
command_prefix=self._get_prefix,
|
||||
command_prefix=settings.discord_prefix,
|
||||
intents=intents,
|
||||
help_command=None, # Set by help cog
|
||||
help_command=None,
|
||||
)
|
||||
|
||||
# Services
|
||||
self.database = Database(settings)
|
||||
self.guild_config: "GuildConfigService | None" = None
|
||||
self.ai_provider: AIProvider | None = None
|
||||
self.wordlist_service = None
|
||||
self.rate_limiter = RateLimiter()
|
||||
|
||||
async def _get_prefix(self, bot: "GuardDen", message: discord.Message) -> list[str]:
|
||||
"""Get the command prefix for a guild."""
|
||||
if not message.guild:
|
||||
return [self.settings.discord_prefix]
|
||||
|
||||
if self.guild_config:
|
||||
config = await self.guild_config.get_config(message.guild.id)
|
||||
if config:
|
||||
return [config.prefix]
|
||||
|
||||
return [self.settings.discord_prefix]
|
||||
|
||||
def is_guild_allowed(self, guild_id: int) -> bool:
|
||||
"""Check if a guild is allowed to run the bot."""
|
||||
return not self.settings.allowed_guilds or guild_id in self.settings.allowed_guilds
|
||||
|
||||
def is_owner_allowed(self, user_id: int) -> bool:
|
||||
"""Check if a user is allowed elevated access."""
|
||||
return not self.settings.owner_ids or user_id in self.settings.owner_ids
|
||||
self.config_loader = ConfigLoader(config_path)
|
||||
self.ai_rate_limiter = AIRateLimiter()
|
||||
|
||||
async def setup_hook(self) -> None:
|
||||
"""Called when the bot is starting up."""
|
||||
logger.info("Starting GuardDen setup...")
|
||||
logger.info("Starting GuardDen Minimal...")
|
||||
|
||||
# Load configuration from YAML
|
||||
try:
|
||||
await self.config_loader.load()
|
||||
logger.info(f"Configuration loaded from {self.config_loader.config_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load configuration: {e}")
|
||||
raise
|
||||
|
||||
self.settings.validate_configuration()
|
||||
logger.info(
|
||||
"Configuration loaded: ai_provider=%s, log_level=%s, allowed_guilds=%s, owner_ids=%s",
|
||||
"Settings: ai_provider=%s, log_level=%s, owner_ids=%s",
|
||||
self.settings.ai_provider,
|
||||
self.settings.log_level,
|
||||
self.settings.allowed_guilds or "all",
|
||||
self.settings.owner_ids or "admins",
|
||||
self.settings.owner_ids or "none",
|
||||
)
|
||||
logger.info(
|
||||
"Runtime versions: python=%s, discord.py=%s",
|
||||
"Runtime: python=%s, discord.py=%s",
|
||||
platform.python_version(),
|
||||
discord.__version__,
|
||||
)
|
||||
@@ -87,14 +77,6 @@ class GuardDen(commands.Bot):
|
||||
await self.database.connect()
|
||||
await self.database.create_tables()
|
||||
|
||||
# Initialize services
|
||||
from guardden.services.guild_config import GuildConfigService
|
||||
|
||||
self.guild_config = GuildConfigService(self.database, settings=self.settings)
|
||||
from guardden.services.wordlist import WordlistService
|
||||
|
||||
self.wordlist_service = WordlistService(self.database, self.settings)
|
||||
|
||||
# Initialize AI provider
|
||||
api_key = None
|
||||
if self.settings.ai_provider == "anthropic" and self.settings.anthropic_api_key:
|
||||
@@ -104,23 +86,22 @@ class GuardDen(commands.Bot):
|
||||
|
||||
self.ai_provider = create_ai_provider(self.settings.ai_provider, api_key)
|
||||
|
||||
if self.settings.ai_provider != "none":
|
||||
logger.info(f"AI provider initialized: {self.settings.ai_provider}")
|
||||
else:
|
||||
logger.warning("AI provider is disabled (provider=none)")
|
||||
|
||||
# Load cogs
|
||||
await self._load_cogs()
|
||||
|
||||
logger.info("GuardDen setup complete")
|
||||
|
||||
async def _load_cogs(self) -> None:
|
||||
"""Load all cog extensions."""
|
||||
"""Load minimal cog extensions."""
|
||||
cogs = [
|
||||
"guardden.cogs.events",
|
||||
"guardden.cogs.moderation",
|
||||
"guardden.cogs.admin",
|
||||
"guardden.cogs.automod",
|
||||
"guardden.cogs.ai_moderation",
|
||||
"guardden.cogs.verification",
|
||||
"guardden.cogs.health",
|
||||
"guardden.cogs.wordlist_sync",
|
||||
"guardden.cogs.help",
|
||||
"guardden.cogs.automod", # Spam detection only
|
||||
"guardden.cogs.ai_moderation", # Image detection only
|
||||
"guardden.cogs.owner", # Owner commands
|
||||
]
|
||||
|
||||
failed_cogs = []
|
||||
@@ -139,8 +120,8 @@ class GuardDen(commands.Bot):
|
||||
failed_cogs.append(cog)
|
||||
|
||||
if failed_cogs:
|
||||
logger.warning(f"Failed to load {len(failed_cogs)} cog(s): {', '.join(failed_cogs)}")
|
||||
# Don't fail startup if some cogs fail to load, but log it prominently
|
||||
logger.error(f"Failed to load {len(failed_cogs)} cog(s): {', '.join(failed_cogs)}")
|
||||
raise RuntimeError(f"Critical cogs failed to load: {failed_cogs}")
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
"""Called when the bot is fully connected and ready."""
|
||||
@@ -148,54 +129,29 @@ class GuardDen(commands.Bot):
|
||||
logger.info(f"Logged in as {self.user} (ID: {self.user.id})")
|
||||
logger.info(f"Connected to {len(self.guilds)} guild(s)")
|
||||
|
||||
# Ensure all guilds have database entries
|
||||
if self.guild_config:
|
||||
initialized = 0
|
||||
failed_guilds = []
|
||||
|
||||
for guild in self.guilds:
|
||||
try:
|
||||
if not self.is_guild_allowed(guild.id):
|
||||
logger.warning(
|
||||
"Leaving unauthorized guild %s (ID: %s)", guild.name, guild.id
|
||||
)
|
||||
try:
|
||||
await guild.leave()
|
||||
except discord.HTTPException as e:
|
||||
logger.error(f"Failed to leave guild {guild.id}: {e}")
|
||||
continue
|
||||
|
||||
await self.guild_config.create_guild(guild)
|
||||
initialized += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initialize config for guild {guild.id} ({guild.name}): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
failed_guilds.append(guild.id)
|
||||
|
||||
logger.info("Initialized config for %s guild(s)", initialized)
|
||||
if failed_guilds:
|
||||
logger.warning(
|
||||
f"Failed to initialize {len(failed_guilds)} guild(s): {failed_guilds}"
|
||||
)
|
||||
logger.info(f" - {guild.name} (ID: {guild.id}, Members: {guild.member_count})")
|
||||
|
||||
# Set presence
|
||||
activity = discord.Activity(
|
||||
type=discord.ActivityType.watching,
|
||||
name="over your community",
|
||||
name="for NSFW content",
|
||||
)
|
||||
await self.change_presence(activity=activity)
|
||||
|
||||
logger.info("Bot is ready!")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Clean up when shutting down."""
|
||||
logger.info("Shutting down GuardDen...")
|
||||
await self._shutdown_cogs()
|
||||
|
||||
if self.ai_provider:
|
||||
try:
|
||||
await self.ai_provider.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing AI provider: {e}")
|
||||
|
||||
await self.database.disconnect()
|
||||
await super().close()
|
||||
|
||||
@@ -216,14 +172,6 @@ class GuardDen(commands.Bot):
|
||||
"""Called when the bot joins a new guild."""
|
||||
logger.info(f"Joined guild: {guild.name} (ID: {guild.id})")
|
||||
|
||||
if not self.is_guild_allowed(guild.id):
|
||||
logger.warning("Guild %s (ID: %s) not in allowlist, leaving.", guild.name, guild.id)
|
||||
await guild.leave()
|
||||
return
|
||||
|
||||
if self.guild_config:
|
||||
await self.guild_config.create_guild(guild)
|
||||
|
||||
async def on_guild_remove(self, guild: discord.Guild) -> None:
|
||||
"""Called when the bot is removed from a guild."""
|
||||
logger.info(f"Removed from guild: {guild.name} (ID: {guild.id})")
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""GuardDen CLI tools for configuration management."""
|
||||
@@ -1,559 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GuardDen Configuration CLI Tool.
|
||||
|
||||
This CLI tool allows you to manage GuardDen bot configurations without
|
||||
using Discord commands. You can create, edit, validate, and migrate
|
||||
configurations using this command-line interface.
|
||||
|
||||
Usage:
|
||||
python -m guardden.cli.config --help
|
||||
python -m guardden.cli.config guild create 123456789 "My Server"
|
||||
python -m guardden.cli.config guild edit 123456789 ai_moderation.sensitivity 75
|
||||
python -m guardden.cli.config migrate from-database
|
||||
python -m guardden.cli.config validate all
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
import argparse
|
||||
import yaml
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from guardden.services.file_config import FileConfigurationManager, ConfigurationError
|
||||
from guardden.services.config_migration import ConfigurationMigrator
|
||||
from guardden.services.database import Database
|
||||
from guardden.services.guild_config import GuildConfigService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigurationCLI:
|
||||
"""Command-line interface for GuardDen configuration management."""
|
||||
|
||||
def __init__(self, config_dir: str = "config"):
|
||||
"""Initialize the CLI with configuration directory."""
|
||||
self.config_dir = Path(config_dir)
|
||||
self.file_manager: Optional[FileConfigurationManager] = None
|
||||
self.database: Optional[Database] = None
|
||||
self.migrator: Optional[ConfigurationMigrator] = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the configuration system."""
|
||||
self.file_manager = FileConfigurationManager(str(self.config_dir))
|
||||
await self.file_manager.initialize()
|
||||
|
||||
# Initialize database connection if available
|
||||
try:
|
||||
import os
|
||||
database_url = os.getenv('GUARDDEN_DATABASE_URL', 'postgresql://guardden:guardden@localhost:5432/guardden')
|
||||
self.database = Database(database_url)
|
||||
|
||||
guild_config_service = GuildConfigService(self.database)
|
||||
self.migrator = ConfigurationMigrator(self.database, guild_config_service, self.file_manager)
|
||||
|
||||
logger.info("Database connection established")
|
||||
except Exception as e:
|
||||
logger.warning(f"Database not available: {e}")
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if self.file_manager:
|
||||
await self.file_manager.shutdown()
|
||||
if self.database:
|
||||
await self.database.close()
|
||||
|
||||
# Guild management commands
|
||||
|
||||
async def guild_create(self, guild_id: int, name: str, owner_id: Optional[int] = None):
|
||||
"""Create a new guild configuration."""
|
||||
try:
|
||||
file_path = await self.file_manager.create_guild_config(guild_id, name, owner_id)
|
||||
print(f"✅ Created guild configuration: {file_path}")
|
||||
print(f"📝 Edit the file to customize settings for {name}")
|
||||
return True
|
||||
except ConfigurationError as e:
|
||||
print(f"❌ Failed to create guild configuration: {e.error_message}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {str(e)}")
|
||||
return False
|
||||
|
||||
async def guild_list(self):
|
||||
"""List all configured guilds."""
|
||||
configs = self.file_manager.get_all_guild_configs()
|
||||
|
||||
if not configs:
|
||||
print("📄 No guild configurations found")
|
||||
print("💡 Use 'guild create <guild_id> <name>' to create a new configuration")
|
||||
return
|
||||
|
||||
print(f"📋 Found {len(configs)} guild configuration(s):")
|
||||
print()
|
||||
|
||||
for guild_id, config in configs.items():
|
||||
status_icon = "✅" if config else "❌"
|
||||
premium_icon = "⭐" if config.premium else ""
|
||||
|
||||
print(f"{status_icon} {premium_icon} {guild_id}: {config.name}")
|
||||
print(f" 📁 File: {config.file_path}")
|
||||
print(f" 🕐 Updated: {config.last_updated.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
# Show key settings
|
||||
settings = config.settings
|
||||
ai_enabled = settings.get("ai_moderation", {}).get("enabled", False)
|
||||
nsfw_only = settings.get("ai_moderation", {}).get("nsfw_only_filtering", False)
|
||||
automod_enabled = settings.get("moderation", {}).get("automod_enabled", False)
|
||||
|
||||
print(f" 🤖 AI: {'✅' if ai_enabled else '❌'} | "
|
||||
f"🔞 NSFW-Only: {'✅' if nsfw_only else '❌'} | "
|
||||
f"⚡ AutoMod: {'✅' if automod_enabled else '❌'}")
|
||||
print()
|
||||
|
||||
async def guild_edit(self, guild_id: int, setting_path: str, value: Any):
|
||||
"""Edit a guild configuration setting."""
|
||||
config = self.file_manager.get_guild_config(guild_id)
|
||||
if not config:
|
||||
print(f"❌ Guild {guild_id} configuration not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Load current configuration
|
||||
with open(config.file_path, 'r', encoding='utf-8') as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
|
||||
# Parse setting path (e.g., "ai_moderation.sensitivity")
|
||||
path_parts = setting_path.split('.')
|
||||
current = file_config
|
||||
|
||||
# Navigate to the parent of the target setting
|
||||
for part in path_parts[:-1]:
|
||||
if part not in current:
|
||||
print(f"❌ Setting path not found: {setting_path}")
|
||||
return False
|
||||
current = current[part]
|
||||
|
||||
# Set the value
|
||||
final_key = path_parts[-1]
|
||||
old_value = current.get(final_key, "Not set")
|
||||
|
||||
# Convert value to appropriate type
|
||||
if isinstance(old_value, bool):
|
||||
value = str(value).lower() in ('true', '1', 'yes', 'on')
|
||||
elif isinstance(old_value, int):
|
||||
value = int(value)
|
||||
elif isinstance(old_value, float):
|
||||
value = float(value)
|
||||
elif isinstance(old_value, list):
|
||||
value = value.split(',') if isinstance(value, str) else value
|
||||
|
||||
current[final_key] = value
|
||||
|
||||
# Write back to file
|
||||
with open(config.file_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(file_config, f, default_flow_style=False, indent=2)
|
||||
|
||||
print(f"✅ Updated {setting_path} for guild {guild_id}")
|
||||
print(f" 📝 Changed from: {old_value}")
|
||||
print(f" 📝 Changed to: {value}")
|
||||
print(f"🔄 Configuration will be hot-reloaded automatically")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to edit configuration: {str(e)}")
|
||||
return False
|
||||
|
||||
async def guild_validate(self, guild_id: Optional[int] = None):
|
||||
"""Validate guild configuration(s)."""
|
||||
if guild_id:
|
||||
configs = {guild_id: self.file_manager.get_guild_config(guild_id)}
|
||||
if not configs[guild_id]:
|
||||
print(f"❌ Guild {guild_id} configuration not found")
|
||||
return False
|
||||
else:
|
||||
configs = self.file_manager.get_all_guild_configs()
|
||||
|
||||
if not configs:
|
||||
print("📄 No configurations to validate")
|
||||
return True
|
||||
|
||||
all_valid = True
|
||||
print(f"🔍 Validating {len(configs)} configuration(s)...")
|
||||
print()
|
||||
|
||||
for guild_id, config in configs.items():
|
||||
if not config:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Load and validate configuration
|
||||
with open(config.file_path, 'r', encoding='utf-8') as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
|
||||
errors = self.file_manager.validate_config(file_config)
|
||||
|
||||
if errors:
|
||||
all_valid = False
|
||||
print(f"❌ Guild {guild_id} ({config.name}) - INVALID")
|
||||
for error in errors:
|
||||
print(f" 🔸 {error}")
|
||||
else:
|
||||
print(f"✅ Guild {guild_id} ({config.name}) - VALID")
|
||||
|
||||
except Exception as e:
|
||||
all_valid = False
|
||||
print(f"❌ Guild {guild_id} - ERROR: {str(e)}")
|
||||
|
||||
print()
|
||||
if all_valid:
|
||||
print("🎉 All configurations are valid!")
|
||||
else:
|
||||
print("⚠️ Some configurations have errors. Please fix them before running the bot.")
|
||||
|
||||
return all_valid
|
||||
|
||||
async def guild_backup(self, guild_id: int):
|
||||
"""Create a backup of guild configuration."""
|
||||
try:
|
||||
backup_path = await self.file_manager.backup_config(guild_id)
|
||||
print(f"✅ Created backup: {backup_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to create backup: {str(e)}")
|
||||
return False
|
||||
|
||||
# Migration commands
|
||||
|
||||
async def migrate_from_database(self, backup_existing: bool = True):
|
||||
"""Migrate all configurations from database to files."""
|
||||
if not self.migrator:
|
||||
print("❌ Database not available for migration")
|
||||
return False
|
||||
|
||||
print("🔄 Starting migration from database to files...")
|
||||
print("⚠️ This will convert Discord command configurations to YAML files")
|
||||
|
||||
if backup_existing:
|
||||
print("📦 Existing files will be backed up")
|
||||
|
||||
try:
|
||||
results = await self.migrator.migrate_all_guilds(backup_existing)
|
||||
|
||||
print("\n📊 Migration Results:")
|
||||
print(f" ✅ Migrated: {len(results['migrated_guilds'])} guilds")
|
||||
print(f" ❌ Failed: {len(results['failed_guilds'])} guilds")
|
||||
print(f" ⏭️ Skipped: {len(results['skipped_guilds'])} guilds")
|
||||
print(f" 📝 Banned words migrated: {results['banned_words_migrated']}")
|
||||
|
||||
if results['migrated_guilds']:
|
||||
print("\n✅ Successfully migrated guilds:")
|
||||
for guild in results['migrated_guilds']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']} "
|
||||
f"({guild['banned_words_count']} banned words)")
|
||||
|
||||
if results['failed_guilds']:
|
||||
print("\n❌ Failed migrations:")
|
||||
for guild in results['failed_guilds']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']} - {guild['error']}")
|
||||
|
||||
if results['skipped_guilds']:
|
||||
print("\n⏭️ Skipped guilds:")
|
||||
for guild in results['skipped_guilds']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']} - {guild['reason']}")
|
||||
|
||||
if results['errors']:
|
||||
print("\n⚠️ Errors encountered:")
|
||||
for error in results['errors']:
|
||||
print(f" • {error}")
|
||||
|
||||
return len(results['failed_guilds']) == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Migration failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def migrate_verify(self, guild_ids: Optional[List[int]] = None):
|
||||
"""Verify migration by comparing database and file configurations."""
|
||||
if not self.migrator:
|
||||
print("❌ Database not available for verification")
|
||||
return False
|
||||
|
||||
print("🔍 Verifying migration results...")
|
||||
|
||||
try:
|
||||
results = await self.migrator.verify_migration(guild_ids)
|
||||
|
||||
print("\n📊 Verification Results:")
|
||||
print(f" ✅ Verified: {len(results['verified_guilds'])} guilds")
|
||||
print(f" ⚠️ Mismatches: {len(results['mismatches'])} guilds")
|
||||
print(f" 📄 Missing files: {len(results['missing_files'])} guilds")
|
||||
|
||||
if results['verified_guilds']:
|
||||
print("\n✅ Verified guilds:")
|
||||
for guild in results['verified_guilds']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']}")
|
||||
|
||||
if results['mismatches']:
|
||||
print("\n⚠️ Configuration mismatches:")
|
||||
for guild in results['mismatches']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']}")
|
||||
print(f" Mismatched fields: {', '.join(guild['mismatched_fields'])}")
|
||||
|
||||
if results['missing_files']:
|
||||
print("\n📄 Missing configuration files:")
|
||||
for guild in results['missing_files']:
|
||||
print(f" • {guild['guild_id']}: {guild['guild_name']}")
|
||||
print(f" Expected: {guild['expected_file']}")
|
||||
|
||||
return len(results['mismatches']) == 0 and len(results['missing_files']) == 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Verification failed: {str(e)}")
|
||||
return False
|
||||
|
||||
# Wordlist management
|
||||
|
||||
async def wordlist_info(self):
|
||||
"""Show information about wordlist configurations."""
|
||||
banned_words = self.file_manager.get_wordlist_config()
|
||||
allowlists = self.file_manager.get_allowlist_config()
|
||||
external_sources = self.file_manager.get_external_sources_config()
|
||||
|
||||
print("📝 Wordlist Configuration Status:")
|
||||
print()
|
||||
|
||||
if banned_words:
|
||||
global_patterns = len(banned_words.get('global_patterns', []))
|
||||
guild_patterns = sum(
|
||||
len(patterns) for patterns in banned_words.get('guild_patterns', {}).values()
|
||||
)
|
||||
print(f"🚫 Banned Words: {global_patterns} global, {guild_patterns} guild-specific")
|
||||
else:
|
||||
print("🚫 Banned Words: Not configured")
|
||||
|
||||
if allowlists:
|
||||
global_allowlist = len(allowlists.get('global_allowlist', []))
|
||||
guild_allowlists = sum(
|
||||
len(domains) for domains in allowlists.get('guild_allowlists', {}).values()
|
||||
)
|
||||
print(f"✅ Domain Allowlists: {global_allowlist} global, {guild_allowlists} guild-specific")
|
||||
else:
|
||||
print("✅ Domain Allowlists: Not configured")
|
||||
|
||||
if external_sources:
|
||||
sources = external_sources.get('sources', [])
|
||||
enabled_sources = len([s for s in sources if s.get('enabled', False)])
|
||||
print(f"🌐 External Sources: {len(sources)} total, {enabled_sources} enabled")
|
||||
else:
|
||||
print("🌐 External Sources: Not configured")
|
||||
|
||||
print()
|
||||
print("📁 Configuration files:")
|
||||
print(f" • {self.config_dir / 'wordlists' / 'banned-words.yml'}")
|
||||
print(f" • {self.config_dir / 'wordlists' / 'domain-allowlists.yml'}")
|
||||
print(f" • {self.config_dir / 'wordlists' / 'external-sources.yml'}")
|
||||
|
||||
# Template management
|
||||
|
||||
async def template_create(self, guild_id: int, name: str):
|
||||
"""Create a new guild configuration from template."""
|
||||
return await self.guild_create(guild_id, name)
|
||||
|
||||
async def template_info(self):
|
||||
"""Show available configuration templates."""
|
||||
template_dir = self.config_dir / "templates"
|
||||
templates = list(template_dir.glob("*.yml"))
|
||||
|
||||
if not templates:
|
||||
print("📄 No configuration templates found")
|
||||
return
|
||||
|
||||
print(f"📋 Available Templates ({len(templates)}):")
|
||||
print()
|
||||
|
||||
for template in templates:
|
||||
try:
|
||||
with open(template, 'r', encoding='utf-8') as f:
|
||||
content = yaml.safe_load(f)
|
||||
|
||||
description = "Default guild configuration template"
|
||||
if '_description' in content:
|
||||
description = content['_description']
|
||||
|
||||
print(f"📄 {template.name}")
|
||||
print(f" {description}")
|
||||
print(f" 📁 {template}")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading template {template.name}: {str(e)}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GuardDen Configuration CLI Tool",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Create a new guild configuration
|
||||
python -m guardden.cli.config guild create 123456789 "My Server"
|
||||
|
||||
# List all guild configurations
|
||||
python -m guardden.cli.config guild list
|
||||
|
||||
# Edit a configuration setting
|
||||
python -m guardden.cli.config guild edit 123456789 ai_moderation.sensitivity 75
|
||||
python -m guardden.cli.config guild edit 123456789 ai_moderation.nsfw_only_filtering true
|
||||
|
||||
# Validate configurations
|
||||
python -m guardden.cli.config guild validate
|
||||
python -m guardden.cli.config guild validate 123456789
|
||||
|
||||
# Migration from database
|
||||
python -m guardden.cli.config migrate from-database
|
||||
python -m guardden.cli.config migrate verify
|
||||
|
||||
# Wordlist management
|
||||
python -m guardden.cli.config wordlist info
|
||||
|
||||
# Template management
|
||||
python -m guardden.cli.config template info
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config-dir', '-c',
|
||||
default='config',
|
||||
help='Configuration directory (default: config)'
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Guild management
|
||||
guild_parser = subparsers.add_parser('guild', help='Guild configuration management')
|
||||
guild_subparsers = guild_parser.add_subparsers(dest='guild_command')
|
||||
|
||||
# Guild create
|
||||
create_parser = guild_subparsers.add_parser('create', help='Create new guild configuration')
|
||||
create_parser.add_argument('guild_id', type=int, help='Discord guild ID')
|
||||
create_parser.add_argument('name', help='Guild name')
|
||||
create_parser.add_argument('--owner-id', type=int, help='Guild owner Discord user ID')
|
||||
|
||||
# Guild list
|
||||
guild_subparsers.add_parser('list', help='List all guild configurations')
|
||||
|
||||
# Guild edit
|
||||
edit_parser = guild_subparsers.add_parser('edit', help='Edit guild configuration setting')
|
||||
edit_parser.add_argument('guild_id', type=int, help='Discord guild ID')
|
||||
edit_parser.add_argument('setting', help='Setting path (e.g., ai_moderation.sensitivity)')
|
||||
edit_parser.add_argument('value', help='New value')
|
||||
|
||||
# Guild validate
|
||||
validate_parser = guild_subparsers.add_parser('validate', help='Validate guild configurations')
|
||||
validate_parser.add_argument('guild_id', type=int, nargs='?', help='Specific guild ID (optional)')
|
||||
|
||||
# Guild backup
|
||||
backup_parser = guild_subparsers.add_parser('backup', help='Backup guild configuration')
|
||||
backup_parser.add_argument('guild_id', type=int, help='Discord guild ID')
|
||||
|
||||
# Migration
|
||||
migrate_parser = subparsers.add_parser('migrate', help='Configuration migration')
|
||||
migrate_subparsers = migrate_parser.add_subparsers(dest='migrate_command')
|
||||
|
||||
# Migrate from database
|
||||
from_db_parser = migrate_subparsers.add_parser('from-database', help='Migrate from database to files')
|
||||
from_db_parser.add_argument('--no-backup', action='store_true', help='Skip backing up existing files')
|
||||
|
||||
# Migrate verify
|
||||
verify_parser = migrate_subparsers.add_parser('verify', help='Verify migration results')
|
||||
verify_parser.add_argument('guild_ids', type=int, nargs='*', help='Specific guild IDs to verify')
|
||||
|
||||
# Wordlist management
|
||||
wordlist_parser = subparsers.add_parser('wordlist', help='Wordlist management')
|
||||
wordlist_subparsers = wordlist_parser.add_subparsers(dest='wordlist_command')
|
||||
wordlist_subparsers.add_parser('info', help='Show wordlist information')
|
||||
|
||||
# Template management
|
||||
template_parser = subparsers.add_parser('template', help='Template management')
|
||||
template_subparsers = template_parser.add_subparsers(dest='template_command')
|
||||
template_subparsers.add_parser('info', help='Show available templates')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
# Initialize CLI
|
||||
cli = ConfigurationCLI(args.config_dir)
|
||||
|
||||
try:
|
||||
await cli.initialize()
|
||||
success = True
|
||||
|
||||
# Execute command
|
||||
if args.command == 'guild':
|
||||
if args.guild_command == 'create':
|
||||
success = await cli.guild_create(args.guild_id, args.name, args.owner_id)
|
||||
elif args.guild_command == 'list':
|
||||
await cli.guild_list()
|
||||
elif args.guild_command == 'edit':
|
||||
success = await cli.guild_edit(args.guild_id, args.setting, args.value)
|
||||
elif args.guild_command == 'validate':
|
||||
success = await cli.guild_validate(args.guild_id)
|
||||
elif args.guild_command == 'backup':
|
||||
success = await cli.guild_backup(args.guild_id)
|
||||
else:
|
||||
print("❌ Unknown guild command. Use --help for available commands.")
|
||||
success = False
|
||||
|
||||
elif args.command == 'migrate':
|
||||
if args.migrate_command == 'from-database':
|
||||
success = await cli.migrate_from_database(not args.no_backup)
|
||||
elif args.migrate_command == 'verify':
|
||||
guild_ids = args.guild_ids if args.guild_ids else None
|
||||
success = await cli.migrate_verify(guild_ids)
|
||||
else:
|
||||
print("❌ Unknown migrate command. Use --help for available commands.")
|
||||
success = False
|
||||
|
||||
elif args.command == 'wordlist':
|
||||
if args.wordlist_command == 'info':
|
||||
await cli.wordlist_info()
|
||||
else:
|
||||
print("❌ Unknown wordlist command. Use --help for available commands.")
|
||||
success = False
|
||||
|
||||
elif args.command == 'template':
|
||||
if args.template_command == 'info':
|
||||
await cli.template_info()
|
||||
else:
|
||||
print("❌ Unknown template command. Use --help for available commands.")
|
||||
success = False
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Interrupted by user")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {str(e)}")
|
||||
logger.exception("CLI error")
|
||||
return 1
|
||||
finally:
|
||||
await cli.cleanup()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,444 +0,0 @@
|
||||
"""Admin commands for bot configuration."""
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
from guardden.utils.ratelimit import RateLimitExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Admin(commands.Cog):
|
||||
"""Administrative commands for bot configuration."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def cog_check(self, ctx: commands.Context) -> bool:
|
||||
"""Ensure only administrators can use these commands."""
|
||||
if not ctx.guild:
|
||||
return False
|
||||
if not self.bot.is_owner_allowed(ctx.author.id):
|
||||
return False
|
||||
return ctx.author.guild_permissions.administrator
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
@commands.group(name="config", invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
async def config(self, ctx: commands.Context) -> None:
|
||||
"""View or modify bot configuration."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
|
||||
if not config:
|
||||
await ctx.send("No configuration found. Run a config command to initialize.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Configuration for {ctx.guild.name}",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
# General settings
|
||||
embed.add_field(name="Prefix", value=f"`{config.prefix}`", inline=True)
|
||||
embed.add_field(name="Locale", value=config.locale, inline=True)
|
||||
embed.add_field(name="\u200b", value="\u200b", inline=True)
|
||||
|
||||
# Channels
|
||||
log_ch = ctx.guild.get_channel(config.log_channel_id) if config.log_channel_id else None
|
||||
mod_log_ch = (
|
||||
ctx.guild.get_channel(config.mod_log_channel_id) if config.mod_log_channel_id else None
|
||||
)
|
||||
welcome_ch = (
|
||||
ctx.guild.get_channel(config.welcome_channel_id) if config.welcome_channel_id else None
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Log Channel", value=log_ch.mention if log_ch else "Not set", inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="Mod Log Channel",
|
||||
value=mod_log_ch.mention if mod_log_ch else "Not set",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Welcome Channel",
|
||||
value=welcome_ch.mention if welcome_ch else "Not set",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
# Features
|
||||
features = []
|
||||
if config.automod_enabled:
|
||||
features.append("AutoMod")
|
||||
if config.anti_spam_enabled:
|
||||
features.append("Anti-Spam")
|
||||
if config.link_filter_enabled:
|
||||
features.append("Link Filter")
|
||||
if config.ai_moderation_enabled:
|
||||
features.append("AI Moderation")
|
||||
if config.verification_enabled:
|
||||
features.append("Verification")
|
||||
|
||||
embed.add_field(
|
||||
name="Enabled Features",
|
||||
value=", ".join(features) if features else "None",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Notification settings
|
||||
embed.add_field(
|
||||
name="In-Channel Warnings",
|
||||
value="✅ Enabled" if config.send_in_channel_warnings else "❌ Disabled",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@config.command(name="prefix")
|
||||
@commands.guild_only()
|
||||
async def config_prefix(self, ctx: commands.Context, prefix: str) -> None:
|
||||
"""Set the command prefix for this server."""
|
||||
if not prefix or not prefix.strip():
|
||||
await ctx.send("Prefix cannot be empty or whitespace only.")
|
||||
return
|
||||
|
||||
if len(prefix) > 10:
|
||||
await ctx.send("Prefix must be 10 characters or less.")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, prefix=prefix)
|
||||
await ctx.send(f"Command prefix set to `{prefix}`")
|
||||
|
||||
@config.command(name="logchannel")
|
||||
@commands.guild_only()
|
||||
async def config_log_channel(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel | None = None
|
||||
) -> None:
|
||||
"""Set the channel for general event logs."""
|
||||
channel_id = channel.id if channel else None
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, log_channel_id=channel_id)
|
||||
|
||||
if channel:
|
||||
await ctx.send(f"Log channel set to {channel.mention}")
|
||||
else:
|
||||
await ctx.send("Log channel has been disabled.")
|
||||
|
||||
@config.command(name="modlogchannel")
|
||||
@commands.guild_only()
|
||||
async def config_mod_log_channel(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel | None = None
|
||||
) -> None:
|
||||
"""Set the channel for moderation action logs."""
|
||||
channel_id = channel.id if channel else None
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, mod_log_channel_id=channel_id)
|
||||
|
||||
if channel:
|
||||
await ctx.send(f"Moderation log channel set to {channel.mention}")
|
||||
else:
|
||||
await ctx.send("Moderation log channel has been disabled.")
|
||||
|
||||
@config.command(name="welcomechannel")
|
||||
@commands.guild_only()
|
||||
async def config_welcome_channel(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel | None = None
|
||||
) -> None:
|
||||
"""Set the welcome channel for new members."""
|
||||
channel_id = channel.id if channel else None
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, welcome_channel_id=channel_id)
|
||||
|
||||
if channel:
|
||||
await ctx.send(f"Welcome channel set to {channel.mention}")
|
||||
else:
|
||||
await ctx.send("Welcome channel has been disabled.")
|
||||
|
||||
@config.command(name="muterole")
|
||||
@commands.guild_only()
|
||||
async def config_mute_role(
|
||||
self, ctx: commands.Context, role: discord.Role | None = None
|
||||
) -> None:
|
||||
"""Set the role to assign when muting members."""
|
||||
role_id = role.id if role else None
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, mute_role_id=role_id)
|
||||
|
||||
if role:
|
||||
await ctx.send(f"Mute role set to {role.mention}")
|
||||
else:
|
||||
await ctx.send("Mute role has been cleared.")
|
||||
|
||||
@config.command(name="automod")
|
||||
@commands.guild_only()
|
||||
async def config_automod(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable automod features."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, automod_enabled=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
await ctx.send(f"AutoMod has been {status}.")
|
||||
|
||||
@config.command(name="antispam")
|
||||
@commands.guild_only()
|
||||
async def config_antispam(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable anti-spam protection."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, anti_spam_enabled=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
await ctx.send(f"Anti-spam has been {status}.")
|
||||
|
||||
@config.command(name="linkfilter")
|
||||
@commands.guild_only()
|
||||
async def config_linkfilter(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable link filtering."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, link_filter_enabled=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
await ctx.send(f"Link filter has been {status}.")
|
||||
|
||||
@commands.group(name="bannedwords", aliases=["bw"], invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
async def banned_words(self, ctx: commands.Context) -> None:
|
||||
"""Manage banned words list."""
|
||||
words = await self.bot.guild_config.get_banned_words(ctx.guild.id)
|
||||
|
||||
if not words:
|
||||
await ctx.send("No banned words configured.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Banned Words",
|
||||
color=discord.Color.red(),
|
||||
)
|
||||
|
||||
for word in words[:25]: # Discord embed limit
|
||||
word_type = "Regex" if word.is_regex else "Text"
|
||||
embed.add_field(
|
||||
name=f"#{word.id}: {word.pattern[:30]}",
|
||||
value=f"Type: {word_type} | Action: {word.action}",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
if len(words) > 25:
|
||||
embed.set_footer(text=f"Showing 25 of {len(words)} banned words")
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@banned_words.command(name="add")
|
||||
@commands.guild_only()
|
||||
async def banned_words_add(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
pattern: str,
|
||||
action: Literal["delete", "warn", "strike"] = "delete",
|
||||
is_regex: bool = False,
|
||||
) -> None:
|
||||
"""Add a banned word or pattern."""
|
||||
word = await self.bot.guild_config.add_banned_word(
|
||||
guild_id=ctx.guild.id,
|
||||
pattern=pattern,
|
||||
added_by=ctx.author.id,
|
||||
is_regex=is_regex,
|
||||
action=action,
|
||||
)
|
||||
|
||||
word_type = "regex pattern" if is_regex else "word"
|
||||
await ctx.send(f"Added banned {word_type}: `{pattern}` (ID: {word.id}, Action: {action})")
|
||||
|
||||
@banned_words.command(name="remove", aliases=["delete"])
|
||||
@commands.guild_only()
|
||||
async def banned_words_remove(self, ctx: commands.Context, word_id: int) -> None:
|
||||
"""Remove a banned word by ID."""
|
||||
success = await self.bot.guild_config.remove_banned_word(ctx.guild.id, word_id)
|
||||
|
||||
if success:
|
||||
await ctx.send(f"Removed banned word #{word_id}")
|
||||
else:
|
||||
await ctx.send(f"Banned word #{word_id} not found.")
|
||||
|
||||
@commands.command(name="channelwarnings")
|
||||
@commands.guild_only()
|
||||
async def channel_warnings(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable PUBLIC in-channel warnings when DMs fail.
|
||||
|
||||
WARNING: In-channel messages are PUBLIC and visible to all users in the channel.
|
||||
They are NOT private due to Discord API limitations.
|
||||
|
||||
When enabled, if a user has DMs disabled, moderation warnings will be sent
|
||||
as temporary PUBLIC messages in the channel (auto-deleted after 10 seconds).
|
||||
|
||||
Args:
|
||||
enabled: True to enable PUBLIC warnings, False to disable (default: False)
|
||||
"""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, send_in_channel_warnings=enabled)
|
||||
|
||||
status = "enabled" if enabled else "disabled"
|
||||
embed = discord.Embed(
|
||||
title="In-Channel Warnings Updated",
|
||||
description=f"In-channel warnings are now **{status}**.",
|
||||
color=discord.Color.green() if enabled else discord.Color.orange(),
|
||||
)
|
||||
|
||||
if enabled:
|
||||
embed.add_field(
|
||||
name="⚠️ Privacy Warning",
|
||||
value="**Messages are PUBLIC and visible to ALL users in the channel.**\n"
|
||||
"When a user has DMs disabled, moderation warnings will be sent "
|
||||
"as temporary PUBLIC messages in the channel (auto-deleted after 10 seconds).",
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="✅ Privacy Protected",
|
||||
value="When users have DMs disabled, they will not receive any notification. "
|
||||
"This protects user privacy and prevents public embarrassment.",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@commands.group(name="whitelist", invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
async def whitelist_cmd(self, ctx: commands.Context) -> None:
|
||||
"""Manage the moderation whitelist."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
whitelisted_ids = config.whitelisted_user_ids if config else []
|
||||
|
||||
if not whitelisted_ids:
|
||||
await ctx.send("No users are whitelisted.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Whitelisted Users",
|
||||
description="These users bypass all moderation checks:",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
users_text = []
|
||||
for user_id in whitelisted_ids[:25]: # Limit to 25 to avoid embed limits
|
||||
user = ctx.guild.get_member(user_id)
|
||||
if user:
|
||||
users_text.append(f"• {user.mention} (`{user_id}`)")
|
||||
else:
|
||||
users_text.append(f"• Unknown User (`{user_id}`)")
|
||||
|
||||
embed.add_field(
|
||||
name=f"Total: {len(whitelisted_ids)} users",
|
||||
value="\n".join(users_text) if users_text else "None",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if len(whitelisted_ids) > 25:
|
||||
embed.set_footer(text=f"Showing 25 of {len(whitelisted_ids)} users")
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@whitelist_cmd.command(name="add")
|
||||
@commands.guild_only()
|
||||
async def whitelist_add(self, ctx: commands.Context, user: discord.Member) -> None:
|
||||
"""Add a user to the whitelist.
|
||||
|
||||
Whitelisted users bypass ALL moderation checks (automod and AI moderation).
|
||||
"""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
whitelisted_ids = list(config.whitelisted_user_ids) if config else []
|
||||
|
||||
if user.id in whitelisted_ids:
|
||||
await ctx.send(f"{user.mention} is already whitelisted.")
|
||||
return
|
||||
|
||||
whitelisted_ids.append(user.id)
|
||||
await self.bot.guild_config.update_settings(
|
||||
ctx.guild.id, whitelisted_user_ids=whitelisted_ids
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="✅ User Whitelisted",
|
||||
description=f"{user.mention} has been added to the whitelist.",
|
||||
color=discord.Color.green(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="What this means",
|
||||
value="This user will bypass all automod and AI moderation checks.",
|
||||
inline=False,
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@whitelist_cmd.command(name="remove")
|
||||
@commands.guild_only()
|
||||
async def whitelist_remove(self, ctx: commands.Context, user: discord.Member) -> None:
|
||||
"""Remove a user from the whitelist."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
whitelisted_ids = list(config.whitelisted_user_ids) if config else []
|
||||
|
||||
if user.id not in whitelisted_ids:
|
||||
await ctx.send(f"{user.mention} is not whitelisted.")
|
||||
return
|
||||
|
||||
whitelisted_ids.remove(user.id)
|
||||
await self.bot.guild_config.update_settings(
|
||||
ctx.guild.id, whitelisted_user_ids=whitelisted_ids
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🚫 User Removed from Whitelist",
|
||||
description=f"{user.mention} has been removed from the whitelist.",
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="What this means",
|
||||
value="This user will now be subject to normal moderation checks.",
|
||||
inline=False,
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@whitelist_cmd.command(name="clear")
|
||||
@commands.guild_only()
|
||||
async def whitelist_clear(self, ctx: commands.Context) -> None:
|
||||
"""Clear the entire whitelist."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
count = len(config.whitelisted_user_ids) if config else 0
|
||||
|
||||
if count == 0:
|
||||
await ctx.send("The whitelist is already empty.")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, whitelisted_user_ids=[])
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🧹 Whitelist Cleared",
|
||||
description=f"Removed {count} user(s) from the whitelist.",
|
||||
color=discord.Color.red(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="What this means",
|
||||
value="All users will now be subject to normal moderation checks.",
|
||||
inline=False,
|
||||
)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@commands.command(name="sync")
|
||||
@commands.is_owner()
|
||||
async def sync_commands(self, ctx: commands.Context) -> None:
|
||||
"""Sync slash commands (bot owner only)."""
|
||||
await self.bot.tree.sync()
|
||||
await ctx.send("Slash commands synced.")
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the Admin cog."""
|
||||
await bot.add_cog(Admin(bot))
|
||||
@@ -1,73 +1,56 @@
|
||||
"""AI-powered moderation cog."""
|
||||
"""AI-powered moderation cog - Images & GIFs only, with cost controls."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
from guardden.models import ModerationLog
|
||||
from guardden.services.ai.base import ContentCategory, ModerationResult
|
||||
from guardden.services.automod import URL_PATTERN, is_allowed_domain, normalize_domain
|
||||
from guardden.utils.notifications import send_moderation_notification
|
||||
from guardden.utils.ratelimit import RateLimitExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NSFW video domain blocklist
|
||||
NSFW_VIDEO_DOMAINS = [] # Loaded from config
|
||||
|
||||
# URL pattern for finding links
|
||||
URL_PATTERN = re.compile(
|
||||
r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
|
||||
)
|
||||
|
||||
|
||||
def _get_action_for_nsfw(category: str) -> str:
|
||||
"""Map NSFW category to suggested action."""
|
||||
mapping = {
|
||||
"suggestive": "warn",
|
||||
"suggestive": "none",
|
||||
"partial_nudity": "delete",
|
||||
"nudity": "delete",
|
||||
"explicit": "timeout",
|
||||
"explicit": "delete",
|
||||
}
|
||||
return mapping.get(category, "none")
|
||||
|
||||
|
||||
class AIModeration(commands.Cog):
|
||||
"""AI-powered content moderation."""
|
||||
"""AI-powered NSFW image detection with strict cost controls."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
# Track recently analyzed messages to avoid duplicates (deque auto-removes oldest)
|
||||
# Track recently analyzed messages to avoid duplicates (cost control)
|
||||
self._analyzed_messages: deque[int] = deque(maxlen=1000)
|
||||
|
||||
def cog_check(self, ctx: commands.Context) -> bool:
|
||||
"""Optional owner allowlist for AI 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."
|
||||
)
|
||||
# Load NSFW video domains from config
|
||||
global NSFW_VIDEO_DOMAINS
|
||||
NSFW_VIDEO_DOMAINS = bot.config_loader.get_setting("nsfw_video_domains", [])
|
||||
|
||||
def _should_analyze(self, message: discord.Message) -> bool:
|
||||
"""Determine if a message should be analyzed by AI."""
|
||||
# Skip if already analyzed
|
||||
# Skip if already analyzed (deduplication for cost control)
|
||||
if message.id in self._analyzed_messages:
|
||||
return False
|
||||
|
||||
# Skip short messages without media
|
||||
if len(message.content) < 20 and not message.attachments and not message.embeds:
|
||||
# Skip if no images/embeds
|
||||
if not message.attachments and not message.embeds:
|
||||
return False
|
||||
|
||||
# Skip messages from bots
|
||||
@@ -80,198 +63,22 @@ class AIModeration(commands.Cog):
|
||||
"""Track that a message has been analyzed."""
|
||||
self._analyzed_messages.append(message_id)
|
||||
|
||||
async def _handle_ai_result(
|
||||
self,
|
||||
message: discord.Message,
|
||||
result: ModerationResult,
|
||||
analysis_type: str,
|
||||
) -> None:
|
||||
"""Handle the result of AI analysis."""
|
||||
if not result.is_flagged:
|
||||
return
|
||||
def _has_nsfw_video_link(self, content: str) -> bool:
|
||||
"""Check if message contains NSFW video domain."""
|
||||
if not content:
|
||||
return False
|
||||
|
||||
config = await self.bot.guild_config.get_config(message.guild.id)
|
||||
if not config:
|
||||
return
|
||||
content_lower = content.lower()
|
||||
for domain in NSFW_VIDEO_DOMAINS:
|
||||
if domain.lower() in content_lower:
|
||||
logger.info(f"Blocked NSFW video domain: {domain}")
|
||||
return True
|
||||
|
||||
# Check NSFW-only filtering mode
|
||||
if config.nsfw_only_filtering:
|
||||
# Only process SEXUAL content when NSFW-only mode is enabled
|
||||
if ContentCategory.SEXUAL not in result.categories:
|
||||
logger.debug(
|
||||
"NSFW-only mode enabled, ignoring non-sexual content: categories=%s",
|
||||
[cat.value for cat in result.categories],
|
||||
)
|
||||
return
|
||||
|
||||
# Check if severity meets threshold based on sensitivity
|
||||
# Higher sensitivity = lower threshold needed to trigger
|
||||
threshold = 100 - config.ai_sensitivity # e.g., sensitivity 70 = threshold 30
|
||||
if result.severity < threshold:
|
||||
logger.debug(
|
||||
"AI flagged content but below threshold: severity=%s, threshold=%s",
|
||||
result.severity,
|
||||
threshold,
|
||||
)
|
||||
return
|
||||
|
||||
if result.confidence < config.ai_confidence_threshold:
|
||||
logger.debug(
|
||||
"AI flagged content but below confidence threshold: confidence=%s, threshold=%s",
|
||||
result.confidence,
|
||||
config.ai_confidence_threshold,
|
||||
)
|
||||
return
|
||||
|
||||
log_only = config.ai_log_only
|
||||
|
||||
# Determine action based on suggested action and severity
|
||||
should_delete = not log_only and result.suggested_action in ("delete", "timeout", "ban")
|
||||
should_timeout = (
|
||||
not log_only and result.suggested_action in ("timeout", "ban") and result.severity > 70
|
||||
)
|
||||
timeout_duration: int | None = None
|
||||
|
||||
# Delete message if needed
|
||||
if should_delete:
|
||||
try:
|
||||
await message.delete()
|
||||
except discord.Forbidden:
|
||||
logger.warning("Cannot delete message: missing permissions")
|
||||
except discord.NotFound:
|
||||
pass
|
||||
|
||||
# Timeout user for severe violations
|
||||
if should_timeout and isinstance(message.author, discord.Member):
|
||||
timeout_duration = 300 if result.severity < 90 else 3600 # 5 min or 1 hour
|
||||
try:
|
||||
await message.author.timeout(
|
||||
timedelta(seconds=timeout_duration),
|
||||
reason=f"AI Moderation: {result.explanation[:100]}",
|
||||
)
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
await self._log_ai_db_action(
|
||||
message,
|
||||
result,
|
||||
analysis_type,
|
||||
log_only=log_only,
|
||||
timeout_duration=timeout_duration,
|
||||
)
|
||||
|
||||
# Log to mod channel
|
||||
await self._log_ai_action(message, result, analysis_type, log_only=log_only)
|
||||
|
||||
if log_only:
|
||||
return
|
||||
|
||||
# Notify user
|
||||
embed = discord.Embed(
|
||||
title=f"Message Flagged in {message.guild.name}",
|
||||
description=result.explanation,
|
||||
color=discord.Color.red(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.add_field(
|
||||
name="Categories",
|
||||
value=", ".join(cat.value for cat in result.categories) or "Unknown",
|
||||
)
|
||||
if should_timeout:
|
||||
embed.add_field(name="Action", value="You have been timed out")
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
async def _log_ai_action(
|
||||
self,
|
||||
message: discord.Message,
|
||||
result: ModerationResult,
|
||||
analysis_type: str,
|
||||
log_only: bool = False,
|
||||
) -> None:
|
||||
"""Log an AI moderation action."""
|
||||
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=f"AI Moderation - {analysis_type}",
|
||||
color=discord.Color.red(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_author(
|
||||
name=str(message.author),
|
||||
icon_url=message.author.display_avatar.url,
|
||||
)
|
||||
|
||||
action_label = "log-only" if log_only else result.suggested_action
|
||||
embed.add_field(name="Confidence", value=f"{result.confidence:.0%}", inline=True)
|
||||
embed.add_field(name="Severity", value=f"{result.severity}/100", inline=True)
|
||||
embed.add_field(name="Action", value=action_label, inline=True)
|
||||
|
||||
categories = ", ".join(cat.value for cat in result.categories)
|
||||
embed.add_field(name="Categories", value=categories or "None", inline=False)
|
||||
embed.add_field(name="Explanation", value=result.explanation[:500], inline=False)
|
||||
|
||||
if message.content:
|
||||
content = (
|
||||
message.content[:500] + "..." if len(message.content) > 500 else message.content
|
||||
)
|
||||
embed.add_field(name="Content", value=f"```{content}```", inline=False)
|
||||
|
||||
embed.set_footer(text=f"User ID: {message.author.id} | Channel: #{message.channel.name}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def _log_ai_db_action(
|
||||
self,
|
||||
message: discord.Message,
|
||||
result: ModerationResult,
|
||||
analysis_type: str,
|
||||
log_only: bool,
|
||||
timeout_duration: int | None,
|
||||
) -> None:
|
||||
"""Log an AI moderation action to the database."""
|
||||
action = "ai_log" if log_only else f"ai_{result.suggested_action}"
|
||||
reason = result.explanation or f"AI moderation flagged content ({analysis_type})"
|
||||
expires_at = None
|
||||
if timeout_duration:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=timeout_duration)
|
||||
|
||||
async with self.bot.database.session() as session:
|
||||
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=reason,
|
||||
duration=timeout_duration,
|
||||
expires_at=expires_at,
|
||||
channel_id=message.channel.id,
|
||||
message_id=message.id,
|
||||
message_content=message.content,
|
||||
is_automatic=True,
|
||||
)
|
||||
session.add(entry)
|
||||
return False
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
"""Analyze messages with AI moderation."""
|
||||
logger.debug("AI moderation received message from %s", message.author)
|
||||
|
||||
"""Analyze messages for NSFW images with strict cost controls."""
|
||||
# Skip bot messages early
|
||||
if message.author.bot:
|
||||
return
|
||||
@@ -279,109 +86,164 @@ class AIModeration(commands.Cog):
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
logger.info(f"AI mod checking message from {message.author} in {message.guild.name}")
|
||||
|
||||
# Check if AI moderation is enabled for this guild
|
||||
config = await self.bot.guild_config.get_config(message.guild.id)
|
||||
if not config or not config.ai_moderation_enabled:
|
||||
logger.debug(f"AI moderation disabled for guild {message.guild.id}")
|
||||
# Check bot permissions in this channel
|
||||
if not message.channel.permissions_for(message.guild.me).manage_messages:
|
||||
logger.debug(f"Missing Manage Messages permission in #{message.channel.name}")
|
||||
return
|
||||
|
||||
# Check if user is whitelisted
|
||||
if message.author.id in config.whitelisted_user_ids:
|
||||
logger.debug(f"Skipping whitelisted user {message.author}")
|
||||
# Get config from YAML
|
||||
config = self.bot.config_loader
|
||||
if not config.get_setting("ai_moderation.enabled", True):
|
||||
return
|
||||
|
||||
# Skip users with manage_messages permission (disabled for testing)
|
||||
# if isinstance(message.author, discord.Member):
|
||||
# if message.author.guild_permissions.manage_messages:
|
||||
# logger.debug(f"Skipping message from privileged user {message.author}")
|
||||
# return
|
||||
|
||||
if not self._should_analyze(message):
|
||||
logger.debug(f"Message {message.id} skipped by _should_analyze")
|
||||
return
|
||||
|
||||
self._track_message(message.id)
|
||||
logger.info(f"Analyzing message {message.id} from {message.author}")
|
||||
|
||||
# Analyze text content
|
||||
if message.content and len(message.content) >= 20:
|
||||
result = await self.bot.ai_provider.moderate_text(
|
||||
content=message.content,
|
||||
context=f"Discord server: {message.guild.name}, channel: {message.channel.name}",
|
||||
sensitivity=config.ai_sensitivity,
|
||||
# Check user blocklist first (blocks ALL media from specific users)
|
||||
blocked_users = config.get_setting("blocked_user_ids", [])
|
||||
if message.author.id in blocked_users:
|
||||
# Check if message has any media content (images, embeds, URLs)
|
||||
has_media = (
|
||||
bool(message.attachments) or
|
||||
bool(message.embeds) or
|
||||
bool(URL_PATTERN.search(message.content))
|
||||
)
|
||||
|
||||
if result.is_flagged:
|
||||
await self._handle_ai_result(message, result, "Text Analysis")
|
||||
return # Don't continue if already flagged
|
||||
if has_media:
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(
|
||||
f"Deleted media content from blocked user {message.author} "
|
||||
f"({message.author.id}) in {message.guild.name}"
|
||||
)
|
||||
except (discord.Forbidden, discord.NotFound):
|
||||
logger.warning(f"Failed to delete message from blocked user {message.author.id}")
|
||||
return
|
||||
|
||||
# Check NSFW video domain blocklist (no AI cost)
|
||||
if self._has_nsfw_video_link(message.content):
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(f"Deleted message with NSFW video link from {message.author}")
|
||||
except (discord.Forbidden, discord.NotFound):
|
||||
pass
|
||||
return
|
||||
|
||||
# Check if should analyze (has images/embeds, not analyzed yet)
|
||||
if not self._should_analyze(message):
|
||||
logger.debug(
|
||||
f"Skipping analysis in #{message.channel.name}: "
|
||||
f"already_analyzed={message.id in self._analyzed_messages}, "
|
||||
f"has_media={bool(message.attachments or message.embeds)}"
|
||||
)
|
||||
return
|
||||
|
||||
# Log that we're about to check this message
|
||||
logger.info(
|
||||
f"Checking message from {message.author} in #{message.channel.name} "
|
||||
f"({len(message.attachments)} attachments, {len(message.embeds)} embeds)"
|
||||
)
|
||||
|
||||
# Check rate limits (CRITICAL for cost control)
|
||||
max_guild_per_hour = config.get_setting("ai_moderation.max_checks_per_hour_per_guild", 25)
|
||||
max_user_per_hour = config.get_setting("ai_moderation.max_checks_per_user_per_hour", 5)
|
||||
|
||||
rate_limit_result = self.bot.ai_rate_limiter.is_limited(
|
||||
message.guild.id,
|
||||
message.author.id,
|
||||
max_guild_per_hour,
|
||||
max_user_per_hour,
|
||||
)
|
||||
|
||||
if rate_limit_result["is_limited"]:
|
||||
logger.warning(
|
||||
f"AI rate limit hit in #{message.channel.name}: {rate_limit_result['reason']} "
|
||||
f"(guild: {rate_limit_result['guild_checks_this_hour']}/{max_guild_per_hour}, "
|
||||
f"user: {rate_limit_result['user_checks_this_hour']}/{max_user_per_hour})"
|
||||
)
|
||||
return
|
||||
|
||||
# Check if AI provider is configured
|
||||
if self.bot.ai_provider is None:
|
||||
logger.warning(
|
||||
f"AI provider not configured but ai_moderation.enabled=true. "
|
||||
f"Set GUARDDEN_AI_PROVIDER in .env to 'anthropic' or 'openai'"
|
||||
)
|
||||
return
|
||||
|
||||
# Get AI settings
|
||||
sensitivity = config.get_setting("ai_moderation.sensitivity", 80)
|
||||
nsfw_only_filtering = config.get_setting("ai_moderation.nsfw_only_filtering", True)
|
||||
max_images = config.get_setting("ai_moderation.max_images_per_message", 2)
|
||||
max_size_mb = config.get_setting("ai_moderation.max_image_size_mb", 3)
|
||||
max_size_bytes = max_size_mb * 1024 * 1024
|
||||
check_embeds = config.get_setting("ai_moderation.check_embed_images", True)
|
||||
|
||||
# Analyze images if NSFW detection is enabled (limit to 3 per message)
|
||||
images_analyzed = 0
|
||||
if config.nsfw_detection_enabled and message.attachments:
|
||||
logger.info(f"Checking {len(message.attachments)} attachments for NSFW content")
|
||||
|
||||
# Analyze image attachments
|
||||
if message.attachments:
|
||||
for attachment in message.attachments:
|
||||
if images_analyzed >= 3:
|
||||
if images_analyzed >= max_images:
|
||||
break
|
||||
if attachment.content_type and attachment.content_type.startswith("image/"):
|
||||
|
||||
# Skip non-images
|
||||
if not attachment.content_type or not attachment.content_type.startswith("image/"):
|
||||
continue
|
||||
|
||||
# Skip large files (cost control)
|
||||
if attachment.size > max_size_bytes:
|
||||
logger.debug(f"Skipping large image: {attachment.size} bytes > {max_size_bytes}")
|
||||
continue
|
||||
|
||||
images_analyzed += 1
|
||||
logger.info(f"Analyzing image: {attachment.url[:80]}...")
|
||||
|
||||
logger.info(f"Analyzing image {images_analyzed}/{max_images} from {message.author}")
|
||||
|
||||
# AI check
|
||||
try:
|
||||
image_result = await self.bot.ai_provider.analyze_image(
|
||||
image_url=attachment.url,
|
||||
sensitivity=config.ai_sensitivity,
|
||||
sensitivity=sensitivity,
|
||||
)
|
||||
logger.info(
|
||||
f"Image result: nsfw={image_result.is_nsfw}, category={image_result.nsfw_category}, "
|
||||
f"severity={image_result.nsfw_severity}, violent={image_result.is_violent}, conf={image_result.confidence}"
|
||||
except Exception as e:
|
||||
logger.error(f"AI image analysis failed: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Image result: nsfw={image_result.is_nsfw}, "
|
||||
f"category={image_result.nsfw_category}, "
|
||||
f"confidence={image_result.confidence}"
|
||||
)
|
||||
|
||||
# Filter based on NSFW-only mode setting
|
||||
should_flag_image = False
|
||||
categories = []
|
||||
# Track AI usage
|
||||
self.bot.ai_rate_limiter.track_usage(message.guild.id, message.author.id)
|
||||
self._track_message(message.id)
|
||||
|
||||
if config.nsfw_only_filtering:
|
||||
# In NSFW-only mode, only flag sexual content
|
||||
# Filter based on NSFW-only mode
|
||||
should_flag = False
|
||||
if nsfw_only_filtering:
|
||||
# Only flag sexual content
|
||||
if image_result.is_nsfw:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.SEXUAL)
|
||||
should_flag = True
|
||||
else:
|
||||
# Normal mode: flag all inappropriate content
|
||||
if image_result.is_nsfw:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.SEXUAL)
|
||||
if image_result.is_violent:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.VIOLENCE)
|
||||
if image_result.is_disturbing:
|
||||
should_flag_image = True
|
||||
# Flag all inappropriate content
|
||||
if image_result.is_nsfw or image_result.is_violent or image_result.is_disturbing:
|
||||
should_flag = True
|
||||
|
||||
if should_flag_image:
|
||||
# Use nsfw_severity if available, otherwise use None for default calculation
|
||||
severity_override = (
|
||||
image_result.nsfw_severity if image_result.nsfw_severity > 0 else None
|
||||
if should_flag:
|
||||
# Delete message (no logging, no timeout, no DM)
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(
|
||||
f"Deleted NSFW image from {message.author} in {message.guild.name}: "
|
||||
f"category={image_result.nsfw_category}, confidence={image_result.confidence:.2f}"
|
||||
)
|
||||
|
||||
# Include NSFW category in explanation for better logging
|
||||
explanation = image_result.description
|
||||
if image_result.nsfw_category and image_result.nsfw_category != "none":
|
||||
explanation = f"[{image_result.nsfw_category}] {explanation}"
|
||||
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=image_result.confidence,
|
||||
categories=categories,
|
||||
explanation=explanation,
|
||||
suggested_action=_get_action_for_nsfw(image_result.nsfw_category),
|
||||
severity_override=severity_override,
|
||||
)
|
||||
await self._handle_ai_result(message, result, "Image Analysis")
|
||||
except (discord.Forbidden, discord.NotFound):
|
||||
pass
|
||||
return
|
||||
|
||||
# Also analyze images from embeds (GIFs from Discord's GIF picker use embeds)
|
||||
if config.nsfw_detection_enabled and message.embeds:
|
||||
# Optionally check embed images (GIFs from Discord picker)
|
||||
if check_embeds and message.embeds:
|
||||
for embed in message.embeds:
|
||||
if images_analyzed >= 3:
|
||||
if images_analyzed >= max_images:
|
||||
break
|
||||
|
||||
# Check embed image or thumbnail (GIFs often use thumbnail)
|
||||
@@ -391,272 +253,56 @@ class AIModeration(commands.Cog):
|
||||
elif embed.thumbnail and embed.thumbnail.url:
|
||||
image_url = embed.thumbnail.url
|
||||
|
||||
if image_url:
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
images_analyzed += 1
|
||||
logger.info(f"Analyzing embed image: {image_url[:80]}...")
|
||||
|
||||
logger.info(f"Analyzing embed image {images_analyzed}/{max_images} from {message.author}")
|
||||
|
||||
# AI check
|
||||
try:
|
||||
image_result = await self.bot.ai_provider.analyze_image(
|
||||
image_url=image_url,
|
||||
sensitivity=config.ai_sensitivity,
|
||||
sensitivity=sensitivity,
|
||||
)
|
||||
logger.info(
|
||||
f"Embed image result: nsfw={image_result.is_nsfw}, category={image_result.nsfw_category}, "
|
||||
f"severity={image_result.nsfw_severity}, violent={image_result.is_violent}, conf={image_result.confidence}"
|
||||
)
|
||||
|
||||
# Filter based on NSFW-only mode setting
|
||||
should_flag_image = False
|
||||
categories = []
|
||||
|
||||
if config.nsfw_only_filtering:
|
||||
# In NSFW-only mode, only flag sexual content
|
||||
if image_result.is_nsfw:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.SEXUAL)
|
||||
else:
|
||||
# Normal mode: flag all inappropriate content
|
||||
if image_result.is_nsfw:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.SEXUAL)
|
||||
if image_result.is_violent:
|
||||
should_flag_image = True
|
||||
categories.append(ContentCategory.VIOLENCE)
|
||||
if image_result.is_disturbing:
|
||||
should_flag_image = True
|
||||
|
||||
if should_flag_image:
|
||||
# Use nsfw_severity if available, otherwise use None for default calculation
|
||||
severity_override = (
|
||||
image_result.nsfw_severity if image_result.nsfw_severity > 0 else None
|
||||
)
|
||||
|
||||
# Include NSFW category in explanation for better logging
|
||||
explanation = image_result.description
|
||||
if image_result.nsfw_category and image_result.nsfw_category != "none":
|
||||
explanation = f"[{image_result.nsfw_category}] {explanation}"
|
||||
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=image_result.confidence,
|
||||
categories=categories,
|
||||
explanation=explanation,
|
||||
suggested_action=_get_action_for_nsfw(image_result.nsfw_category),
|
||||
severity_override=severity_override,
|
||||
)
|
||||
await self._handle_ai_result(message, result, "Image Analysis")
|
||||
return
|
||||
|
||||
# Analyze URLs for phishing
|
||||
urls = URL_PATTERN.findall(message.content)
|
||||
allowlist = {normalize_domain(domain) for domain in config.scam_allowlist if domain}
|
||||
for url in urls[:3]: # Limit to first 3 URLs
|
||||
hostname = normalize_domain(url)
|
||||
if allowlist and is_allowed_domain(hostname, allowlist):
|
||||
except Exception as e:
|
||||
logger.error(f"AI embed image analysis failed: {e}", exc_info=True)
|
||||
continue
|
||||
phishing_result = await self.bot.ai_provider.analyze_phishing(
|
||||
url=url,
|
||||
message_content=message.content,
|
||||
|
||||
logger.debug(
|
||||
f"Embed image result: nsfw={image_result.is_nsfw}, "
|
||||
f"category={image_result.nsfw_category}, "
|
||||
f"confidence={image_result.confidence}"
|
||||
)
|
||||
|
||||
if phishing_result.is_phishing and phishing_result.confidence > 0.7:
|
||||
result = ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=phishing_result.confidence,
|
||||
categories=[ContentCategory.SCAM],
|
||||
explanation=phishing_result.explanation,
|
||||
suggested_action="delete",
|
||||
)
|
||||
await self._handle_ai_result(message, result, "Phishing Detection")
|
||||
return
|
||||
# Track AI usage
|
||||
self.bot.ai_rate_limiter.track_usage(message.guild.id, message.author.id)
|
||||
self._track_message(message.id)
|
||||
|
||||
@commands.group(name="ai", invoke_without_command=True)
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_cmd(self, ctx: commands.Context) -> None:
|
||||
"""View AI moderation settings."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="AI Moderation Settings",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="AI Moderation",
|
||||
value="✅ Enabled" if config and config.ai_moderation_enabled else "❌ Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="NSFW Detection",
|
||||
value="✅ Enabled" if config and config.nsfw_detection_enabled else "❌ Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Sensitivity",
|
||||
value=f"{config.ai_sensitivity}/100" if config else "50/100",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Confidence Threshold",
|
||||
value=f"{config.ai_confidence_threshold:.2f}" if config else "0.70",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Log Only",
|
||||
value="✅ Enabled" if config and config.ai_log_only else "❌ Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="NSFW-Only Mode",
|
||||
value="✅ Enabled" if config and config.nsfw_only_filtering else "❌ Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="AI Provider",
|
||||
value=self.bot.settings.ai_provider.capitalize(),
|
||||
inline=True,
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@ai_cmd.command(name="enable")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_enable(self, ctx: commands.Context) -> None:
|
||||
"""Enable AI moderation."""
|
||||
if self.bot.settings.ai_provider == "none":
|
||||
await ctx.send(
|
||||
"AI moderation is not configured. Set `GUARDDEN_AI_PROVIDER` and API key."
|
||||
)
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, ai_moderation_enabled=True)
|
||||
await ctx.send("✅ AI moderation enabled.")
|
||||
|
||||
@ai_cmd.command(name="disable")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_disable(self, ctx: commands.Context) -> None:
|
||||
"""Disable AI moderation."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, ai_moderation_enabled=False)
|
||||
await ctx.send("❌ AI moderation disabled.")
|
||||
|
||||
@ai_cmd.command(name="sensitivity")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_sensitivity(self, ctx: commands.Context, level: int) -> None:
|
||||
"""Set AI sensitivity level (0-100). Higher = more strict."""
|
||||
if not 0 <= level <= 100:
|
||||
await ctx.send("Sensitivity must be between 0 and 100.")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, ai_sensitivity=level)
|
||||
await ctx.send(f"AI sensitivity set to {level}/100.")
|
||||
|
||||
@ai_cmd.command(name="threshold")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_threshold(self, ctx: commands.Context, value: float) -> None:
|
||||
"""Set AI confidence threshold (0.0-1.0)."""
|
||||
if not 0.0 <= value <= 1.0:
|
||||
await ctx.send("Threshold must be between 0.0 and 1.0.")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, ai_confidence_threshold=value)
|
||||
await ctx.send(f"AI confidence threshold set to {value:.2f}.")
|
||||
|
||||
@ai_cmd.command(name="logonly")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_logonly(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable log-only mode for AI moderation."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, ai_log_only=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
await ctx.send(f"AI log-only mode {status}.")
|
||||
|
||||
@ai_cmd.command(name="nsfw")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_nsfw(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable NSFW image detection."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, nsfw_detection_enabled=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
await ctx.send(f"NSFW detection {status}.")
|
||||
|
||||
@ai_cmd.command(name="nsfwonly")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_nsfw_only(self, ctx: commands.Context, enabled: bool) -> None:
|
||||
"""Enable or disable NSFW-only filtering mode.
|
||||
|
||||
When enabled, only sexual/nude content will be filtered.
|
||||
Violence, harassment, and other content types will be allowed.
|
||||
"""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, nsfw_only_filtering=enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
|
||||
if enabled:
|
||||
embed = discord.Embed(
|
||||
title="NSFW-Only Mode Enabled",
|
||||
description="⚠️ **Important:** Only sexual and nude content will now be filtered.\n"
|
||||
"Violence, harassment, hate speech, and other content types will be **allowed**.",
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="What will be filtered:",
|
||||
value="• Sexual content\n• Nude images\n• Explicit material",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="What will be allowed:",
|
||||
value="• Violence and gore\n• Harassment\n• Hate speech\n• Self-harm content",
|
||||
inline=True,
|
||||
)
|
||||
embed.set_footer(text="Use '!ai nsfwonly false' to return to normal filtering")
|
||||
# Filter based on NSFW-only mode
|
||||
should_flag = False
|
||||
if nsfw_only_filtering:
|
||||
# Only flag sexual content
|
||||
if image_result.is_nsfw:
|
||||
should_flag = True
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title="NSFW-Only Mode Disabled",
|
||||
description="✅ Normal content filtering restored.\n"
|
||||
"All inappropriate content types will now be filtered.",
|
||||
color=discord.Color.green(),
|
||||
# Flag all inappropriate content
|
||||
if image_result.is_nsfw or image_result.is_violent or image_result.is_disturbing:
|
||||
should_flag = True
|
||||
|
||||
if should_flag:
|
||||
# Delete message (no logging, no timeout, no DM)
|
||||
try:
|
||||
await message.delete()
|
||||
logger.info(
|
||||
f"Deleted NSFW embed from {message.author} in {message.guild.name}: "
|
||||
f"category={image_result.nsfw_category}, confidence={image_result.confidence:.2f}"
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@ai_cmd.command(name="analyze")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def ai_analyze(self, ctx: commands.Context, *, text: str) -> None:
|
||||
"""Test AI analysis on text (does not take action)."""
|
||||
if self.bot.settings.ai_provider == "none":
|
||||
await ctx.send("AI moderation is not configured.")
|
||||
except (discord.Forbidden, discord.NotFound):
|
||||
pass
|
||||
return
|
||||
|
||||
async with ctx.typing():
|
||||
result = await self.bot.ai_provider.moderate_text(
|
||||
content=text,
|
||||
context=f"Test analysis in {ctx.guild.name}",
|
||||
sensitivity=50,
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="AI Analysis Result",
|
||||
color=discord.Color.red() if result.is_flagged else discord.Color.green(),
|
||||
)
|
||||
|
||||
embed.add_field(name="Flagged", value="Yes" if result.is_flagged else "No", inline=True)
|
||||
embed.add_field(name="Confidence", value=f"{result.confidence:.0%}", inline=True)
|
||||
embed.add_field(name="Severity", value=f"{result.severity}/100", inline=True)
|
||||
embed.add_field(name="Suggested Action", value=result.suggested_action, inline=True)
|
||||
|
||||
if result.categories:
|
||||
categories = ", ".join(cat.value for cat in result.categories)
|
||||
embed.add_field(name="Categories", value=categories, inline=False)
|
||||
|
||||
if result.explanation:
|
||||
embed.add_field(name="Explanation", value=result.explanation[:1000], inline=False)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the AI Moderation cog."""
|
||||
|
||||
@@ -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)
|
||||
def _spam_config(self) -> SpamConfig:
|
||||
"""Get spam config from YAML."""
|
||||
config_loader = self.bot.config_loader
|
||||
|
||||
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
|
||||
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:
|
||||
# Get config from YAML
|
||||
config = self.bot.config_loader
|
||||
if not config.get_setting("automod.enabled", True):
|
||||
return
|
||||
|
||||
# Get guild config
|
||||
config = await self.bot.guild_config.get_config(message.guild.id)
|
||||
if not config or not config.automod_enabled:
|
||||
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)
|
||||
|
||||
@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."""
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""Event handlers for logging and monitoring."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Events(commands.Cog):
|
||||
"""Handles Discord events for logging and monitoring."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member) -> None:
|
||||
"""Called when a member joins a guild."""
|
||||
logger.debug(f"Member joined: {member} in {member.guild}")
|
||||
|
||||
config = await self.bot.guild_config.get_config(member.guild.id)
|
||||
if not config or not config.log_channel_id:
|
||||
return
|
||||
|
||||
channel = member.guild.get_channel(config.log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Member Joined",
|
||||
description=f"{member.mention} ({member})",
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_thumbnail(url=member.display_avatar.url)
|
||||
embed.add_field(
|
||||
name="Account Created", value=discord.utils.format_dt(member.created_at, "R")
|
||||
)
|
||||
embed.add_field(name="Member ID", value=str(member.id))
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member: discord.Member) -> None:
|
||||
"""Called when a member leaves a guild."""
|
||||
logger.debug(f"Member left: {member} from {member.guild}")
|
||||
|
||||
config = await self.bot.guild_config.get_config(member.guild.id)
|
||||
if not config or not config.log_channel_id:
|
||||
return
|
||||
|
||||
channel = member.guild.get_channel(config.log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Member Left",
|
||||
description=f"{member} ({member.id})",
|
||||
color=discord.Color.orange(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_thumbnail(url=member.display_avatar.url)
|
||||
|
||||
if member.joined_at:
|
||||
embed.add_field(name="Joined", value=discord.utils.format_dt(member.joined_at, "R"))
|
||||
|
||||
roles = [r.mention for r in member.roles if r != member.guild.default_role]
|
||||
if roles:
|
||||
embed.add_field(name="Roles", value=", ".join(roles[:10]), inline=False)
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message_delete(self, message: discord.Message) -> None:
|
||||
"""Called when a message is deleted."""
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
config = await self.bot.guild_config.get_config(message.guild.id)
|
||||
if not config or not config.log_channel_id:
|
||||
return
|
||||
|
||||
channel = message.guild.get_channel(config.log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Message Deleted",
|
||||
description=f"In {message.channel.mention}",
|
||||
color=discord.Color.red(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(message.author), icon_url=message.author.display_avatar.url)
|
||||
|
||||
if message.content:
|
||||
content = message.content[:1024] if len(message.content) > 1024 else message.content
|
||||
embed.add_field(name="Content", value=content, inline=False)
|
||||
|
||||
if message.attachments:
|
||||
attachments = "\n".join(a.filename for a in message.attachments)
|
||||
embed.add_field(name="Attachments", value=attachments, inline=False)
|
||||
|
||||
embed.set_footer(text=f"Author ID: {message.author.id} | Message ID: {message.id}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
|
||||
"""Called when a message is edited."""
|
||||
if before.author.bot or not before.guild:
|
||||
return
|
||||
|
||||
if before.content == after.content:
|
||||
return
|
||||
|
||||
config = await self.bot.guild_config.get_config(before.guild.id)
|
||||
if not config or not config.log_channel_id:
|
||||
return
|
||||
|
||||
channel = before.guild.get_channel(config.log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Message Edited",
|
||||
description=f"In {before.channel.mention} | [Jump to message]({after.jump_url})",
|
||||
color=discord.Color.blue(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_author(name=str(before.author), icon_url=before.author.display_avatar.url)
|
||||
|
||||
before_content = before.content[:1024] if len(before.content) > 1024 else before.content
|
||||
after_content = after.content[:1024] if len(after.content) > 1024 else after.content
|
||||
|
||||
embed.add_field(name="Before", value=before_content or "*empty*", inline=False)
|
||||
embed.add_field(name="After", value=after_content or "*empty*", inline=False)
|
||||
embed.set_footer(text=f"Author ID: {before.author.id}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_voice_state_update(
|
||||
self,
|
||||
member: discord.Member,
|
||||
before: discord.VoiceState,
|
||||
after: discord.VoiceState,
|
||||
) -> None:
|
||||
"""Called when a member's voice state changes."""
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
config = await self.bot.guild_config.get_config(member.guild.id)
|
||||
if not config or not config.log_channel_id:
|
||||
return
|
||||
|
||||
channel = member.guild.get_channel(config.log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = None
|
||||
|
||||
if before.channel is None and after.channel is not None:
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Joined",
|
||||
description=f"{member.mention} joined {after.channel.mention}",
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
elif before.channel is not None and after.channel is None:
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Left",
|
||||
description=f"{member.mention} left {before.channel.mention}",
|
||||
color=discord.Color.orange(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
elif before.channel != after.channel and before.channel and after.channel:
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Moved",
|
||||
description=f"{member.mention} moved from {before.channel.mention} to {after.channel.mention}",
|
||||
color=discord.Color.blue(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
if embed:
|
||||
embed.set_author(name=str(member), icon_url=member.display_avatar.url)
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_ban(self, guild: discord.Guild, user: discord.User) -> None:
|
||||
"""Called when a user is banned."""
|
||||
config = await self.bot.guild_config.get_config(guild.id)
|
||||
if not config or not config.mod_log_channel_id:
|
||||
return
|
||||
|
||||
channel = guild.get_channel(config.mod_log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Member Banned",
|
||||
description=f"{user} ({user.id})",
|
||||
color=discord.Color.dark_red(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_unban(self, guild: discord.Guild, user: discord.User) -> None:
|
||||
"""Called when a user is unbanned."""
|
||||
config = await self.bot.guild_config.get_config(guild.id)
|
||||
if not config or not config.mod_log_channel_id:
|
||||
return
|
||||
|
||||
channel = guild.get_channel(config.mod_log_channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Member Unbanned",
|
||||
description=f"{user} ({user.id})",
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
|
||||
await channel.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the Events cog."""
|
||||
await bot.add_cog(Events(bot))
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Health check commands."""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from sqlalchemy import select
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
from guardden.utils.ratelimit import RateLimitExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Health(commands.Cog):
|
||||
"""Health checks for the bot."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def cog_check(self, ctx: commands.Context) -> bool:
|
||||
if not ctx.guild:
|
||||
return False
|
||||
if not self.bot.is_owner_allowed(ctx.author.id):
|
||||
return False
|
||||
return ctx.author.guild_permissions.administrator
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
@commands.command(name="health")
|
||||
@commands.guild_only()
|
||||
async def health(self, ctx: commands.Context) -> None:
|
||||
"""Check database and AI provider health."""
|
||||
db_status = "ok"
|
||||
try:
|
||||
async with self.bot.database.session() as session:
|
||||
await session.execute(select(1))
|
||||
except Exception as exc: # pragma: no cover - external dependency
|
||||
logger.exception("Health check database failure")
|
||||
db_status = f"error: {exc}"
|
||||
|
||||
ai_status = "disabled"
|
||||
if self.bot.settings.ai_provider != "none":
|
||||
ai_status = "ok" if self.bot.ai_provider else "unavailable"
|
||||
|
||||
embed = discord.Embed(title="GuardDen Health", color=discord.Color.green())
|
||||
embed.add_field(name="Database", value=db_status, inline=False)
|
||||
embed.add_field(name="AI Provider", value=ai_status, inline=False)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the health cog."""
|
||||
await bot.add_cog(Health(bot))
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Custom help command for GuardDen."""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuardDenHelpCommand(commands.HelpCommand):
|
||||
"""Custom help command with embed formatting and permission filtering."""
|
||||
|
||||
# Friendly category names with emojis
|
||||
CATEGORY_NAMES = {
|
||||
"Moderation": "🛡️ Moderation",
|
||||
"Admin": "⚙️ Server Configuration",
|
||||
"Automod": "🤖 Automatic Moderation",
|
||||
"AiModeration": "🧠 AI Moderation",
|
||||
"Verification": "✅ Member Verification",
|
||||
"Health": "💊 System Health",
|
||||
"WordlistSync": "📝 Wordlist Sync",
|
||||
}
|
||||
|
||||
# Category descriptions
|
||||
CATEGORY_DESCRIPTIONS = {
|
||||
"Moderation": "Server moderation tools",
|
||||
"Admin": "Bot settings and configuration",
|
||||
"Automod": "Automatic content filtering rules",
|
||||
"AiModeration": "AI-powered content moderation",
|
||||
"Verification": "New member verification system",
|
||||
"Health": "System diagnostics",
|
||||
"WordlistSync": "Wordlist synchronization",
|
||||
}
|
||||
|
||||
def get_command_signature(self, command: commands.Command) -> str:
|
||||
"""Get the command signature showing usage."""
|
||||
parent = command.full_parent_name
|
||||
alias = command.name if not parent else f"{parent} {command.name}"
|
||||
return f"{self.context.clean_prefix}{alias} {command.signature}"
|
||||
|
||||
def get_cog_display_name(self, cog_name: str) -> str:
|
||||
"""Get user-friendly display name for a cog."""
|
||||
return self.CATEGORY_NAMES.get(cog_name, cog_name)
|
||||
|
||||
def get_cog_description(self, cog_name: str) -> str:
|
||||
"""Get description for a cog."""
|
||||
return self.CATEGORY_DESCRIPTIONS.get(cog_name, "Commands")
|
||||
|
||||
def _get_permission_info(self, command: commands.Command) -> tuple[str, discord.Color]:
|
||||
"""Get permission requirement text and color for a command."""
|
||||
# Check cog-level restrictions
|
||||
if command.cog:
|
||||
cog_name = command.cog.qualified_name
|
||||
if cog_name == "Admin":
|
||||
return "🔒 Admin Only", discord.Color.red()
|
||||
elif cog_name == "Moderation":
|
||||
return "🛡️ Moderator/Owner", discord.Color.orange()
|
||||
elif cog_name == "WordlistSync":
|
||||
return "🔒 Admin Only", discord.Color.red()
|
||||
|
||||
# Check command-level checks
|
||||
if hasattr(command.callback, "__commands_checks__"):
|
||||
checks = command.callback.__commands_checks__
|
||||
for check in checks:
|
||||
check_name = getattr(check, "__name__", "")
|
||||
if "is_owner" in check_name:
|
||||
return "👑 Bot Owner Only", discord.Color.dark_red()
|
||||
elif "has_permissions" in check_name or "administrator" in check_name:
|
||||
return "🔒 Admin Only", discord.Color.red()
|
||||
|
||||
return "👥 Everyone", discord.Color.green()
|
||||
|
||||
async def send_bot_help(self, mapping: dict) -> None:
|
||||
"""Send the main help menu showing all commands with detailed information."""
|
||||
embeds = []
|
||||
prefix = self.context.clean_prefix
|
||||
|
||||
# Create overview embed
|
||||
overview = discord.Embed(
|
||||
title="📚 GuardDen Help - All Commands",
|
||||
description=f"A comprehensive Discord moderation bot\n\n"
|
||||
f"**Legend:**\n"
|
||||
f"👥 Everyone can use | 🛡️ Moderators/Owners | 🔒 Admins | 👑 Bot Owner",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
overview.set_footer(text=f"Prefix: {prefix} (customizable per server)")
|
||||
embeds.append(overview)
|
||||
|
||||
# Collect all commands organized by category
|
||||
for cog, cog_commands in mapping.items():
|
||||
if cog is None:
|
||||
continue
|
||||
|
||||
# Get all commands (don't filter by permissions for full overview)
|
||||
all_commands = sorted(cog_commands, key=lambda c: c.qualified_name)
|
||||
if not all_commands:
|
||||
continue
|
||||
|
||||
cog_name = cog.qualified_name
|
||||
display_name = self.get_cog_display_name(cog_name)
|
||||
|
||||
# Create embed for this category
|
||||
embed = discord.Embed(
|
||||
title=display_name,
|
||||
description=self.get_cog_description(cog_name),
|
||||
color=discord.Color.gold() if "Admin" in display_name else discord.Color.blue(),
|
||||
)
|
||||
|
||||
# Add each command with full details
|
||||
for command in all_commands:
|
||||
perm_text, _ = self._get_permission_info(command)
|
||||
|
||||
# Build command signature with all parameters
|
||||
signature_parts = [command.name]
|
||||
if command.signature:
|
||||
signature_parts.append(command.signature)
|
||||
|
||||
full_signature = f"{prefix}{' '.join(signature_parts)}"
|
||||
|
||||
# Build description
|
||||
desc_parts = []
|
||||
|
||||
# Add help text
|
||||
if command.help:
|
||||
desc_parts.append(command.help.split("\n")[0])
|
||||
else:
|
||||
desc_parts.append("No description available")
|
||||
|
||||
# Add aliases if present
|
||||
if command.aliases:
|
||||
desc_parts.append(f"*Aliases: {', '.join(command.aliases)}*")
|
||||
|
||||
# Add permission requirement
|
||||
desc_parts.append(f"**Permission:** {perm_text}")
|
||||
|
||||
# Add parameter details if present
|
||||
if command.clean_params:
|
||||
param_details = []
|
||||
for param_name, param in command.clean_params.items():
|
||||
if param.default is param.empty:
|
||||
param_details.append(f"`{param_name}` (required)")
|
||||
else:
|
||||
default_val = param.default if param.default is not None else "None"
|
||||
param_details.append(f"`{param_name}` (default: {default_val})")
|
||||
|
||||
if param_details:
|
||||
desc_parts.append(f"**Options:** {', '.join(param_details)}")
|
||||
|
||||
# Handle subcommands for groups
|
||||
if isinstance(command, commands.Group):
|
||||
subcommands = list(command.commands)
|
||||
if subcommands:
|
||||
subcommand_names = ", ".join(
|
||||
f"`{cmd.name}`" for cmd in sorted(subcommands, key=lambda c: c.name)
|
||||
)
|
||||
desc_parts.append(f"**Subcommands:** {subcommand_names}")
|
||||
|
||||
description = "\n".join(desc_parts)
|
||||
|
||||
embed.add_field(
|
||||
name=f"`{full_signature}`",
|
||||
value=description,
|
||||
inline=False,
|
||||
)
|
||||
|
||||
embeds.append(embed)
|
||||
|
||||
# Send all embeds
|
||||
channel = self.get_destination()
|
||||
for embed in embeds:
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def send_cog_help(self, cog: commands.Cog) -> None:
|
||||
"""Send help for a specific category/cog."""
|
||||
# Get all commands (show all, not just what user can run)
|
||||
all_commands = sorted(cog.get_commands(), key=lambda c: c.qualified_name)
|
||||
|
||||
if not all_commands:
|
||||
await self.get_destination().send(f"No commands available in this category.")
|
||||
return
|
||||
|
||||
cog_name = cog.qualified_name
|
||||
display_name = self.get_cog_display_name(cog_name)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{display_name} Commands",
|
||||
description=f"{cog.description or 'Commands in this category'}\n\n"
|
||||
f"**Legend:** 👥 Everyone | 🛡️ Moderators/Owners | 🔒 Admins | 👑 Bot Owner",
|
||||
color=discord.Color.gold() if "Admin" in display_name else discord.Color.blue(),
|
||||
)
|
||||
|
||||
# Show each command with full details
|
||||
for command in all_commands:
|
||||
# Get permission info
|
||||
perm_text, _ = self._get_permission_info(command)
|
||||
|
||||
# Get command signature
|
||||
signature = self.get_command_signature(command)
|
||||
|
||||
# Build description
|
||||
desc_parts = []
|
||||
if command.help:
|
||||
desc_parts.append(command.help.split("\n")[0]) # First line only
|
||||
else:
|
||||
desc_parts.append("No description available")
|
||||
|
||||
if command.aliases:
|
||||
desc_parts.append(f"*Aliases: {', '.join(command.aliases)}*")
|
||||
|
||||
# Add permission info
|
||||
desc_parts.append(f"**Permission:** {perm_text}")
|
||||
|
||||
# Add parameter info
|
||||
if command.clean_params:
|
||||
param_count = len(command.clean_params)
|
||||
required_count = sum(
|
||||
1 for p in command.clean_params.values() if p.default is p.empty
|
||||
)
|
||||
desc_parts.append(
|
||||
f"**Parameters:** {required_count} required, {param_count - required_count} optional"
|
||||
)
|
||||
|
||||
description = "\n".join(desc_parts)
|
||||
|
||||
embed.add_field(
|
||||
name=f"`{signature}`",
|
||||
value=description,
|
||||
inline=False,
|
||||
)
|
||||
|
||||
embed.set_footer(text=f"Use {self.context.clean_prefix}help <command> for detailed info")
|
||||
|
||||
channel = self.get_destination()
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def send_group_help(self, group: commands.Group) -> None:
|
||||
"""Send help for a command group."""
|
||||
embed = discord.Embed(
|
||||
title=f"Command Group: {group.qualified_name}",
|
||||
description=group.help or "No description available",
|
||||
color=discord.Color.blurple(),
|
||||
)
|
||||
|
||||
# Add usage
|
||||
signature = self.get_command_signature(group)
|
||||
embed.add_field(
|
||||
name="Usage",
|
||||
value=f"`{signature}`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# List subcommands
|
||||
filtered = await self.filter_commands(group.commands, sort=True)
|
||||
if filtered:
|
||||
subcommands_text = []
|
||||
for command in filtered:
|
||||
sig = f"{self.context.clean_prefix}{command.qualified_name} {command.signature}"
|
||||
desc = command.help.split("\n")[0] if command.help else "No description"
|
||||
subcommands_text.append(f"`{sig}`\n{desc}")
|
||||
|
||||
embed.add_field(
|
||||
name="Subcommands",
|
||||
value="\n\n".join(subcommands_text[:10]), # Limit to 10 to avoid embed size limits
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if len(filtered) > 10:
|
||||
embed.add_field(
|
||||
name="More...",
|
||||
value=f"And {len(filtered) - 10} more subcommands",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Add aliases
|
||||
if group.aliases:
|
||||
embed.add_field(
|
||||
name="Aliases",
|
||||
value=", ".join(f"`{alias}`" for alias in group.aliases),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
channel = self.get_destination()
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def send_command_help(self, command: commands.Command) -> None:
|
||||
"""Send help for a specific command."""
|
||||
perm_text, perm_color = self._get_permission_info(command)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Command: {command.qualified_name}",
|
||||
description=command.help or "No description available",
|
||||
color=perm_color,
|
||||
)
|
||||
|
||||
# Add usage
|
||||
signature = self.get_command_signature(command)
|
||||
embed.add_field(
|
||||
name="Usage",
|
||||
value=f"`{signature}`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Add permission requirement prominently
|
||||
embed.add_field(
|
||||
name="Permission Required",
|
||||
value=perm_text,
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Add aliases
|
||||
if command.aliases:
|
||||
embed.add_field(
|
||||
name="Aliases",
|
||||
value=", ".join(f"`{alias}`" for alias in command.aliases),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Add parameter details if available
|
||||
if command.clean_params:
|
||||
params_text = []
|
||||
for param_name, param in command.clean_params.items():
|
||||
# Get parameter annotation for type hint
|
||||
param_type = ""
|
||||
if param.annotation is not param.empty:
|
||||
type_name = getattr(param.annotation, "__name__", str(param.annotation))
|
||||
param_type = f" ({type_name})"
|
||||
|
||||
# Determine if required or optional
|
||||
if param.default is param.empty:
|
||||
params_text.append(f"`{param_name}`{param_type} - **Required**")
|
||||
else:
|
||||
default_val = param.default if param.default is not None else "None"
|
||||
params_text.append(
|
||||
f"`{param_name}`{param_type} - Optional (default: `{default_val}`)"
|
||||
)
|
||||
|
||||
if params_text:
|
||||
embed.add_field(
|
||||
name="Parameters",
|
||||
value="\n".join(params_text),
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# Add category info
|
||||
if command.cog:
|
||||
cog_name = command.cog.qualified_name
|
||||
embed.set_footer(text=f"Category: {self.get_cog_display_name(cog_name)}")
|
||||
|
||||
channel = self.get_destination()
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def send_error_message(self, error: str) -> None:
|
||||
"""Send an error message."""
|
||||
embed = discord.Embed(
|
||||
title="Help Error",
|
||||
description=error,
|
||||
color=discord.Color.red(),
|
||||
)
|
||||
embed.set_footer(text=f"Use {self.context.clean_prefix}help for available commands")
|
||||
|
||||
channel = self.get_destination()
|
||||
await channel.send(embed=embed)
|
||||
|
||||
async def command_not_found(self, string: str) -> str:
|
||||
"""Handle command not found error."""
|
||||
return f"No command or category called `{string}` found."
|
||||
|
||||
async def subcommand_not_found(self, command: commands.Command, string: str) -> str:
|
||||
"""Handle subcommand not found error."""
|
||||
if isinstance(command, commands.Group) and len(command.all_commands) > 0:
|
||||
return f"Command `{command.qualified_name}` has no subcommand named `{string}`."
|
||||
return f"Command `{command.qualified_name}` has no subcommands."
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Set up the help command."""
|
||||
bot.help_command = GuardDenHelpCommand()
|
||||
logger.info("Custom help command loaded")
|
||||
@@ -1,513 +0,0 @@
|
||||
"""Moderation commands and automod features."""
|
||||
|
||||
import logging
|
||||
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
|
||||
from guardden.utils import parse_duration
|
||||
from guardden.utils.notifications import send_moderation_notification
|
||||
from guardden.utils.ratelimit import RateLimitExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Moderation(commands.Cog):
|
||||
"""Moderation commands for server management."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def cog_check(self, ctx: commands.Context) -> bool:
|
||||
if not ctx.guild:
|
||||
return False
|
||||
if not self.bot.is_owner_allowed(ctx.author.id):
|
||||
return False
|
||||
return True
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# Notify the user
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
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)
|
||||
|
||||
# Use notification utility to send DM with in-channel fallback
|
||||
if isinstance(ctx.channel, discord.TextChannel):
|
||||
await send_moderation_notification(
|
||||
user=member,
|
||||
channel=ctx.channel,
|
||||
embed=dm_embed,
|
||||
send_in_channel=config.send_in_channel_warnings if config else False,
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
# Notify the user before kicking
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
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)
|
||||
|
||||
# Use notification utility to send DM with in-channel fallback
|
||||
if isinstance(ctx.channel, discord.TextChannel):
|
||||
await send_moderation_notification(
|
||||
user=member,
|
||||
channel=ctx.channel,
|
||||
embed=dm_embed,
|
||||
send_in_channel=config.send_in_channel_warnings if config else False,
|
||||
)
|
||||
|
||||
try:
|
||||
await member.kick(reason=f"{ctx.author}: {reason}")
|
||||
except discord.Forbidden:
|
||||
await ctx.send("❌ I don't have permission to kick this member.")
|
||||
return
|
||||
except discord.HTTPException as e:
|
||||
await ctx.send(f"❌ Failed to kick member: {e}")
|
||||
return
|
||||
|
||||
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}")
|
||||
|
||||
try:
|
||||
await ctx.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
await ctx.send(f"✅ {member} has been kicked from the server.")
|
||||
|
||||
@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
|
||||
|
||||
# Notify the user before banning
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
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)
|
||||
|
||||
# Use notification utility to send DM with in-channel fallback
|
||||
if isinstance(ctx.channel, discord.TextChannel):
|
||||
await send_moderation_notification(
|
||||
user=member,
|
||||
channel=ctx.channel,
|
||||
embed=dm_embed,
|
||||
send_in_channel=config.send_in_channel_warnings if config else False,
|
||||
)
|
||||
|
||||
try:
|
||||
await ctx.guild.ban(member, reason=f"{ctx.author}: {reason}", delete_message_days=0)
|
||||
except discord.Forbidden:
|
||||
await ctx.send("❌ I don't have permission to ban this member.")
|
||||
return
|
||||
except discord.HTTPException as e:
|
||||
await ctx.send(f"❌ Failed to ban member: {e}")
|
||||
return
|
||||
|
||||
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}")
|
||||
|
||||
try:
|
||||
await ctx.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
await ctx.send(f"✅ {member} has been banned from the server.")
|
||||
|
||||
@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))
|
||||
105
src/guardden/cogs/owner.py
Normal file
105
src/guardden/cogs/owner.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Owner-only commands for bot maintenance."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Owner(commands.Cog):
|
||||
"""Owner-only commands for debugging and maintenance."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
self.start_time = datetime.now(timezone.utc)
|
||||
|
||||
@commands.command(name="status")
|
||||
@commands.is_owner()
|
||||
async def status_cmd(self, ctx: commands.Context) -> None:
|
||||
"""Show bot status and AI usage statistics."""
|
||||
uptime = datetime.now(timezone.utc) - self.start_time
|
||||
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="GuardDen Status",
|
||||
color=discord.Color.blue(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Bot info
|
||||
embed.add_field(
|
||||
name="Bot Info",
|
||||
value=f"**Uptime:** {hours}h {minutes}m {seconds}s\n"
|
||||
f"**Guilds:** {len(self.bot.guilds)}\n"
|
||||
f"**Users:** {sum(g.member_count or 0 for g in self.bot.guilds)}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
# AI provider info
|
||||
ai_status = "None (Disabled)" if self.bot.settings.ai_provider == "none" else self.bot.settings.ai_provider.capitalize()
|
||||
embed.add_field(
|
||||
name="AI Provider",
|
||||
value=ai_status,
|
||||
inline=True,
|
||||
)
|
||||
|
||||
# Config status
|
||||
config_loaded = "Yes" if hasattr(self.bot, 'config_loader') and self.bot.config_loader.config else "No"
|
||||
embed.add_field(
|
||||
name="Config Loaded",
|
||||
value=config_loaded,
|
||||
inline=True,
|
||||
)
|
||||
|
||||
# AI usage stats (if available)
|
||||
if hasattr(self.bot, 'ai_rate_limiter'):
|
||||
for guild in self.bot.guilds:
|
||||
stats = self.bot.ai_rate_limiter.get_stats(guild.id)
|
||||
if stats['guild_checks_this_hour'] > 0:
|
||||
max_checks = self.bot.config_loader.get_setting('ai_moderation.max_checks_per_hour_per_guild', 25)
|
||||
usage_pct = (stats['guild_checks_this_hour'] / max_checks) * 100
|
||||
|
||||
status_emoji = "🟢" if usage_pct < 50 else "🟡" if usage_pct < 80 else "🔴"
|
||||
|
||||
embed.add_field(
|
||||
name=f"{status_emoji} {guild.name}",
|
||||
value=f"**AI Checks (1h):** {stats['guild_checks_this_hour']}/{max_checks} ({usage_pct:.0f}%)\n"
|
||||
f"**Today:** {stats['guild_checks_today']}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@commands.command(name="reload")
|
||||
@commands.is_owner()
|
||||
async def reload_cmd(self, ctx: commands.Context) -> None:
|
||||
"""Reload configuration from config.yml."""
|
||||
if not hasattr(self.bot, 'config_loader'):
|
||||
await ctx.send("❌ Config loader not initialized.")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.bot.config_loader.reload()
|
||||
await ctx.send("✅ Configuration reloaded successfully.")
|
||||
logger.info("Configuration reloaded by owner command")
|
||||
except Exception as e:
|
||||
await ctx.send(f"❌ Failed to reload config: {e}")
|
||||
logger.error(f"Failed to reload config: {e}", exc_info=True)
|
||||
|
||||
@commands.command(name="ping")
|
||||
@commands.is_owner()
|
||||
async def ping_cmd(self, ctx: commands.Context) -> None:
|
||||
"""Check bot latency."""
|
||||
latency_ms = round(self.bot.latency * 1000, 2)
|
||||
await ctx.send(f"🏓 Pong! Latency: {latency_ms}ms")
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the Owner cog."""
|
||||
await bot.add_cog(Owner(bot))
|
||||
@@ -1,449 +0,0 @@
|
||||
"""Verification cog for new member verification."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord import ui
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from guardden.bot import GuardDen
|
||||
from guardden.services.verification import (
|
||||
ChallengeType,
|
||||
PendingVerification,
|
||||
VerificationService,
|
||||
)
|
||||
from guardden.utils.ratelimit import RateLimitExceeded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VerifyButton(ui.Button["VerificationView"]):
|
||||
"""Button for simple verification."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
style=discord.ButtonStyle.success,
|
||||
label="Verify",
|
||||
custom_id="verify_button",
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction) -> None:
|
||||
if self.view is None:
|
||||
return
|
||||
|
||||
success, message = await self.view.cog.complete_verification(
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
"verified",
|
||||
)
|
||||
|
||||
if success:
|
||||
await interaction.response.send_message(message, ephemeral=True)
|
||||
# Disable the button
|
||||
self.disabled = True
|
||||
self.label = "Verified"
|
||||
await interaction.message.edit(view=self.view)
|
||||
else:
|
||||
await interaction.response.send_message(message, ephemeral=True)
|
||||
|
||||
|
||||
class EmojiButton(ui.Button["EmojiVerificationView"]):
|
||||
"""Button for emoji selection verification."""
|
||||
|
||||
def __init__(self, emoji: str, row: int = 0) -> None:
|
||||
super().__init__(
|
||||
style=discord.ButtonStyle.secondary,
|
||||
label=emoji,
|
||||
custom_id=f"emoji_{emoji}",
|
||||
row=row,
|
||||
)
|
||||
self.emoji_value = emoji
|
||||
|
||||
async def callback(self, interaction: discord.Interaction) -> None:
|
||||
if self.view is None:
|
||||
return
|
||||
|
||||
success, message = await self.view.cog.complete_verification(
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
self.emoji_value,
|
||||
)
|
||||
|
||||
if success:
|
||||
await interaction.response.send_message(message, ephemeral=True)
|
||||
# Disable all buttons
|
||||
for item in self.view.children:
|
||||
if isinstance(item, ui.Button):
|
||||
item.disabled = True
|
||||
await interaction.message.edit(view=self.view)
|
||||
else:
|
||||
await interaction.response.send_message(message, ephemeral=True)
|
||||
|
||||
|
||||
class VerificationView(ui.View):
|
||||
"""View for button verification."""
|
||||
|
||||
def __init__(self, cog: "Verification", timeout: float = 600) -> None:
|
||||
super().__init__(timeout=timeout)
|
||||
self.cog = cog
|
||||
self.add_item(VerifyButton())
|
||||
|
||||
|
||||
class EmojiVerificationView(ui.View):
|
||||
"""View for emoji selection verification."""
|
||||
|
||||
def __init__(self, cog: "Verification", options: list[str], timeout: float = 600) -> None:
|
||||
super().__init__(timeout=timeout)
|
||||
self.cog = cog
|
||||
for i, emoji in enumerate(options):
|
||||
self.add_item(EmojiButton(emoji, row=i // 4))
|
||||
|
||||
|
||||
class CaptchaModal(ui.Modal):
|
||||
"""Modal for captcha/math input."""
|
||||
|
||||
answer = ui.TextInput(
|
||||
label="Your Answer",
|
||||
placeholder="Enter the answer here...",
|
||||
max_length=50,
|
||||
)
|
||||
|
||||
def __init__(self, cog: "Verification", title: str = "Verification") -> None:
|
||||
super().__init__(title=title)
|
||||
self.cog = cog
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction) -> None:
|
||||
success, message = await self.cog.complete_verification(
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
self.answer.value,
|
||||
)
|
||||
await interaction.response.send_message(message, ephemeral=True)
|
||||
|
||||
|
||||
class AnswerButton(ui.Button["AnswerView"]):
|
||||
"""Button to open the answer modal."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
style=discord.ButtonStyle.primary,
|
||||
label="Submit Answer",
|
||||
custom_id="submit_answer",
|
||||
)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction) -> None:
|
||||
if self.view is None:
|
||||
return
|
||||
modal = CaptchaModal(self.view.cog)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
|
||||
class AnswerView(ui.View):
|
||||
"""View with button to open answer modal."""
|
||||
|
||||
def __init__(self, cog: "Verification", timeout: float = 600) -> None:
|
||||
super().__init__(timeout=timeout)
|
||||
self.cog = cog
|
||||
self.add_item(AnswerButton())
|
||||
|
||||
|
||||
class Verification(commands.Cog):
|
||||
"""Member verification system."""
|
||||
|
||||
def __init__(self, bot: GuardDen) -> None:
|
||||
self.bot = bot
|
||||
self.service = VerificationService()
|
||||
self.cleanup_task.start()
|
||||
|
||||
def cog_check(self, ctx: commands.Context) -> bool:
|
||||
if not ctx.guild:
|
||||
return False
|
||||
if not self.bot.is_owner_allowed(ctx.author.id):
|
||||
return False
|
||||
return True
|
||||
|
||||
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 cog_unload(self) -> None:
|
||||
self.cleanup_task.cancel()
|
||||
|
||||
@tasks.loop(minutes=5)
|
||||
async def cleanup_task(self) -> None:
|
||||
"""Periodically clean up expired verifications."""
|
||||
count = self.service.cleanup_expired()
|
||||
if count > 0:
|
||||
logger.debug(f"Cleaned up {count} expired verifications")
|
||||
|
||||
@cleanup_task.before_loop
|
||||
async def before_cleanup(self) -> None:
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
async def complete_verification(
|
||||
self, guild_id: int, user_id: int, response: str
|
||||
) -> tuple[bool, str]:
|
||||
"""Complete a verification and assign role if successful."""
|
||||
success, message = self.service.verify(guild_id, user_id, response)
|
||||
|
||||
if success:
|
||||
# Assign verified role
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if guild:
|
||||
member = guild.get_member(user_id)
|
||||
config = await self.bot.guild_config.get_config(guild_id)
|
||||
|
||||
if member and config and config.verified_role_id:
|
||||
role = guild.get_role(config.verified_role_id)
|
||||
if role:
|
||||
try:
|
||||
await member.add_roles(role, reason="Verification completed")
|
||||
logger.info(f"Verified {member} in {guild.name}")
|
||||
except discord.Forbidden:
|
||||
logger.warning(f"Cannot assign verified role in {guild.name}")
|
||||
|
||||
return success, message
|
||||
|
||||
async def send_verification(
|
||||
self,
|
||||
member: discord.Member,
|
||||
channel: discord.TextChannel,
|
||||
challenge_type: ChallengeType,
|
||||
) -> None:
|
||||
"""Send a verification challenge to a member."""
|
||||
pending = self.service.create_challenge(
|
||||
user_id=member.id,
|
||||
guild_id=member.guild.id,
|
||||
challenge_type=challenge_type,
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Verification Required",
|
||||
description=pending.challenge.question,
|
||||
color=discord.Color.blue(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Expires in 10 minutes • {pending.challenge.max_attempts} attempts allowed"
|
||||
)
|
||||
|
||||
# Create appropriate view based on challenge type
|
||||
if challenge_type == ChallengeType.BUTTON:
|
||||
view = VerificationView(self)
|
||||
elif challenge_type == ChallengeType.EMOJI:
|
||||
view = EmojiVerificationView(self, pending.challenge.options)
|
||||
else:
|
||||
# Captcha or Math - use modal
|
||||
view = AnswerView(self)
|
||||
|
||||
try:
|
||||
# Try to DM the user first
|
||||
dm_channel = await member.create_dm()
|
||||
msg = await dm_channel.send(embed=embed, view=view)
|
||||
pending.message_id = msg.id
|
||||
pending.channel_id = dm_channel.id
|
||||
except discord.Forbidden:
|
||||
# Fall back to channel mention
|
||||
msg = await channel.send(
|
||||
content=member.mention,
|
||||
embed=embed,
|
||||
view=view,
|
||||
)
|
||||
pending.message_id = msg.id
|
||||
pending.channel_id = channel.id
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member) -> None:
|
||||
"""Handle new member joins for verification."""
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
config = await self.bot.guild_config.get_config(member.guild.id)
|
||||
if not config or not config.verification_enabled:
|
||||
return
|
||||
|
||||
# Determine verification channel
|
||||
channel_id = config.welcome_channel_id or config.log_channel_id
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
channel = member.guild.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, discord.TextChannel):
|
||||
return
|
||||
|
||||
# Get challenge type from config
|
||||
try:
|
||||
challenge_type = ChallengeType(config.verification_type)
|
||||
except ValueError:
|
||||
challenge_type = ChallengeType.BUTTON
|
||||
|
||||
await self.send_verification(member, channel, challenge_type)
|
||||
|
||||
@commands.group(name="verify", invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
async def verify_cmd(self, ctx: commands.Context) -> None:
|
||||
"""Request a verification challenge."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
|
||||
if not config or not config.verification_enabled:
|
||||
await ctx.send("Verification is not enabled on this server.")
|
||||
return
|
||||
|
||||
# Check if already verified
|
||||
if config.verified_role_id:
|
||||
role = ctx.guild.get_role(config.verified_role_id)
|
||||
if role and role in ctx.author.roles:
|
||||
await ctx.send("You are already verified!")
|
||||
return
|
||||
|
||||
# Check for existing pending verification
|
||||
pending = self.service.get_pending(ctx.guild.id, ctx.author.id)
|
||||
if pending and not pending.challenge.is_expired:
|
||||
await ctx.send("You already have a pending verification. Please complete it first.")
|
||||
return
|
||||
|
||||
# Get challenge type
|
||||
try:
|
||||
challenge_type = ChallengeType(config.verification_type)
|
||||
except ValueError:
|
||||
challenge_type = ChallengeType.BUTTON
|
||||
|
||||
await self.send_verification(ctx.author, ctx.channel, challenge_type)
|
||||
await ctx.message.delete(delay=1)
|
||||
|
||||
@verify_cmd.command(name="setup")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_setup(self, ctx: commands.Context) -> None:
|
||||
"""View verification setup status."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Verification Setup",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Enabled",
|
||||
value="✅ Yes" if config and config.verification_enabled else "❌ No",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Type",
|
||||
value=config.verification_type if config else "button",
|
||||
inline=True,
|
||||
)
|
||||
|
||||
if config and config.verified_role_id:
|
||||
role = ctx.guild.get_role(config.verified_role_id)
|
||||
embed.add_field(
|
||||
name="Verified Role",
|
||||
value=role.mention if role else "Not found",
|
||||
inline=True,
|
||||
)
|
||||
else:
|
||||
embed.add_field(name="Verified Role", value="Not set", inline=True)
|
||||
|
||||
pending_count = self.service.get_pending_count(ctx.guild.id)
|
||||
embed.add_field(name="Pending Verifications", value=str(pending_count), inline=True)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@verify_cmd.command(name="enable")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_enable(self, ctx: commands.Context) -> None:
|
||||
"""Enable verification for new members."""
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
|
||||
if not config or not config.verified_role_id:
|
||||
await ctx.send("Please set a verified role first with `!verify role @role`")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, verification_enabled=True)
|
||||
await ctx.send("✅ Verification enabled for new members.")
|
||||
|
||||
@verify_cmd.command(name="disable")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_disable(self, ctx: commands.Context) -> None:
|
||||
"""Disable verification."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, verification_enabled=False)
|
||||
await ctx.send("❌ Verification disabled.")
|
||||
|
||||
@verify_cmd.command(name="role")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_role(self, ctx: commands.Context, role: discord.Role) -> None:
|
||||
"""Set the role given upon verification."""
|
||||
await self.bot.guild_config.update_settings(ctx.guild.id, verified_role_id=role.id)
|
||||
await ctx.send(f"Verified role set to {role.mention}")
|
||||
|
||||
@verify_cmd.command(name="type")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_type(self, ctx: commands.Context, vtype: str) -> None:
|
||||
"""Set verification type (button, captcha, math, emoji)."""
|
||||
try:
|
||||
challenge_type = ChallengeType(vtype.lower())
|
||||
except ValueError:
|
||||
valid = ", ".join(t.value for t in ChallengeType if t != ChallengeType.QUESTIONS)
|
||||
await ctx.send(f"Invalid type. Valid options: {valid}")
|
||||
return
|
||||
|
||||
await self.bot.guild_config.update_settings(
|
||||
ctx.guild.id, verification_type=challenge_type.value
|
||||
)
|
||||
await ctx.send(f"Verification type set to **{challenge_type.value}**")
|
||||
|
||||
@verify_cmd.command(name="test")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def verify_test(self, ctx: commands.Context, vtype: str = "button") -> None:
|
||||
"""Test verification (sends challenge to you)."""
|
||||
try:
|
||||
challenge_type = ChallengeType(vtype.lower())
|
||||
except ValueError:
|
||||
challenge_type = ChallengeType.BUTTON
|
||||
|
||||
await self.send_verification(ctx.author, ctx.channel, challenge_type)
|
||||
|
||||
@verify_cmd.command(name="reset")
|
||||
@commands.has_permissions(kick_members=True)
|
||||
@commands.guild_only()
|
||||
async def verify_reset(self, ctx: commands.Context, member: discord.Member) -> None:
|
||||
"""Reset verification for a member (remove role and cancel pending)."""
|
||||
# Cancel any pending verification
|
||||
self.service.cancel(ctx.guild.id, member.id)
|
||||
|
||||
# Remove verified role
|
||||
config = await self.bot.guild_config.get_config(ctx.guild.id)
|
||||
if config and config.verified_role_id:
|
||||
role = ctx.guild.get_role(config.verified_role_id)
|
||||
if role and role in member.roles:
|
||||
try:
|
||||
await member.remove_roles(role, reason=f"Verification reset by {ctx.author}")
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
await ctx.send(f"Reset verification for {member.mention}")
|
||||
|
||||
|
||||
async def setup(bot: GuardDen) -> None:
|
||||
"""Load the Verification cog."""
|
||||
await bot.add_cog(Verification(bot))
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Background task for managed wordlist syncing."""
|
||||
|
||||
import logging
|
||||
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from guardden.services.wordlist import WordlistService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WordlistSync(commands.Cog):
|
||||
"""Periodic sync of managed wordlists into guild bans."""
|
||||
|
||||
def __init__(self, bot: commands.Bot, service: WordlistService) -> None:
|
||||
self.bot = bot
|
||||
self.service = service
|
||||
self.sync_task.change_interval(hours=service.update_interval.total_seconds() / 3600)
|
||||
self.sync_task.start()
|
||||
|
||||
def cog_unload(self) -> None:
|
||||
self.sync_task.cancel()
|
||||
|
||||
@tasks.loop(hours=1)
|
||||
async def sync_task(self) -> None:
|
||||
await self.service.sync_all()
|
||||
|
||||
@sync_task.before_loop
|
||||
async def before_sync_task(self) -> None:
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot) -> None:
|
||||
service = getattr(bot, "wordlist_service", None)
|
||||
if not service:
|
||||
logger.warning("Wordlist service not initialized; skipping sync task")
|
||||
return
|
||||
await bot.add_cog(WordlistSync(bot, service))
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Configuration management for GuardDen."""
|
||||
"""Configuration management for GuardDen - Minimal Version."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, SecretStr, ValidationError, field_validator
|
||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings.sources import EnvSettingsSource
|
||||
|
||||
@@ -69,62 +68,13 @@ class GuardDenEnvSettingsSource(EnvSettingsSource):
|
||||
"""Environment settings source with safe list parsing."""
|
||||
|
||||
def decode_complex_value(self, field_name: str, field, value: Any):
|
||||
if field_name in {"allowed_guilds", "owner_ids"} and isinstance(value, str):
|
||||
if field_name in {"owner_ids"} and isinstance(value, str):
|
||||
return value
|
||||
return super().decode_complex_value(field_name, field, value)
|
||||
|
||||
|
||||
class WordlistSourceConfig(BaseModel):
|
||||
"""Configuration for a managed wordlist source."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
category: Literal["hard", "soft", "context"]
|
||||
action: Literal["delete", "warn", "strike"]
|
||||
reason: str
|
||||
is_regex: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class GuildDefaults(BaseModel):
|
||||
"""Default values for new guild settings (configurable via env).
|
||||
|
||||
These values are used when creating a new guild configuration.
|
||||
Override via environment variables with GUARDDEN_GUILD_DEFAULT_ prefix.
|
||||
Example: GUARDDEN_GUILD_DEFAULT_PREFIX=? sets the default prefix to "?"
|
||||
"""
|
||||
|
||||
prefix: str = Field(default="!", min_length=1, max_length=10)
|
||||
locale: str = Field(default="en", min_length=2, max_length=10)
|
||||
automod_enabled: bool = True
|
||||
anti_spam_enabled: bool = True
|
||||
link_filter_enabled: bool = False
|
||||
message_rate_limit: int = Field(default=5, ge=1)
|
||||
message_rate_window: int = Field(default=5, ge=1)
|
||||
duplicate_threshold: int = Field(default=3, ge=1)
|
||||
mention_limit: int = Field(default=5, ge=1)
|
||||
mention_rate_limit: int = Field(default=10, ge=1)
|
||||
mention_rate_window: int = Field(default=60, ge=1)
|
||||
ai_moderation_enabled: bool = True
|
||||
ai_sensitivity: int = Field(default=80, ge=0, le=100)
|
||||
ai_confidence_threshold: float = Field(default=0.7, ge=0.0, le=1.0)
|
||||
ai_log_only: bool = False
|
||||
nsfw_detection_enabled: bool = True
|
||||
verification_enabled: bool = False
|
||||
verification_type: Literal["button", "captcha", "math", "emoji"] = "button"
|
||||
strike_actions: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"1": {"action": "warn"},
|
||||
"3": {"action": "timeout", "duration": 300},
|
||||
"5": {"action": "kick"},
|
||||
"7": {"action": "ban"},
|
||||
}
|
||||
)
|
||||
scam_allowlist: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
"""Application settings loaded from environment variables - Minimal Version."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -177,62 +127,23 @@ class Settings(BaseSettings):
|
||||
log_json: bool = Field(default=False, description="Use JSON structured logging format")
|
||||
log_file: str | None = Field(default=None, description="Log file path (optional)")
|
||||
|
||||
# Access control
|
||||
allowed_guilds: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="Guild IDs the bot is allowed to join (empty = allow all)",
|
||||
)
|
||||
# Access control (owner IDs for debug commands)
|
||||
owner_ids: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="Owner user IDs with elevated access (empty = allow admins)",
|
||||
description="Owner user IDs for debug commands (empty = all admins)",
|
||||
)
|
||||
|
||||
# Paths
|
||||
data_dir: Path = Field(default=Path("data"), description="Data directory for persistent files")
|
||||
|
||||
# Wordlist sync
|
||||
wordlist_enabled: bool = Field(
|
||||
default=True, description="Enable automatic managed wordlist syncing"
|
||||
)
|
||||
wordlist_update_hours: int = Field(
|
||||
default=168, description="Managed wordlist sync interval in hours"
|
||||
)
|
||||
wordlist_sources: list[WordlistSourceConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Managed wordlist sources (JSON array via env overrides)",
|
||||
# Config file path
|
||||
config_file: Path = Field(
|
||||
default=Path("config.yml"),
|
||||
description="Path to config.yml file",
|
||||
)
|
||||
|
||||
# Guild defaults (used when creating new guild configurations)
|
||||
guild_default: GuildDefaults = Field(
|
||||
default_factory=GuildDefaults,
|
||||
description="Default values for new guild settings",
|
||||
)
|
||||
|
||||
@field_validator("allowed_guilds", "owner_ids", mode="before")
|
||||
@field_validator("owner_ids", mode="before")
|
||||
@classmethod
|
||||
def _validate_id_list(cls, value: Any) -> list[int]:
|
||||
return _parse_id_list(value)
|
||||
|
||||
@field_validator("wordlist_sources", mode="before")
|
||||
@classmethod
|
||||
def _parse_wordlist_sources(cls, value: Any) -> list[WordlistSourceConfig]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [WordlistSourceConfig.model_validate(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Invalid JSON for wordlist_sources") from exc
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("wordlist_sources must be a JSON array")
|
||||
return [WordlistSourceConfig.model_validate(item) for item in data]
|
||||
return []
|
||||
|
||||
@field_validator("discord_token")
|
||||
@classmethod
|
||||
def _validate_discord_token(cls, value: SecretStr) -> SecretStr:
|
||||
@@ -278,14 +189,6 @@ class Settings(BaseSettings):
|
||||
if self.database_pool_min < 1:
|
||||
raise ValueError("database_pool_min must be at least 1")
|
||||
|
||||
# Data directory validation
|
||||
if not isinstance(self.data_dir, Path):
|
||||
raise ValueError("data_dir must be a valid path")
|
||||
|
||||
# Wordlist validation
|
||||
if self.wordlist_update_hours < 1:
|
||||
raise ValueError("wordlist_update_hours must be at least 1")
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get application settings instance."""
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
"""Database models for GuardDen."""
|
||||
"""Database models for GuardDen - Minimal Version."""
|
||||
|
||||
from guardden.models.analytics import AICheck, MessageActivity, UserActivity
|
||||
from guardden.models.base import Base
|
||||
from guardden.models.guild import BannedWord, Guild, GuildSettings
|
||||
from guardden.models.moderation import ModerationLog, Strike, UserNote
|
||||
from guardden.models.guild import Guild, GuildSettings
|
||||
|
||||
__all__ = [
|
||||
"AICheck",
|
||||
"Base",
|
||||
"BannedWord",
|
||||
"Guild",
|
||||
"GuildSettings",
|
||||
"MessageActivity",
|
||||
"ModerationLog",
|
||||
"Strike",
|
||||
"UserActivity",
|
||||
"UserNote",
|
||||
]
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Analytics models for tracking bot usage and performance."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from guardden.models.base import Base, SnowflakeID, TimestampMixin
|
||||
|
||||
|
||||
class AICheck(Base, TimestampMixin):
|
||||
"""Record of AI moderation checks."""
|
||||
|
||||
__tablename__ = "ai_checks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False, index=True)
|
||||
channel_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
message_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
|
||||
# Check result
|
||||
flagged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
category: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
severity: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Performance metrics
|
||||
response_time_ms: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# False positive tracking (set by moderators)
|
||||
is_false_positive: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, index=True
|
||||
)
|
||||
reviewed_by: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class MessageActivity(Base):
|
||||
"""Daily message activity statistics per guild."""
|
||||
|
||||
__tablename__ = "message_activity"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False, index=True)
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# Activity counts
|
||||
total_messages: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
active_users: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
new_joins: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Moderation activity
|
||||
automod_triggers: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
ai_checks: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
manual_actions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class UserActivity(Base, TimestampMixin):
|
||||
"""Track user activity and first/last seen timestamps."""
|
||||
|
||||
__tablename__ = "user_activity"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False, index=True)
|
||||
|
||||
# User information
|
||||
username: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Activity timestamps
|
||||
first_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_message: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Activity counts
|
||||
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
command_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Moderation stats
|
||||
strike_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
warning_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
kick_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
ban_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
timeout_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
@@ -1,17 +1,10 @@
|
||||
"""Guild-related database models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from guardden.models.base import Base, SnowflakeID, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from guardden.models.moderation import ModerationLog, Strike
|
||||
|
||||
|
||||
class Guild(Base, TimestampMixin):
|
||||
"""Represents a Discord guild (server) configuration."""
|
||||
@@ -27,15 +20,6 @@ class Guild(Base, TimestampMixin):
|
||||
settings: Mapped["GuildSettings"] = relationship(
|
||||
back_populates="guild", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
banned_words: Mapped[list["BannedWord"]] = relationship(
|
||||
back_populates="guild", cascade="all, delete-orphan"
|
||||
)
|
||||
moderation_logs: Mapped[list["ModerationLog"]] = relationship(
|
||||
back_populates="guild", cascade="all, delete-orphan"
|
||||
)
|
||||
strikes: Mapped[list["Strike"]] = relationship(
|
||||
back_populates="guild", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class GuildSettings(Base, TimestampMixin):
|
||||
@@ -51,94 +35,21 @@ class GuildSettings(Base, TimestampMixin):
|
||||
prefix: Mapped[str] = mapped_column(String(10), default="!", nullable=False)
|
||||
locale: Mapped[str] = mapped_column(String(10), default="en", nullable=False)
|
||||
|
||||
# Channel configuration (stored as snowflake IDs)
|
||||
log_channel_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
mod_log_channel_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
welcome_channel_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
|
||||
# Role configuration
|
||||
mute_role_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
verified_role_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
mod_role_ids: Mapped[dict] = mapped_column(
|
||||
JSONB().with_variant(JSON(), "sqlite"), default=list, nullable=False
|
||||
)
|
||||
|
||||
# Moderation settings
|
||||
# Spam detection settings
|
||||
automod_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
anti_spam_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
link_filter_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Automod thresholds
|
||||
message_rate_limit: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
message_rate_window: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
duplicate_threshold: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
|
||||
mention_limit: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
mention_rate_limit: Mapped[int] = mapped_column(Integer, default=10, nullable=False)
|
||||
mention_rate_window: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||
scam_allowlist: Mapped[list[str]] = mapped_column(
|
||||
JSONB().with_variant(JSON(), "sqlite"), default=list, nullable=False
|
||||
)
|
||||
|
||||
# Strike thresholds (actions at each threshold)
|
||||
strike_actions: Mapped[dict] = mapped_column(
|
||||
JSONB().with_variant(JSON(), "sqlite"),
|
||||
default=lambda: {
|
||||
"1": {"action": "warn"},
|
||||
"3": {"action": "timeout", "duration": 300},
|
||||
"5": {"action": "kick"},
|
||||
"7": {"action": "ban"},
|
||||
},
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# AI moderation settings
|
||||
ai_moderation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
ai_sensitivity: Mapped[int] = mapped_column(Integer, default=80, nullable=False) # 0-100 scale
|
||||
ai_confidence_threshold: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
|
||||
ai_log_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
ai_sensitivity: Mapped[int] = mapped_column(Integer, default=80, nullable=False)
|
||||
nsfw_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
nsfw_only_filtering: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Notification settings
|
||||
send_in_channel_warnings: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Whitelist settings
|
||||
whitelisted_user_ids: Mapped[list[int]] = mapped_column(
|
||||
JSONB().with_variant(JSON(), "sqlite"), default=list, nullable=False
|
||||
)
|
||||
|
||||
# Verification settings
|
||||
verification_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
verification_type: Mapped[str] = mapped_column(
|
||||
String(20), default="button", nullable=False
|
||||
) # button, captcha, questions
|
||||
|
||||
# Relationship
|
||||
guild: Mapped["Guild"] = relationship(back_populates="settings")
|
||||
|
||||
|
||||
class BannedWord(Base, TimestampMixin):
|
||||
"""Banned words/phrases for a guild with regex support."""
|
||||
|
||||
__tablename__ = "banned_words"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(
|
||||
SnowflakeID, ForeignKey("guilds.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
pattern: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_regex: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(20), default="delete", nullable=False
|
||||
) # delete, warn, strike
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
managed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Who added this and when
|
||||
added_by: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
|
||||
# Relationship
|
||||
guild: Mapped["Guild"] = relationship(back_populates="banned_words")
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Moderation-related database models."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from guardden.models.base import Base, SnowflakeID, TimestampMixin
|
||||
from guardden.models.guild import Guild
|
||||
|
||||
|
||||
class ModAction(str, Enum):
|
||||
"""Types of moderation actions."""
|
||||
|
||||
WARN = "warn"
|
||||
TIMEOUT = "timeout"
|
||||
KICK = "kick"
|
||||
BAN = "ban"
|
||||
UNBAN = "unban"
|
||||
UNMUTE = "unmute"
|
||||
NOTE = "note"
|
||||
STRIKE = "strike"
|
||||
DELETE = "delete"
|
||||
|
||||
|
||||
class ModerationLog(Base, TimestampMixin):
|
||||
"""Log of all moderation actions taken."""
|
||||
|
||||
__tablename__ = "moderation_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(
|
||||
SnowflakeID, ForeignKey("guilds.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
# Target and moderator
|
||||
target_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
target_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
moderator_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
moderator_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Action details
|
||||
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) # Duration in seconds
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Context
|
||||
channel_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
message_id: Mapped[int | None] = mapped_column(SnowflakeID, nullable=True)
|
||||
message_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Was this an automatic action?
|
||||
is_automatic: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationship
|
||||
guild: Mapped["Guild"] = relationship(back_populates="moderation_logs")
|
||||
|
||||
|
||||
class Strike(Base, TimestampMixin):
|
||||
"""User strikes/warnings tracking."""
|
||||
|
||||
__tablename__ = "strikes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(
|
||||
SnowflakeID, ForeignKey("guilds.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
user_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
moderator_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
points: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
# Strikes can expire
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Reference to the moderation log entry
|
||||
mod_log_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("moderation_logs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# Relationship
|
||||
guild: Mapped["Guild"] = relationship(back_populates="strikes")
|
||||
|
||||
|
||||
class UserNote(Base, TimestampMixin):
|
||||
"""Moderator notes on users."""
|
||||
|
||||
__tablename__ = "user_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
guild_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
moderator_id: Mapped[int] = mapped_column(SnowflakeID, nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""AI services for content moderation."""
|
||||
|
||||
from guardden.services.ai.base import AIProvider, ModerationResult
|
||||
from guardden.services.ai.base import AIProvider, ImageAnalysisResult
|
||||
from guardden.services.ai.factory import create_ai_provider
|
||||
|
||||
__all__ = ["AIProvider", "ModerationResult", "create_ai_provider"]
|
||||
__all__ = ["AIProvider", "ImageAnalysisResult", "create_ai_provider"]
|
||||
|
||||
@@ -3,41 +3,10 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from guardden.services.ai.base import (
|
||||
AIProvider,
|
||||
ImageAnalysisResult,
|
||||
ModerationResult,
|
||||
PhishingAnalysisResult,
|
||||
parse_categories,
|
||||
run_with_retries,
|
||||
)
|
||||
from guardden.services.ai.base import AIProvider, ImageAnalysisResult, run_with_retries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Content moderation system prompt
|
||||
MODERATION_SYSTEM_PROMPT = """You are a content moderation AI for a Discord server. Analyze the given message and determine if it violates community guidelines.
|
||||
|
||||
Categories to check:
|
||||
- harassment: Personal attacks, bullying, intimidation
|
||||
- hate_speech: Discrimination, slurs, dehumanization based on identity
|
||||
- sexual: Explicit sexual content, sexual solicitation
|
||||
- violence: Threats, graphic violence, encouraging harm
|
||||
- self_harm: Suicide, self-injury content or encouragement
|
||||
- spam: Repetitive, promotional, or low-quality content
|
||||
- scam: Phishing attempts, fraudulent offers, impersonation
|
||||
- misinformation: Dangerous false information
|
||||
|
||||
Respond in this exact JSON format:
|
||||
{
|
||||
"is_flagged": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"categories": ["category1", "category2"],
|
||||
"explanation": "Brief explanation",
|
||||
"suggested_action": "none/warn/delete/timeout/ban"
|
||||
}
|
||||
|
||||
Be balanced - flag genuinely problematic content but allow normal conversation, jokes, and mild language. Consider context."""
|
||||
|
||||
IMAGE_ANALYSIS_PROMPT = """Analyze this image for content moderation. Classify NSFW content with precision.
|
||||
|
||||
NSFW CATEGORIES (in order of severity):
|
||||
@@ -76,24 +45,6 @@ NSFW SEVERITY GUIDELINES:
|
||||
- nudity: 60-75
|
||||
- explicit: 80-100"""
|
||||
|
||||
PHISHING_ANALYSIS_PROMPT = """Analyze this URL and message context for phishing or scam indicators.
|
||||
|
||||
Check for:
|
||||
- Domain impersonation (typosquatting, lookalike domains)
|
||||
- Urgency tactics ("act now", "limited time")
|
||||
- Requests for credentials or personal info
|
||||
- Too-good-to-be-true offers
|
||||
- Suspicious redirects or URL shorteners
|
||||
- Mismatched or hidden URLs
|
||||
|
||||
Respond in this exact JSON format:
|
||||
{
|
||||
"is_phishing": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"risk_factors": ["factor1", "factor2"],
|
||||
"explanation": "Brief explanation"
|
||||
}"""
|
||||
|
||||
|
||||
class AnthropicProvider(AIProvider):
|
||||
"""AI provider using Anthropic's Claude API."""
|
||||
@@ -150,47 +101,6 @@ class AnthropicProvider(AIProvider):
|
||||
|
||||
return json.loads(text)
|
||||
|
||||
async def moderate_text(
|
||||
self,
|
||||
content: str,
|
||||
context: str | None = None,
|
||||
sensitivity: int = 50,
|
||||
) -> ModerationResult:
|
||||
"""Analyze text content for policy violations."""
|
||||
# Adjust prompt based on sensitivity
|
||||
sensitivity_note = ""
|
||||
if sensitivity < 30:
|
||||
sensitivity_note = "\n\nBe lenient - only flag clearly problematic content."
|
||||
elif sensitivity > 70:
|
||||
sensitivity_note = "\n\nBe strict - flag anything potentially problematic."
|
||||
|
||||
system = MODERATION_SYSTEM_PROMPT + sensitivity_note
|
||||
|
||||
user_message = f"Message to analyze:\n{content}"
|
||||
if context:
|
||||
user_message = f"Context: {context}\n\n{user_message}"
|
||||
|
||||
try:
|
||||
response = await self._call_api(system, user_message)
|
||||
data = self._parse_json_response(response)
|
||||
|
||||
categories = parse_categories(data.get("categories", []))
|
||||
|
||||
return ModerationResult(
|
||||
is_flagged=data.get("is_flagged", False),
|
||||
confidence=float(data.get("confidence", 0.0)),
|
||||
categories=categories,
|
||||
explanation=data.get("explanation", ""),
|
||||
suggested_action=data.get("suggested_action", "none"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moderating text: {e}")
|
||||
return ModerationResult(
|
||||
is_flagged=False,
|
||||
explanation=f"Error analyzing content: {str(e)}",
|
||||
)
|
||||
|
||||
async def analyze_image(
|
||||
self,
|
||||
image_url: str,
|
||||
@@ -276,31 +186,6 @@ SENSITIVITY: BALANCED
|
||||
logger.error(f"Error analyzing image: {e}")
|
||||
return ImageAnalysisResult(description=f"Error analyzing image: {str(e)}")
|
||||
|
||||
async def analyze_phishing(
|
||||
self,
|
||||
url: str,
|
||||
message_content: str | None = None,
|
||||
) -> PhishingAnalysisResult:
|
||||
"""Analyze a URL for phishing/scam indicators."""
|
||||
user_message = f"URL to analyze: {url}"
|
||||
if message_content:
|
||||
user_message += f"\n\nFull message context:\n{message_content}"
|
||||
|
||||
try:
|
||||
response = await self._call_api(PHISHING_ANALYSIS_PROMPT, user_message)
|
||||
data = self._parse_json_response(response)
|
||||
|
||||
return PhishingAnalysisResult(
|
||||
is_phishing=data.get("is_phishing", False),
|
||||
confidence=float(data.get("confidence", 0.0)),
|
||||
risk_factors=data.get("risk_factors", []),
|
||||
explanation=data.get("explanation", ""),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing phishing: {e}")
|
||||
return PhishingAnalysisResult(explanation=f"Error analyzing URL: {str(e)}")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
await self.client.close()
|
||||
|
||||
@@ -9,20 +9,6 @@ from enum import Enum
|
||||
from typing import Literal, TypeVar
|
||||
|
||||
|
||||
class ContentCategory(str, Enum):
|
||||
"""Categories of problematic content."""
|
||||
|
||||
SAFE = "safe"
|
||||
HARASSMENT = "harassment"
|
||||
HATE_SPEECH = "hate_speech"
|
||||
SEXUAL = "sexual"
|
||||
VIOLENCE = "violence"
|
||||
SELF_HARM = "self_harm"
|
||||
SPAM = "spam"
|
||||
SCAM = "scam"
|
||||
MISINFORMATION = "misinformation"
|
||||
|
||||
|
||||
class NSFWCategory(str, Enum):
|
||||
"""NSFW content subcategories with increasing severity."""
|
||||
|
||||
@@ -45,17 +31,6 @@ class RetryConfig:
|
||||
max_delay: float = 2.0
|
||||
|
||||
|
||||
def parse_categories(values: list[str]) -> list[ContentCategory]:
|
||||
"""Parse category values into ContentCategory enums."""
|
||||
categories: list[ContentCategory] = []
|
||||
for value in values:
|
||||
try:
|
||||
categories.append(ContentCategory(value))
|
||||
except ValueError:
|
||||
continue
|
||||
return categories
|
||||
|
||||
|
||||
async def run_with_retries(
|
||||
operation: Callable[[], Awaitable[_T]],
|
||||
*,
|
||||
@@ -91,53 +66,6 @@ async def run_with_retries(
|
||||
raise RuntimeError("Retry loop exited unexpectedly")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModerationResult:
|
||||
"""Result of AI content moderation."""
|
||||
|
||||
is_flagged: bool = False
|
||||
confidence: float = 0.0 # 0.0 to 1.0
|
||||
categories: list[ContentCategory] = field(default_factory=list)
|
||||
explanation: str = ""
|
||||
suggested_action: Literal["none", "warn", "delete", "timeout", "ban"] = "none"
|
||||
severity_override: int | None = None # Direct severity for NSFW images
|
||||
|
||||
@property
|
||||
def severity(self) -> int:
|
||||
"""Get severity score 0-100 based on confidence and categories."""
|
||||
if not self.is_flagged:
|
||||
return 0
|
||||
|
||||
# Use override if provided (e.g., from NSFW image analysis)
|
||||
if self.severity_override is not None:
|
||||
return min(self.severity_override, 100)
|
||||
|
||||
# Base severity from confidence
|
||||
severity = int(self.confidence * 50)
|
||||
|
||||
# Add severity based on category
|
||||
high_severity = {
|
||||
ContentCategory.HATE_SPEECH,
|
||||
ContentCategory.SELF_HARM,
|
||||
ContentCategory.SCAM,
|
||||
}
|
||||
medium_severity = {
|
||||
ContentCategory.HARASSMENT,
|
||||
ContentCategory.VIOLENCE,
|
||||
ContentCategory.SEXUAL,
|
||||
}
|
||||
|
||||
for cat in self.categories:
|
||||
if cat in high_severity:
|
||||
severity += 30
|
||||
elif cat in medium_severity:
|
||||
severity += 20
|
||||
else:
|
||||
severity += 10
|
||||
|
||||
return min(severity, 100)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageAnalysisResult:
|
||||
"""Result of AI image analysis."""
|
||||
@@ -152,38 +80,8 @@ class ImageAnalysisResult:
|
||||
nsfw_severity: int = 0 # 0-100 specific NSFW severity score
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhishingAnalysisResult:
|
||||
"""Result of AI phishing/scam analysis."""
|
||||
|
||||
is_phishing: bool = False
|
||||
confidence: float = 0.0
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
explanation: str = ""
|
||||
|
||||
|
||||
class AIProvider(ABC):
|
||||
"""Abstract base class for AI providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def moderate_text(
|
||||
self,
|
||||
content: str,
|
||||
context: str | None = None,
|
||||
sensitivity: int = 50,
|
||||
) -> ModerationResult:
|
||||
"""
|
||||
Analyze text content for policy violations.
|
||||
|
||||
Args:
|
||||
content: The text to analyze
|
||||
context: Optional context about the conversation/server
|
||||
sensitivity: 0-100, higher means more strict
|
||||
|
||||
Returns:
|
||||
ModerationResult with analysis
|
||||
"""
|
||||
pass
|
||||
"""Abstract base class for AI providers - Image analysis only."""
|
||||
|
||||
@abstractmethod
|
||||
async def analyze_image(
|
||||
@@ -203,24 +101,6 @@ class AIProvider(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def analyze_phishing(
|
||||
self,
|
||||
url: str,
|
||||
message_content: str | None = None,
|
||||
) -> PhishingAnalysisResult:
|
||||
"""
|
||||
Analyze a URL for phishing/scam indicators.
|
||||
|
||||
Args:
|
||||
url: The URL to analyze
|
||||
message_content: Optional full message for context
|
||||
|
||||
Returns:
|
||||
PhishingAnalysisResult with analysis
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
|
||||
@@ -11,21 +11,11 @@ logger = logging.getLogger(__name__)
|
||||
class NullProvider(AIProvider):
|
||||
"""Null provider that does nothing (for when AI is disabled)."""
|
||||
|
||||
async def moderate_text(self, content, context=None, sensitivity=50):
|
||||
from guardden.services.ai.base import ModerationResult
|
||||
|
||||
return ModerationResult()
|
||||
|
||||
async def analyze_image(self, image_url, sensitivity=50):
|
||||
from guardden.services.ai.base import ImageAnalysisResult
|
||||
|
||||
return ImageAnalysisResult()
|
||||
|
||||
async def analyze_phishing(self, url, message_content=None):
|
||||
from guardden.services.ai.base import PhishingAnalysisResult
|
||||
|
||||
return PhishingAnalysisResult()
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from guardden.services.ai.base import (
|
||||
AIProvider,
|
||||
ContentCategory,
|
||||
ImageAnalysisResult,
|
||||
ModerationResult,
|
||||
PhishingAnalysisResult,
|
||||
run_with_retries,
|
||||
)
|
||||
from guardden.services.ai.base import AIProvider, ImageAnalysisResult, run_with_retries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,132 +28,48 @@ class OpenAIProvider(AIProvider):
|
||||
self.model = model
|
||||
logger.info(f"Initialized OpenAI provider with model: {model}")
|
||||
|
||||
async def _call_api(
|
||||
self,
|
||||
system: str,
|
||||
user_content: Any,
|
||||
max_tokens: int = 500,
|
||||
) -> str:
|
||||
"""Make an API call to OpenAI."""
|
||||
|
||||
async def _request() -> str:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
try:
|
||||
return await run_with_retries(
|
||||
_request,
|
||||
logger=logger,
|
||||
operation_name="OpenAI chat completion",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI API error: {e}")
|
||||
raise
|
||||
|
||||
def _parse_json_response(self, response: str) -> dict:
|
||||
"""Parse JSON from response."""
|
||||
import json
|
||||
|
||||
return json.loads(response)
|
||||
|
||||
async def moderate_text(
|
||||
self,
|
||||
content: str,
|
||||
context: str | None = None,
|
||||
sensitivity: int = 50,
|
||||
) -> ModerationResult:
|
||||
"""Analyze text content for policy violations."""
|
||||
# First, use OpenAI's built-in moderation API for quick check
|
||||
try:
|
||||
|
||||
async def _moderate() -> Any:
|
||||
return await self.client.moderations.create(input=content)
|
||||
|
||||
mod_response = await run_with_retries(
|
||||
_moderate,
|
||||
logger=logger,
|
||||
operation_name="OpenAI moderation",
|
||||
)
|
||||
results = mod_response.results[0]
|
||||
|
||||
# Map OpenAI categories to our categories
|
||||
category_mapping = {
|
||||
"harassment": ContentCategory.HARASSMENT,
|
||||
"harassment/threatening": ContentCategory.HARASSMENT,
|
||||
"hate": ContentCategory.HATE_SPEECH,
|
||||
"hate/threatening": ContentCategory.HATE_SPEECH,
|
||||
"self-harm": ContentCategory.SELF_HARM,
|
||||
"self-harm/intent": ContentCategory.SELF_HARM,
|
||||
"self-harm/instructions": ContentCategory.SELF_HARM,
|
||||
"sexual": ContentCategory.SEXUAL,
|
||||
"sexual/minors": ContentCategory.SEXUAL,
|
||||
"violence": ContentCategory.VIOLENCE,
|
||||
"violence/graphic": ContentCategory.VIOLENCE,
|
||||
}
|
||||
|
||||
flagged_categories = []
|
||||
max_score = 0.0
|
||||
|
||||
for category, score in results.category_scores.model_dump().items():
|
||||
if score > 0.5: # Threshold
|
||||
if category in category_mapping:
|
||||
flagged_categories.append(category_mapping[category])
|
||||
max_score = max(max_score, score)
|
||||
|
||||
# Adjust threshold based on sensitivity
|
||||
threshold = 0.3 + (0.4 * (100 - sensitivity) / 100) # 0.3 to 0.7
|
||||
|
||||
if results.flagged or max_score > threshold:
|
||||
return ModerationResult(
|
||||
is_flagged=True,
|
||||
confidence=max_score,
|
||||
categories=list(set(flagged_categories)),
|
||||
explanation="Content flagged by moderation API",
|
||||
suggested_action="delete" if max_score > 0.8 else "warn",
|
||||
)
|
||||
|
||||
return ModerationResult(is_flagged=False, confidence=1.0 - max_score)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moderating text: {e}")
|
||||
return ModerationResult(
|
||||
is_flagged=False,
|
||||
explanation=f"Error analyzing content: {str(e)}",
|
||||
)
|
||||
|
||||
async def analyze_image(
|
||||
self,
|
||||
image_url: str,
|
||||
sensitivity: int = 50,
|
||||
) -> ImageAnalysisResult:
|
||||
"""Analyze an image for NSFW or inappropriate content."""
|
||||
system = """Analyze this image for content moderation. Classify NSFW content with precision.
|
||||
system = """You are a content moderation system for a Discord server. Your job is to analyze images and classify them for NSFW content. This is a legitimate moderation task to protect users.
|
||||
|
||||
NSFW CATEGORIES (in order of severity):
|
||||
- none: Safe content, no sexual elements
|
||||
- suggestive: Revealing clothing, provocative poses, cleavage, but no nudity
|
||||
- partial_nudity: Underwear visible, partial exposure, censored nudity
|
||||
- nudity: Full nudity visible (breasts, genitals, buttocks) but no sexual acts
|
||||
IMPORTANT: You MUST analyze ALL artwork types equally, including:
|
||||
- Human characters
|
||||
- Anime/manga characters
|
||||
- Furry/anthropomorphic characters (animals with human traits)
|
||||
- Fantasy creatures
|
||||
- Any drawn or digital artwork
|
||||
|
||||
Apply the SAME standards to drawn/artwork content as you would to photographs. Artistic style does NOT exempt content from classification.
|
||||
|
||||
NSFW CATEGORIES (classify based on what is depicted):
|
||||
- none: Fully clothed, no sexual elements, safe for work
|
||||
- suggestive: Revealing clothing, provocative poses, emphasis on body parts, tight/skimpy outfits, seductive positioning
|
||||
- partial_nudity: Underwear/lingerie visible, bare chest, partially exposed, strategically covered nudity
|
||||
- nudity: Exposed breasts, buttocks, or genitals visible (even if stylized/drawn)
|
||||
- explicit: Sexual acts, pornographic content, genitalia in sexual context
|
||||
|
||||
DETECTION CRITERIA:
|
||||
- Focus on human bodies; anime/artwork should be judged by same standards
|
||||
- Swimwear in beach/pool context = suggestive at most
|
||||
- Artistic nudity (classical art, sculptures) = nudity but note in description
|
||||
KEY DETECTION RULES:
|
||||
- Leather/latex bodysuits, harnesses, BDSM gear = suggestive or higher
|
||||
- Exposed chest (any gender, any species) = partial_nudity or higher
|
||||
- Sexualized poses with minimal clothing = suggestive
|
||||
- Characters in underwear/lingerie = partial_nudity
|
||||
- "Bara", "yiff", or similar adult artwork styles = likely nudity or explicit
|
||||
|
||||
ALSO CHECK FOR:
|
||||
- Violence or gore (blood, injuries, weapons used violently)
|
||||
- Disturbing content (shock imagery, extreme content)
|
||||
|
||||
Respond in JSON format:
|
||||
Respond ONLY with valid JSON:
|
||||
{
|
||||
"is_nsfw": true/false,
|
||||
"nsfw_category": "none|suggestive|partial_nudity|nudity|explicit",
|
||||
@@ -168,11 +77,13 @@ Respond in JSON format:
|
||||
"is_violent": true/false,
|
||||
"is_disturbing": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"description": "Brief description including context",
|
||||
"description": "Brief factual description of what you see",
|
||||
"categories": ["category1"]
|
||||
}
|
||||
|
||||
NSFW SEVERITY GUIDELINES: none=0, suggestive=20-35, partial_nudity=40-55, nudity=60-75, explicit=80-100"""
|
||||
SEVERITY SCALE: none=0, suggestive=25-40, partial_nudity=45-60, nudity=65-80, explicit=85-100
|
||||
|
||||
If unsure, err on the side of caution and classify higher rather than lower."""
|
||||
|
||||
if sensitivity < 30:
|
||||
sensitivity_note = " SENSITIVITY: LENIENT - Allow suggestive content, only flag partial_nudity and above, set is_nsfw=false for suggestive."
|
||||
@@ -223,41 +134,6 @@ NSFW SEVERITY GUIDELINES: none=0, suggestive=20-35, partial_nudity=40-55, nudity
|
||||
logger.error(f"Error analyzing image: {e}")
|
||||
return ImageAnalysisResult(description=f"Error analyzing image: {str(e)}")
|
||||
|
||||
async def analyze_phishing(
|
||||
self,
|
||||
url: str,
|
||||
message_content: str | None = None,
|
||||
) -> PhishingAnalysisResult:
|
||||
"""Analyze a URL for phishing/scam indicators."""
|
||||
system = """Analyze the URL for phishing/scam indicators. Respond in JSON:
|
||||
{
|
||||
"is_phishing": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"risk_factors": ["factor1"],
|
||||
"explanation": "Brief explanation"
|
||||
}
|
||||
|
||||
Check for: domain impersonation, urgency tactics, credential requests, too-good-to-be-true offers."""
|
||||
|
||||
user_message = f"URL: {url}"
|
||||
if message_content:
|
||||
user_message += f"\n\nMessage context: {message_content}"
|
||||
|
||||
try:
|
||||
response = await self._call_api(system, user_message)
|
||||
data = self._parse_json_response(response)
|
||||
|
||||
return PhishingAnalysisResult(
|
||||
is_phishing=data.get("is_phishing", False),
|
||||
confidence=float(data.get("confidence", 0.0)),
|
||||
risk_factors=data.get("risk_factors", []),
|
||||
explanation=data.get("explanation", ""),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing phishing: {e}")
|
||||
return PhishingAnalysisResult(explanation=f"Error analyzing URL: {str(e)}")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
await self.client.close()
|
||||
|
||||
159
src/guardden/services/ai_rate_limiter.py
Normal file
159
src/guardden/services/ai_rate_limiter.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""AI usage tracking and rate limiting for cost control."""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TypedDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimitResult(TypedDict):
|
||||
"""Result of rate limit check."""
|
||||
|
||||
is_limited: bool
|
||||
reason: str
|
||||
guild_checks_this_hour: int
|
||||
user_checks_this_hour: int
|
||||
|
||||
|
||||
class UsageStats(TypedDict):
|
||||
"""AI usage statistics."""
|
||||
|
||||
guild_checks_this_hour: int
|
||||
guild_checks_today: int
|
||||
user_checks_this_hour: int
|
||||
|
||||
|
||||
class AIRateLimiter:
|
||||
"""Track AI usage and enforce rate limits to control costs."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize rate limiter."""
|
||||
# guild_id -> deque of timestamps
|
||||
self._guild_checks: dict[int, deque] = defaultdict(lambda: deque())
|
||||
# user_id -> deque of timestamps
|
||||
self._user_checks: dict[int, deque] = defaultdict(lambda: deque())
|
||||
|
||||
def _clean_old_entries(
|
||||
self,
|
||||
guild_id: int,
|
||||
user_id: int,
|
||||
max_guild: int,
|
||||
max_user: int,
|
||||
) -> None:
|
||||
"""Remove timestamps older than 1 hour."""
|
||||
now = datetime.now(timezone.utc)
|
||||
hour_ago = now - timedelta(hours=1)
|
||||
|
||||
# Clean guild entries
|
||||
self._guild_checks[guild_id] = deque(
|
||||
[ts for ts in self._guild_checks[guild_id] if ts > hour_ago],
|
||||
maxlen=max_guild,
|
||||
)
|
||||
|
||||
# Clean user entries
|
||||
self._user_checks[user_id] = deque(
|
||||
[ts for ts in self._user_checks[user_id] if ts > hour_ago],
|
||||
maxlen=max_user,
|
||||
)
|
||||
|
||||
def is_limited(
|
||||
self,
|
||||
guild_id: int,
|
||||
user_id: int,
|
||||
max_guild_per_hour: int,
|
||||
max_user_per_hour: int,
|
||||
) -> RateLimitResult:
|
||||
"""Check if rate limited.
|
||||
|
||||
Args:
|
||||
guild_id: Discord guild ID
|
||||
user_id: Discord user ID
|
||||
max_guild_per_hour: Maximum AI checks per hour for guild
|
||||
max_user_per_hour: Maximum AI checks per hour for user
|
||||
|
||||
Returns:
|
||||
RateLimitResult with is_limited and reason
|
||||
"""
|
||||
self._clean_old_entries(guild_id, user_id, max_guild_per_hour, max_user_per_hour)
|
||||
|
||||
guild_count = len(self._guild_checks[guild_id])
|
||||
user_count = len(self._user_checks[user_id])
|
||||
|
||||
# Check guild limit
|
||||
if guild_count >= max_guild_per_hour:
|
||||
logger.warning(
|
||||
f"Guild {guild_id} hit AI rate limit: {guild_count}/{max_guild_per_hour} checks this hour"
|
||||
)
|
||||
return RateLimitResult(
|
||||
is_limited=True,
|
||||
reason="guild_hourly_limit",
|
||||
guild_checks_this_hour=guild_count,
|
||||
user_checks_this_hour=user_count,
|
||||
)
|
||||
|
||||
# Check user limit
|
||||
if user_count >= max_user_per_hour:
|
||||
logger.info(
|
||||
f"User {user_id} in guild {guild_id} hit AI rate limit: {user_count}/{max_user_per_hour} checks this hour"
|
||||
)
|
||||
return RateLimitResult(
|
||||
is_limited=True,
|
||||
reason="user_hourly_limit",
|
||||
guild_checks_this_hour=guild_count,
|
||||
user_checks_this_hour=user_count,
|
||||
)
|
||||
|
||||
return RateLimitResult(
|
||||
is_limited=False,
|
||||
reason="",
|
||||
guild_checks_this_hour=guild_count,
|
||||
user_checks_this_hour=user_count,
|
||||
)
|
||||
|
||||
def track_usage(self, guild_id: int, user_id: int) -> None:
|
||||
"""Track that an AI check was performed.
|
||||
|
||||
Args:
|
||||
guild_id: Discord guild ID
|
||||
user_id: Discord user ID
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
self._guild_checks[guild_id].append(now)
|
||||
self._user_checks[user_id].append(now)
|
||||
|
||||
logger.debug(
|
||||
f"AI check tracked: guild={guild_id}, user={user_id}, "
|
||||
f"guild_total_this_hour={len(self._guild_checks[guild_id])}, "
|
||||
f"user_total_this_hour={len(self._user_checks[user_id])}"
|
||||
)
|
||||
|
||||
def get_stats(self, guild_id: int, user_id: int | None = None) -> UsageStats:
|
||||
"""Get usage statistics for status command.
|
||||
|
||||
Args:
|
||||
guild_id: Discord guild ID
|
||||
user_id: Optional Discord user ID for user-specific stats
|
||||
|
||||
Returns:
|
||||
UsageStats dictionary with counts
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
hour_ago = now - timedelta(hours=1)
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Guild stats
|
||||
guild_checks_this_hour = sum(1 for ts in self._guild_checks[guild_id] if ts > hour_ago)
|
||||
guild_checks_today = sum(1 for ts in self._guild_checks[guild_id] if ts > day_start)
|
||||
|
||||
# User stats
|
||||
user_checks_this_hour = 0
|
||||
if user_id:
|
||||
user_checks_this_hour = sum(1 for ts in self._user_checks[user_id] if ts > hour_ago)
|
||||
|
||||
return UsageStats(
|
||||
guild_checks_this_hour=guild_checks_this_hour,
|
||||
guild_checks_today=guild_checks_today,
|
||||
user_checks_this_hour=user_checks_this_hour,
|
||||
)
|
||||
@@ -1,14 +1,11 @@
|
||||
"""Automod service for content filtering and spam detection."""
|
||||
"""Automod service for spam detection - Minimal Version."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import signal
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, NamedTuple, Sequence
|
||||
from urllib.parse import urlparse
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import discord
|
||||
@@ -16,221 +13,17 @@ else:
|
||||
try:
|
||||
import discord # type: ignore
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
|
||||
class _DiscordStub:
|
||||
class Message: # minimal stub for type hints
|
||||
class Message:
|
||||
pass
|
||||
|
||||
discord = _DiscordStub() # type: ignore
|
||||
|
||||
from guardden.models.guild import BannedWord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Circuit breaker for regex safety
|
||||
class RegexTimeoutError(Exception):
|
||||
"""Raised when regex execution takes too long."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RegexCircuitBreaker:
|
||||
"""Circuit breaker to prevent catastrophic backtracking in regex patterns."""
|
||||
|
||||
def __init__(self, timeout_seconds: float = 0.1):
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.failed_patterns: dict[str, datetime] = {}
|
||||
self.failure_threshold = timedelta(minutes=5) # Disable pattern for 5 minutes after failure
|
||||
|
||||
def _timeout_handler(self, signum, frame):
|
||||
"""Signal handler for regex timeout."""
|
||||
raise RegexTimeoutError("Regex execution timed out")
|
||||
|
||||
def is_pattern_disabled(self, pattern: str) -> bool:
|
||||
"""Check if a pattern is temporarily disabled due to timeouts."""
|
||||
if pattern not in self.failed_patterns:
|
||||
return False
|
||||
|
||||
failure_time = self.failed_patterns[pattern]
|
||||
if datetime.now(timezone.utc) - failure_time > self.failure_threshold:
|
||||
# Re-enable the pattern after threshold time
|
||||
del self.failed_patterns[pattern]
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def safe_regex_search(self, pattern: str, text: str, flags: int = 0) -> bool:
|
||||
"""Safely execute regex search with timeout protection."""
|
||||
if self.is_pattern_disabled(pattern):
|
||||
logger.warning(f"Regex pattern temporarily disabled due to timeout: {pattern[:50]}...")
|
||||
return False
|
||||
|
||||
# Basic pattern validation to catch obviously problematic patterns
|
||||
if self._is_dangerous_pattern(pattern):
|
||||
logger.warning(f"Potentially dangerous regex pattern rejected: {pattern[:50]}...")
|
||||
return False
|
||||
|
||||
old_handler = None
|
||||
try:
|
||||
# Set up timeout signal (Unix systems only)
|
||||
if hasattr(signal, "SIGALRM"):
|
||||
old_handler = signal.signal(signal.SIGALRM, self._timeout_handler)
|
||||
signal.alarm(int(self.timeout_seconds * 1000)) # Convert to milliseconds
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Compile and execute regex
|
||||
compiled_pattern = re.compile(pattern, flags)
|
||||
result = bool(compiled_pattern.search(text))
|
||||
|
||||
execution_time = time.perf_counter() - start_time
|
||||
|
||||
# Log slow patterns for monitoring
|
||||
if execution_time > self.timeout_seconds * 0.8:
|
||||
logger.warning(
|
||||
f"Slow regex pattern (took {execution_time:.3f}s): {pattern[:50]}..."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except RegexTimeoutError:
|
||||
# Pattern took too long, disable it temporarily
|
||||
self.failed_patterns[pattern] = datetime.now(timezone.utc)
|
||||
logger.error(f"Regex pattern timed out and disabled: {pattern[:50]}...")
|
||||
return False
|
||||
|
||||
except re.error as e:
|
||||
logger.warning(f"Invalid regex pattern '{pattern[:50]}...': {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in regex execution: {e}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
# Clean up timeout signal
|
||||
if hasattr(signal, "SIGALRM") and old_handler is not None:
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
|
||||
def _is_dangerous_pattern(self, pattern: str) -> bool:
|
||||
"""Basic heuristic to detect potentially dangerous regex patterns."""
|
||||
# Check for patterns that are commonly problematic
|
||||
dangerous_indicators = [
|
||||
r"(\w+)+", # Nested quantifiers
|
||||
r"(\d+)+", # Nested quantifiers on digits
|
||||
r"(.+)+", # Nested quantifiers on anything
|
||||
r"(.*)+", # Nested quantifiers on anything (greedy)
|
||||
r"(\w*)+", # Nested quantifiers with *
|
||||
r"(\S+)+", # Nested quantifiers on non-whitespace
|
||||
]
|
||||
|
||||
# Check for excessively long patterns
|
||||
if len(pattern) > 500:
|
||||
return True
|
||||
|
||||
# Check for nested quantifiers (simplified detection)
|
||||
if "+)+" in pattern or "*)+" in pattern or "?)+" in pattern:
|
||||
return True
|
||||
|
||||
# Check for excessive repetition operators
|
||||
if pattern.count("+") > 10 or pattern.count("*") > 10:
|
||||
return True
|
||||
|
||||
# Check for specific dangerous patterns
|
||||
for dangerous in dangerous_indicators:
|
||||
if dangerous in pattern:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Global circuit breaker instance
|
||||
_regex_circuit_breaker = RegexCircuitBreaker()
|
||||
|
||||
|
||||
# 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 - more restrictive for security
|
||||
URL_PATTERN = re.compile(
|
||||
r"https?://(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:/[^\s]*)?|"
|
||||
r"(?:www\.)?[a-zA-Z0-9-]+\.(?:com|org|net|io|gg|co|me|tv|xyz|top|club|work|click|link|info|gov|edu)(?:/[^\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 = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpamConfig:
|
||||
"""Configuration for spam thresholds."""
|
||||
|
||||
"""Spam detection configuration."""
|
||||
message_rate_limit: int = 5
|
||||
message_rate_window: int = 5
|
||||
duplicate_threshold: int = 3
|
||||
@@ -239,324 +32,158 @@ class SpamConfig:
|
||||
mention_rate_window: int = 60
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str:
|
||||
"""Normalize a domain or URL for allowlist checks with security validation."""
|
||||
if not value or not isinstance(value, str):
|
||||
return ""
|
||||
|
||||
if any(char in value for char in ["\x00", "\n", "\r", "\t"]):
|
||||
return ""
|
||||
|
||||
text = value.strip().lower()
|
||||
if not text or len(text) > 2000: # Prevent excessively long URLs
|
||||
return ""
|
||||
|
||||
try:
|
||||
if "://" not in text:
|
||||
text = f"http://{text}"
|
||||
|
||||
parsed = urlparse(text)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
# Additional validation for hostname
|
||||
if not hostname or len(hostname) > 253: # RFC limit
|
||||
return ""
|
||||
|
||||
# Check for malicious patterns
|
||||
if any(char in hostname for char in [" ", "\x00", "\n", "\r", "\t"]):
|
||||
return ""
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9.-]+", hostname):
|
||||
return ""
|
||||
if hostname.startswith(".") or hostname.endswith(".") or ".." in hostname:
|
||||
return ""
|
||||
for label in hostname.split("."):
|
||||
if not label:
|
||||
return ""
|
||||
if label.startswith("-") or label.endswith("-"):
|
||||
return ""
|
||||
|
||||
# Remove www prefix
|
||||
if hostname.startswith("www."):
|
||||
hostname = hostname[4:]
|
||||
|
||||
return hostname
|
||||
except (ValueError, UnicodeError, Exception):
|
||||
# urlparse can raise various exceptions with malicious input
|
||||
return ""
|
||||
@dataclass
|
||||
class AutomodResult:
|
||||
"""Result of an automod check."""
|
||||
matched_filter: str
|
||||
reason: str
|
||||
should_delete: bool = True
|
||||
should_warn: bool = False
|
||||
should_strike: bool = False
|
||||
should_timeout: bool = False
|
||||
timeout_duration: int | None = None
|
||||
|
||||
|
||||
def is_allowed_domain(hostname: str, allowlist: set[str]) -> bool:
|
||||
"""Check if a hostname is allowlisted."""
|
||||
if not hostname:
|
||||
return False
|
||||
for domain in allowlist:
|
||||
if hostname == domain or hostname.endswith(f".{domain}"):
|
||||
return True
|
||||
return False
|
||||
class SpamTracker:
|
||||
"""Track user spam behavior."""
|
||||
|
||||
def __init__(self):
|
||||
# guild_id -> user_id -> deque of message timestamps
|
||||
self.message_times: dict[int, dict[int, list[float]]] = defaultdict(lambda: defaultdict(list))
|
||||
# guild_id -> user_id -> deque of message contents for duplicate detection
|
||||
self.message_contents: dict[int, dict[int, list[str]]] = defaultdict(lambda: defaultdict(list))
|
||||
# guild_id -> user_id -> deque of mention timestamps
|
||||
self.mention_times: dict[int, dict[int, list[float]]] = defaultdict(lambda: defaultdict(list))
|
||||
# Last cleanup time
|
||||
self.last_cleanup = time.time()
|
||||
|
||||
def cleanup_old_entries(self):
|
||||
"""Periodically cleanup old entries to prevent memory leaks."""
|
||||
now = time.time()
|
||||
if now - self.last_cleanup < 300: # Cleanup every 5 minutes
|
||||
return
|
||||
|
||||
cutoff = now - 3600 # Keep last hour of data
|
||||
|
||||
for guild_data in [self.message_times, self.mention_times]:
|
||||
for guild_id in list(guild_data.keys()):
|
||||
for user_id in list(guild_data[guild_id].keys()):
|
||||
# Remove old timestamps
|
||||
guild_data[guild_id][user_id] = [
|
||||
ts for ts in guild_data[guild_id][user_id] if ts > cutoff
|
||||
]
|
||||
# Remove empty users
|
||||
if not guild_data[guild_id][user_id]:
|
||||
del guild_data[guild_id][user_id]
|
||||
# Remove empty guilds
|
||||
if not guild_data[guild_id]:
|
||||
del guild_data[guild_id]
|
||||
|
||||
# Cleanup message contents
|
||||
for guild_id in list(self.message_contents.keys()):
|
||||
for user_id in list(self.message_contents[guild_id].keys()):
|
||||
# Keep only last 10 messages per user
|
||||
self.message_contents[guild_id][user_id] = self.message_contents[guild_id][user_id][-10:]
|
||||
if not self.message_contents[guild_id][user_id]:
|
||||
del self.message_contents[guild_id][user_id]
|
||||
if not self.message_contents[guild_id]:
|
||||
del self.message_contents[guild_id]
|
||||
|
||||
self.last_cleanup = now
|
||||
|
||||
|
||||
class AutomodService:
|
||||
"""Service for automatic content moderation."""
|
||||
"""Service for spam detection - no banned words, no scam links, no invites."""
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
# Default spam thresholds
|
||||
def __init__(self):
|
||||
self.spam_tracker = SpamTracker()
|
||||
self.default_spam_config = SpamConfig()
|
||||
|
||||
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
|
||||
# Use simple string operations for basic patterns to avoid regex overhead
|
||||
normalized = content.lower()
|
||||
|
||||
# Remove special characters (simplified approach)
|
||||
normalized = "".join(c for c in normalized if c.isalnum() or c.isspace())
|
||||
|
||||
# Normalize whitespace
|
||||
normalized = " ".join(normalized.split())
|
||||
|
||||
return normalized
|
||||
|
||||
def check_banned_words(
|
||||
self, content: str, banned_words: Sequence[BannedWord]
|
||||
) -> AutomodResult | None:
|
||||
"""Check message against banned words list."""
|
||||
content_lower = content.lower()
|
||||
|
||||
for banned in banned_words:
|
||||
matched = False
|
||||
|
||||
if banned.is_regex:
|
||||
# Use circuit breaker for safe regex execution
|
||||
if _regex_circuit_breaker.safe_regex_search(banned.pattern, content, re.IGNORECASE):
|
||||
matched = True
|
||||
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, allowlist: list[str] | None = None
|
||||
) -> 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",
|
||||
)
|
||||
|
||||
allowlist_set = {normalize_domain(domain) for domain in allowlist or [] if domain}
|
||||
|
||||
# Check URLs for suspicious TLDs
|
||||
urls = URL_PATTERN.findall(content)
|
||||
for url in urls:
|
||||
# Limit URL length to prevent processing extremely long URLs
|
||||
if len(url) > 2000:
|
||||
continue
|
||||
|
||||
url_lower = url.lower()
|
||||
hostname = normalize_domain(url)
|
||||
|
||||
# Skip if hostname normalization failed (security check)
|
||||
if not hostname:
|
||||
continue
|
||||
|
||||
if allowlist_set and is_allowed_domain(hostname, allowlist_set):
|
||||
continue
|
||||
|
||||
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,
|
||||
message: "discord.Message",
|
||||
anti_spam_enabled: bool = True,
|
||||
spam_config: SpamConfig | None = None,
|
||||
) -> AutomodResult | None:
|
||||
"""Check message for spam behavior."""
|
||||
"""Check message for spam patterns.
|
||||
|
||||
Args:
|
||||
message: Discord message to check
|
||||
anti_spam_enabled: Whether spam detection is enabled
|
||||
spam_config: Spam configuration settings
|
||||
|
||||
Returns:
|
||||
AutomodResult if spam detected, None otherwise
|
||||
"""
|
||||
if not anti_spam_enabled:
|
||||
return None
|
||||
|
||||
# Skip DM messages
|
||||
if message.guild is None:
|
||||
return None
|
||||
|
||||
config = spam_config or self.default_spam_config
|
||||
|
||||
guild_id = message.guild.id
|
||||
user_id = message.author.id
|
||||
tracker = self._spam_trackers[guild_id][user_id]
|
||||
now = datetime.now(timezone.utc)
|
||||
now = time.time()
|
||||
|
||||
# Cleanup old records
|
||||
tracker.cleanup()
|
||||
# Periodic cleanup
|
||||
self.spam_tracker.cleanup_old_entries()
|
||||
|
||||
# Check message rate
|
||||
content_hash = self._get_content_hash(message.content)
|
||||
tracker.messages.append(SpamRecord(content_hash, now))
|
||||
# Check 1: Message rate limiting
|
||||
message_times = self.spam_tracker.message_times[guild_id][user_id]
|
||||
cutoff_time = now - config.message_rate_window
|
||||
|
||||
# Rate limit check
|
||||
recent_window = now - timedelta(seconds=config.message_rate_window)
|
||||
recent_messages = [m for m in tracker.messages if m.timestamp > recent_window]
|
||||
# Remove old timestamps
|
||||
message_times = [ts for ts in message_times if ts > cutoff_time]
|
||||
self.spam_tracker.message_times[guild_id][user_id] = message_times
|
||||
|
||||
if len(recent_messages) > config.message_rate_limit:
|
||||
# Add current message
|
||||
message_times.append(now)
|
||||
|
||||
if len(message_times) > config.message_rate_limit:
|
||||
return AutomodResult(
|
||||
matched_filter="spam_rate_limit",
|
||||
reason=f"Exceeded message rate limit ({len(message_times)} messages in {config.message_rate_window}s)",
|
||||
should_delete=True,
|
||||
should_timeout=True,
|
||||
timeout_duration=60, # 1 minute timeout
|
||||
reason=(
|
||||
f"Sending messages too fast ({len(recent_messages)} in "
|
||||
f"{config.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)
|
||||
# Check 2: Duplicate messages
|
||||
message_contents = self.spam_tracker.message_contents[guild_id][user_id]
|
||||
message_contents.append(message.content)
|
||||
self.spam_tracker.message_contents[guild_id][user_id] = message_contents[-10:] # Keep last 10
|
||||
|
||||
# Count duplicates in recent messages
|
||||
duplicate_count = message_contents.count(message.content)
|
||||
if duplicate_count >= config.duplicate_threshold:
|
||||
return AutomodResult(
|
||||
matched_filter="spam_duplicate",
|
||||
reason=f"Duplicate message posted {duplicate_count} times",
|
||||
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
|
||||
|
||||
# Check 3: Mass mentions in single message
|
||||
mention_count = len(message.mentions)
|
||||
if mention_count > config.mention_limit:
|
||||
return AutomodResult(
|
||||
matched_filter="spam_mass_mentions",
|
||||
reason=f"Too many mentions in single message ({mention_count})",
|
||||
should_delete=True,
|
||||
should_timeout=True,
|
||||
timeout_duration=300, # 5 minute timeout
|
||||
reason=f"Mass mentions detected ({mention_count} mentions)",
|
||||
matched_filter="mass_mention",
|
||||
)
|
||||
|
||||
# Check 4: Mention rate limiting
|
||||
if mention_count > 0:
|
||||
if tracker.last_mention_time:
|
||||
window = timedelta(seconds=config.mention_rate_window)
|
||||
if now - tracker.last_mention_time > window:
|
||||
tracker.mention_count = 0
|
||||
tracker.mention_count += mention_count
|
||||
tracker.last_mention_time = now
|
||||
mention_times = self.spam_tracker.mention_times[guild_id][user_id]
|
||||
mention_cutoff = now - config.mention_rate_window
|
||||
|
||||
if tracker.mention_count > config.mention_rate_limit:
|
||||
# Remove old timestamps
|
||||
mention_times = [ts for ts in mention_times if ts > mention_cutoff]
|
||||
|
||||
# Add current mentions
|
||||
mention_times.extend([now] * mention_count)
|
||||
self.spam_tracker.mention_times[guild_id][user_id] = mention_times
|
||||
|
||||
if len(mention_times) > config.mention_rate_limit:
|
||||
return AutomodResult(
|
||||
matched_filter="spam_mention_rate",
|
||||
reason=f"Exceeded mention rate limit ({len(mention_times)} mentions in {config.mention_rate_window}s)",
|
||||
should_delete=True,
|
||||
should_timeout=True,
|
||||
timeout_duration=300,
|
||||
reason=(
|
||||
"Too many mentions in a short period "
|
||||
f"({tracker.mention_count} in {config.mention_rate_window}s)"
|
||||
),
|
||||
matched_filter="mention_rate",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
_automod_service = AutomodService()
|
||||
|
||||
|
||||
def detect_scam_links(content: str, allowlist: list[str] | None = None) -> AutomodResult | None:
|
||||
"""Convenience wrapper for scam detection."""
|
||||
return _automod_service.check_scam_links(content, allowlist)
|
||||
|
||||
83
src/guardden/services/config_loader.py
Normal file
83
src/guardden/services/config_loader.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Configuration loader from single YAML file."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigLoader:
|
||||
"""Load and manage configuration from single YAML file."""
|
||||
|
||||
def __init__(self, config_path: Path):
|
||||
"""Initialize config loader.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.yml file
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self.config: dict[str, Any] = {}
|
||||
|
||||
async def load(self) -> dict[str, Any]:
|
||||
"""Load configuration from YAML file.
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist
|
||||
yaml.YAMLError: If config file is invalid YAML
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||
|
||||
with open(self.config_path) as f:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
logger.info(f"Configuration loaded from {self.config_path}")
|
||||
return self.config
|
||||
|
||||
async def reload(self) -> dict[str, Any]:
|
||||
"""Reload configuration from YAML file.
|
||||
|
||||
Returns:
|
||||
Updated configuration dictionary
|
||||
"""
|
||||
logger.info("Reloading configuration...")
|
||||
return await self.load()
|
||||
|
||||
def get_setting(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a nested setting using dot notation.
|
||||
|
||||
Examples:
|
||||
get_setting("ai_moderation.sensitivity") -> 80
|
||||
get_setting("automod.enabled") -> True
|
||||
|
||||
Args:
|
||||
key: Dot-separated path to setting (e.g., "ai_moderation.sensitivity")
|
||||
default: Default value if setting not found
|
||||
|
||||
Returns:
|
||||
Setting value or default
|
||||
"""
|
||||
parts = key.split(".")
|
||||
value = self.config
|
||||
|
||||
for part in parts:
|
||||
if isinstance(value, dict) and part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Get entire configuration dictionary.
|
||||
|
||||
Returns:
|
||||
Complete configuration
|
||||
"""
|
||||
return self.config
|
||||
@@ -1,457 +0,0 @@
|
||||
"""Configuration migration system for GuardDen.
|
||||
|
||||
This module handles migration from database-based Discord command configuration
|
||||
to file-based YAML configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
import yaml
|
||||
|
||||
from guardden.services.database import Database
|
||||
from guardden.services.guild_config import GuildConfigService
|
||||
from guardden.services.file_config import FileConfigurationManager
|
||||
from guardden.models.guild import Guild, GuildSettings, BannedWord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigurationMigrator:
|
||||
"""Handles migration from database to file-based configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
database: Database,
|
||||
guild_config_service: GuildConfigService,
|
||||
file_config_manager: FileConfigurationManager
|
||||
):
|
||||
"""Initialize the migration system.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
guild_config_service: Current guild configuration service
|
||||
file_config_manager: File configuration manager
|
||||
"""
|
||||
self.database = database
|
||||
self.guild_config_service = guild_config_service
|
||||
self.file_config_manager = file_config_manager
|
||||
|
||||
async def migrate_all_guilds(self, backup_existing: bool = True) -> Dict[str, Any]:
|
||||
"""Migrate all guild configurations from database to files.
|
||||
|
||||
Args:
|
||||
backup_existing: Whether to backup existing configuration files
|
||||
|
||||
Returns:
|
||||
Dictionary with migration results
|
||||
"""
|
||||
logger.info("Starting migration of all guild configurations...")
|
||||
|
||||
results = {
|
||||
"migrated_guilds": [],
|
||||
"failed_guilds": [],
|
||||
"skipped_guilds": [],
|
||||
"total_guilds": 0,
|
||||
"banned_words_migrated": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.database.session() as session:
|
||||
# Get all guilds from database
|
||||
from sqlalchemy import select
|
||||
stmt = select(Guild)
|
||||
result = await session.execute(stmt)
|
||||
guilds = result.scalars().all()
|
||||
|
||||
results["total_guilds"] = len(guilds)
|
||||
logger.info(f"Found {len(guilds)} guilds to migrate")
|
||||
|
||||
for guild in guilds:
|
||||
try:
|
||||
await self._migrate_single_guild(guild, backup_existing, results)
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to migrate guild {guild.id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
results["failed_guilds"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name,
|
||||
"error": error_msg
|
||||
})
|
||||
results["errors"].append(error_msg)
|
||||
|
||||
# Migrate wordlists
|
||||
await self._migrate_wordlists(results)
|
||||
|
||||
logger.info(f"Migration complete. Success: {len(results['migrated_guilds'])}, "
|
||||
f"Failed: {len(results['failed_guilds'])}, "
|
||||
f"Skipped: {len(results['skipped_guilds'])}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Migration failed with error: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
results["errors"].append(error_msg)
|
||||
|
||||
return results
|
||||
|
||||
async def _migrate_single_guild(
|
||||
self,
|
||||
guild: Guild,
|
||||
backup_existing: bool,
|
||||
results: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Migrate a single guild's configuration."""
|
||||
|
||||
# Check if file already exists
|
||||
guild_file = self.file_config_manager.config_dir / "guilds" / f"guild-{guild.id}.yml"
|
||||
|
||||
if guild_file.exists():
|
||||
if backup_existing:
|
||||
backup_path = await self.file_config_manager.backup_config(guild.id)
|
||||
logger.info(f"Backed up existing config for guild {guild.id}: {backup_path}")
|
||||
else:
|
||||
results["skipped_guilds"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name,
|
||||
"reason": "Configuration file already exists"
|
||||
})
|
||||
return
|
||||
|
||||
# Get guild settings from database
|
||||
async with self.database.session() as session:
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = select(Guild).where(Guild.id == guild.id).options(
|
||||
selectinload(Guild.settings),
|
||||
selectinload(Guild.banned_words)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
guild_with_settings = result.scalar_one_or_none()
|
||||
|
||||
if not guild_with_settings:
|
||||
raise Exception(f"Guild {guild.id} not found in database")
|
||||
|
||||
# Convert to file configuration format
|
||||
file_config = await self._convert_guild_to_file_config(guild_with_settings)
|
||||
|
||||
# Write to file
|
||||
with open(guild_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(file_config, f, default_flow_style=False, indent=2, sort_keys=False)
|
||||
|
||||
logger.info(f"Migrated guild {guild.id} ({guild.name}) to {guild_file}")
|
||||
|
||||
results["migrated_guilds"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name,
|
||||
"file_path": str(guild_file),
|
||||
"banned_words_count": len(guild_with_settings.banned_words) if guild_with_settings.banned_words else 0
|
||||
})
|
||||
|
||||
if guild_with_settings.banned_words:
|
||||
results["banned_words_migrated"] += len(guild_with_settings.banned_words)
|
||||
|
||||
async def _convert_guild_to_file_config(self, guild: Guild) -> Dict[str, Any]:
|
||||
"""Convert database guild model to file configuration format."""
|
||||
|
||||
settings = guild.settings if guild.settings else GuildSettings()
|
||||
|
||||
# Base guild information
|
||||
config = {
|
||||
"guild_id": guild.id,
|
||||
"name": guild.name,
|
||||
"owner_id": guild.owner_id,
|
||||
"premium": guild.premium,
|
||||
|
||||
# Add migration metadata
|
||||
"_migration_info": {
|
||||
"migrated_at": datetime.now().isoformat(),
|
||||
"migrated_from": "database",
|
||||
"original_created_at": guild.created_at.isoformat() if guild.created_at else None,
|
||||
"original_updated_at": guild.updated_at.isoformat() if guild.updated_at else None
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"general": {
|
||||
"prefix": settings.prefix,
|
||||
"locale": settings.locale
|
||||
},
|
||||
"channels": {
|
||||
"log_channel_id": settings.log_channel_id,
|
||||
"mod_log_channel_id": settings.mod_log_channel_id,
|
||||
"welcome_channel_id": settings.welcome_channel_id
|
||||
},
|
||||
"roles": {
|
||||
"mute_role_id": settings.mute_role_id,
|
||||
"verified_role_id": settings.verified_role_id,
|
||||
"mod_role_ids": settings.mod_role_ids or []
|
||||
},
|
||||
"moderation": {
|
||||
"automod_enabled": settings.automod_enabled,
|
||||
"anti_spam_enabled": settings.anti_spam_enabled,
|
||||
"link_filter_enabled": settings.link_filter_enabled,
|
||||
"strike_actions": settings.strike_actions or {}
|
||||
},
|
||||
"automod": {
|
||||
"message_rate_limit": settings.message_rate_limit,
|
||||
"message_rate_window": settings.message_rate_window,
|
||||
"duplicate_threshold": settings.duplicate_threshold,
|
||||
"mention_limit": settings.mention_limit,
|
||||
"mention_rate_limit": settings.mention_rate_limit,
|
||||
"mention_rate_window": settings.mention_rate_window,
|
||||
"scam_allowlist": settings.scam_allowlist or []
|
||||
},
|
||||
"ai_moderation": {
|
||||
"enabled": settings.ai_moderation_enabled,
|
||||
"sensitivity": settings.ai_sensitivity,
|
||||
"confidence_threshold": settings.ai_confidence_threshold,
|
||||
"log_only": settings.ai_log_only,
|
||||
"nsfw_detection_enabled": settings.nsfw_detection_enabled,
|
||||
"nsfw_only_filtering": getattr(settings, 'nsfw_only_filtering', False)
|
||||
},
|
||||
"verification": {
|
||||
"enabled": settings.verification_enabled,
|
||||
"type": settings.verification_type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add banned words if any exist
|
||||
if guild.banned_words:
|
||||
config["banned_words"] = []
|
||||
for banned_word in guild.banned_words:
|
||||
config["banned_words"].append({
|
||||
"pattern": banned_word.pattern,
|
||||
"action": banned_word.action,
|
||||
"is_regex": banned_word.is_regex,
|
||||
"reason": banned_word.reason,
|
||||
"category": banned_word.category,
|
||||
"source": banned_word.source,
|
||||
"managed": banned_word.managed,
|
||||
"added_by": banned_word.added_by,
|
||||
"created_at": banned_word.created_at.isoformat() if banned_word.created_at else None
|
||||
})
|
||||
|
||||
return config
|
||||
|
||||
async def _migrate_wordlists(self, results: Dict[str, Any]) -> None:
|
||||
"""Migrate global banned words and allowlists to wordlist files."""
|
||||
|
||||
# Get all managed banned words (global wordlists)
|
||||
async with self.database.session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(BannedWord).where(BannedWord.managed == True)
|
||||
result = await session.execute(stmt)
|
||||
managed_words = result.scalars().all()
|
||||
|
||||
if managed_words:
|
||||
# Group by source and category
|
||||
sources = {}
|
||||
for word in managed_words:
|
||||
source = word.source or "unknown"
|
||||
if source not in sources:
|
||||
sources[source] = []
|
||||
sources[source].append(word)
|
||||
|
||||
# Update external sources configuration
|
||||
external_config_path = self.file_config_manager.config_dir / "wordlists" / "external-sources.yml"
|
||||
|
||||
if external_config_path.exists():
|
||||
with open(external_config_path, 'r', encoding='utf-8') as f:
|
||||
external_config = yaml.safe_load(f)
|
||||
else:
|
||||
external_config = {"sources": []}
|
||||
|
||||
# Add migration info for discovered sources
|
||||
for source_name, words in sources.items():
|
||||
existing_source = next(
|
||||
(s for s in external_config["sources"] if s["name"] == source_name),
|
||||
None
|
||||
)
|
||||
|
||||
if not existing_source:
|
||||
# Add new source based on migrated words
|
||||
category = words[0].category if words[0].category else "profanity"
|
||||
action = words[0].action if words[0].action else "warn"
|
||||
|
||||
external_config["sources"].append({
|
||||
"name": source_name,
|
||||
"url": f"# MIGRATED: Originally from {source_name}",
|
||||
"category": category,
|
||||
"action": action,
|
||||
"reason": f"Migrated from database source: {source_name}",
|
||||
"enabled": False, # Disabled by default, needs manual URL
|
||||
"update_interval_hours": 168,
|
||||
"applies_to_guilds": [],
|
||||
"_migration_info": {
|
||||
"migrated_at": datetime.now().isoformat(),
|
||||
"original_word_count": len(words),
|
||||
"needs_url_configuration": True
|
||||
}
|
||||
})
|
||||
|
||||
# Write updated external sources
|
||||
with open(external_config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(external_config, f, default_flow_style=False, indent=2)
|
||||
|
||||
results["external_sources_updated"] = True
|
||||
results["managed_words_found"] = len(managed_words)
|
||||
|
||||
logger.info(f"Updated external sources configuration with {len(sources)} discovered sources")
|
||||
|
||||
async def verify_migration(self, guild_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""Verify that migration was successful by comparing database and file configs.
|
||||
|
||||
Args:
|
||||
guild_ids: Specific guild IDs to verify, or None for all
|
||||
|
||||
Returns:
|
||||
Verification results
|
||||
"""
|
||||
logger.info("Verifying migration results...")
|
||||
|
||||
verification_results = {
|
||||
"verified_guilds": [],
|
||||
"mismatches": [],
|
||||
"missing_files": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.database.session() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
if guild_ids:
|
||||
stmt = select(Guild).where(Guild.id.in_(guild_ids))
|
||||
else:
|
||||
stmt = select(Guild)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
guilds = result.scalars().all()
|
||||
|
||||
for guild in guilds:
|
||||
try:
|
||||
await self._verify_single_guild(guild, verification_results)
|
||||
except Exception as e:
|
||||
error_msg = f"Verification error for guild {guild.id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
verification_results["errors"].append(error_msg)
|
||||
|
||||
logger.info(f"Verification complete. Verified: {len(verification_results['verified_guilds'])}, "
|
||||
f"Mismatches: {len(verification_results['mismatches'])}, "
|
||||
f"Missing: {len(verification_results['missing_files'])}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Verification failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
verification_results["errors"].append(error_msg)
|
||||
|
||||
return verification_results
|
||||
|
||||
async def _verify_single_guild(self, guild: Guild, results: Dict[str, Any]) -> None:
|
||||
"""Verify migration for a single guild."""
|
||||
guild_file = self.file_config_manager.config_dir / "guilds" / f"guild-{guild.id}.yml"
|
||||
|
||||
if not guild_file.exists():
|
||||
results["missing_files"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name,
|
||||
"expected_file": str(guild_file)
|
||||
})
|
||||
return
|
||||
|
||||
# Load file configuration
|
||||
with open(guild_file, 'r', encoding='utf-8') as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
|
||||
# Get database configuration
|
||||
db_config = await self.guild_config_service.get_config(guild.id)
|
||||
|
||||
# Compare key settings
|
||||
mismatches = []
|
||||
|
||||
if file_config.get("guild_id") != guild.id:
|
||||
mismatches.append("guild_id")
|
||||
|
||||
if file_config.get("name") != guild.name:
|
||||
mismatches.append("name")
|
||||
|
||||
if db_config:
|
||||
file_settings = file_config.get("settings", {})
|
||||
|
||||
# Compare AI moderation settings
|
||||
ai_settings = file_settings.get("ai_moderation", {})
|
||||
if ai_settings.get("enabled") != db_config.ai_moderation_enabled:
|
||||
mismatches.append("ai_moderation.enabled")
|
||||
if ai_settings.get("sensitivity") != db_config.ai_sensitivity:
|
||||
mismatches.append("ai_moderation.sensitivity")
|
||||
|
||||
# Compare automod settings
|
||||
automod_settings = file_settings.get("automod", {})
|
||||
if automod_settings.get("message_rate_limit") != db_config.message_rate_limit:
|
||||
mismatches.append("automod.message_rate_limit")
|
||||
|
||||
if mismatches:
|
||||
results["mismatches"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name,
|
||||
"mismatched_fields": mismatches
|
||||
})
|
||||
else:
|
||||
results["verified_guilds"].append({
|
||||
"guild_id": guild.id,
|
||||
"guild_name": guild.name
|
||||
})
|
||||
|
||||
async def cleanup_database_configs(self, confirm: bool = False) -> Dict[str, Any]:
|
||||
"""Clean up database configurations after successful migration.
|
||||
|
||||
WARNING: This will delete all guild settings and banned words from the database.
|
||||
Only run after verifying migration is successful.
|
||||
|
||||
Args:
|
||||
confirm: Must be True to actually perform cleanup
|
||||
|
||||
Returns:
|
||||
Cleanup results
|
||||
"""
|
||||
if not confirm:
|
||||
raise ValueError("cleanup_database_configs requires confirm=True to prevent accidental data loss")
|
||||
|
||||
logger.warning("STARTING DATABASE CLEANUP - This will delete all migrated configuration data!")
|
||||
|
||||
cleanup_results = {
|
||||
"guild_settings_deleted": 0,
|
||||
"banned_words_deleted": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.database.session() as session:
|
||||
# Delete all guild settings
|
||||
from sqlalchemy import delete
|
||||
|
||||
# Delete banned words first (foreign key constraint)
|
||||
banned_words_result = await session.execute(delete(BannedWord))
|
||||
cleanup_results["banned_words_deleted"] = banned_words_result.rowcount
|
||||
|
||||
# Delete guild settings
|
||||
guild_settings_result = await session.execute(delete(GuildSettings))
|
||||
cleanup_results["guild_settings_deleted"] = guild_settings_result.rowcount
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.warning(f"Database cleanup complete. Deleted {cleanup_results['guild_settings_deleted']} "
|
||||
f"guild settings and {cleanup_results['banned_words_deleted']} banned words.")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Database cleanup failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
cleanup_results["errors"].append(error_msg)
|
||||
|
||||
return cleanup_results
|
||||
@@ -1,502 +0,0 @@
|
||||
"""File-based configuration system for GuardDen.
|
||||
|
||||
This module provides a complete file-based configuration system that replaces
|
||||
Discord commands for bot configuration. Features include:
|
||||
- YAML configuration files with schema validation
|
||||
- Hot-reloading with file watching
|
||||
- Migration from database settings
|
||||
- Comprehensive error handling and rollback
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
import yaml
|
||||
import jsonschema
|
||||
from watchfiles import watch, Change
|
||||
except ImportError as e:
|
||||
raise ImportError(f"Required dependencies missing: {e}. Install with 'pip install pyyaml jsonschema watchfiles'")
|
||||
|
||||
from guardden.models.guild import GuildSettings
|
||||
from guardden.services.database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration is invalid or cannot be loaded."""
|
||||
file_path: str
|
||||
error_message: str
|
||||
validation_errors: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileConfig:
|
||||
"""Represents a loaded configuration file."""
|
||||
path: Path
|
||||
content: Dict[str, Any]
|
||||
last_modified: float
|
||||
content_hash: str
|
||||
is_valid: bool = True
|
||||
validation_errors: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildConfig:
|
||||
"""Processed guild configuration."""
|
||||
guild_id: int
|
||||
name: str
|
||||
owner_id: Optional[int]
|
||||
premium: bool
|
||||
settings: Dict[str, Any]
|
||||
file_path: Path
|
||||
last_updated: datetime
|
||||
|
||||
|
||||
class FileConfigurationManager:
|
||||
"""Manages file-based configuration with hot-reloading and validation."""
|
||||
|
||||
def __init__(self, config_dir: str = "config", database: Optional[Database] = None):
|
||||
"""Initialize the configuration manager.
|
||||
|
||||
Args:
|
||||
config_dir: Base directory for configuration files
|
||||
database: Database instance for migration and fallback
|
||||
"""
|
||||
self.config_dir = Path(config_dir)
|
||||
self.database = database
|
||||
self.guild_configs: Dict[int, GuildConfig] = {}
|
||||
self.wordlist_config: Optional[FileConfig] = None
|
||||
self.allowlist_config: Optional[FileConfig] = None
|
||||
self.external_sources_config: Optional[FileConfig] = None
|
||||
|
||||
# File watching
|
||||
self._watch_task: Optional[asyncio.Task] = None
|
||||
self._watch_enabled = True
|
||||
self._callbacks: List[Callable[[int, GuildConfig], None]] = []
|
||||
|
||||
# Validation schemas
|
||||
self._schemas: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Backup configurations (for rollback)
|
||||
self._backup_configs: Dict[int, GuildConfig] = {}
|
||||
|
||||
# Ensure directories exist
|
||||
self._ensure_directories()
|
||||
|
||||
def _ensure_directories(self) -> None:
|
||||
"""Create configuration directories if they don't exist."""
|
||||
dirs = [
|
||||
self.config_dir / "guilds",
|
||||
self.config_dir / "wordlists",
|
||||
self.config_dir / "schemas",
|
||||
self.config_dir / "templates",
|
||||
self.config_dir / "backups"
|
||||
]
|
||||
for dir_path in dirs:
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the configuration system."""
|
||||
logger.info("Initializing file-based configuration system...")
|
||||
|
||||
try:
|
||||
# Load validation schemas
|
||||
await self._load_schemas()
|
||||
|
||||
# Load all configuration files
|
||||
await self._load_all_configs()
|
||||
|
||||
# Start file watching for hot-reload
|
||||
if self._watch_enabled:
|
||||
await self._start_file_watching()
|
||||
|
||||
logger.info(f"Configuration system initialized with {len(self.guild_configs)} guild configs")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize configuration system: {e}")
|
||||
raise
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Shutdown the configuration system."""
|
||||
logger.info("Shutting down configuration system...")
|
||||
|
||||
if self._watch_task and not self._watch_task.done():
|
||||
self._watch_task.cancel()
|
||||
try:
|
||||
await self._watch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("Configuration system shutdown complete")
|
||||
|
||||
async def _load_schemas(self) -> None:
|
||||
"""Load validation schemas from files."""
|
||||
schema_dir = self.config_dir / "schemas"
|
||||
|
||||
schema_files = {
|
||||
"guild": schema_dir / "guild-schema.yml",
|
||||
"wordlists": schema_dir / "wordlists-schema.yml"
|
||||
}
|
||||
|
||||
for schema_name, schema_path in schema_files.items():
|
||||
if schema_path.exists():
|
||||
try:
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
self._schemas[schema_name] = yaml.safe_load(f)
|
||||
logger.debug(f"Loaded schema: {schema_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load schema {schema_name}: {e}")
|
||||
else:
|
||||
logger.warning(f"Schema file not found: {schema_path}")
|
||||
|
||||
async def _load_all_configs(self) -> None:
|
||||
"""Load all configuration files."""
|
||||
# Load guild configurations
|
||||
guild_dir = self.config_dir / "guilds"
|
||||
if guild_dir.exists():
|
||||
for config_file in guild_dir.glob("guild-*.yml"):
|
||||
try:
|
||||
await self._load_guild_config(config_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load guild config {config_file}: {e}")
|
||||
|
||||
# Load wordlist configurations
|
||||
await self._load_wordlist_configs()
|
||||
|
||||
async def _load_guild_config(self, file_path: Path) -> Optional[GuildConfig]:
|
||||
"""Load a single guild configuration file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = yaml.safe_load(f)
|
||||
|
||||
# Validate against schema
|
||||
if 'guild' in self._schemas:
|
||||
try:
|
||||
jsonschema.validate(content, self._schemas['guild'])
|
||||
except jsonschema.ValidationError as e:
|
||||
logger.error(f"Schema validation failed for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
# Extract guild information
|
||||
guild_id = content.get('guild_id')
|
||||
if not guild_id:
|
||||
logger.error(f"Guild config missing guild_id: {file_path}")
|
||||
return None
|
||||
|
||||
guild_config = GuildConfig(
|
||||
guild_id=guild_id,
|
||||
name=content.get('name', f"Guild {guild_id}"),
|
||||
owner_id=content.get('owner_id'),
|
||||
premium=content.get('premium', False),
|
||||
settings=content.get('settings', {}),
|
||||
file_path=file_path,
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
|
||||
# Backup current config before updating
|
||||
if guild_id in self.guild_configs:
|
||||
self._backup_configs[guild_id] = self.guild_configs[guild_id]
|
||||
|
||||
self.guild_configs[guild_id] = guild_config
|
||||
logger.debug(f"Loaded guild config for {guild_id}: {guild_config.name}")
|
||||
|
||||
# Notify callbacks of config change
|
||||
await self._notify_config_change(guild_id, guild_config)
|
||||
|
||||
return guild_config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading guild config {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def _load_wordlist_configs(self) -> None:
|
||||
"""Load wordlist configuration files."""
|
||||
wordlist_dir = self.config_dir / "wordlists"
|
||||
|
||||
configs = {
|
||||
"banned-words.yml": "wordlist_config",
|
||||
"domain-allowlists.yml": "allowlist_config",
|
||||
"external-sources.yml": "external_sources_config"
|
||||
}
|
||||
|
||||
for filename, attr_name in configs.items():
|
||||
file_path = wordlist_dir / filename
|
||||
if file_path.exists():
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = yaml.safe_load(f)
|
||||
|
||||
# Calculate content hash
|
||||
content_hash = hashlib.md5(str(content).encode()).hexdigest()
|
||||
|
||||
file_config = FileConfig(
|
||||
path=file_path,
|
||||
content=content,
|
||||
last_modified=file_path.stat().st_mtime,
|
||||
content_hash=content_hash
|
||||
)
|
||||
|
||||
setattr(self, attr_name, file_config)
|
||||
logger.debug(f"Loaded {filename}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load {filename}: {e}")
|
||||
|
||||
async def _start_file_watching(self) -> None:
|
||||
"""Start watching configuration files for changes."""
|
||||
if self._watch_task and not self._watch_task.done():
|
||||
return
|
||||
|
||||
self._watch_task = asyncio.create_task(self._file_watcher())
|
||||
logger.info("Started file watching for configuration hot-reload")
|
||||
|
||||
async def _file_watcher(self) -> None:
|
||||
"""Watch for file changes and reload configurations."""
|
||||
try:
|
||||
async for changes in watch(self.config_dir, recursive=True):
|
||||
for change_type, file_path in changes:
|
||||
file_path = Path(file_path)
|
||||
|
||||
# Only process YAML files
|
||||
if file_path.suffix != '.yml':
|
||||
continue
|
||||
|
||||
if change_type in (Change.added, Change.modified):
|
||||
await self._handle_file_change(file_path)
|
||||
elif change_type == Change.deleted:
|
||||
await self._handle_file_deletion(file_path)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("File watcher cancelled")
|
||||
except Exception as e:
|
||||
logger.error(f"File watcher error: {e}")
|
||||
|
||||
async def _handle_file_change(self, file_path: Path) -> None:
|
||||
"""Handle a file change event."""
|
||||
try:
|
||||
# Determine file type and reload appropriately
|
||||
if file_path.parent.name == "guilds" and file_path.name.startswith("guild-"):
|
||||
await self._load_guild_config(file_path)
|
||||
logger.info(f"Reloaded guild config: {file_path}")
|
||||
elif file_path.parent.name == "wordlists":
|
||||
await self._load_wordlist_configs()
|
||||
logger.info(f"Reloaded wordlist config: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling file change {file_path}: {e}")
|
||||
await self._rollback_config(file_path)
|
||||
|
||||
async def _handle_file_deletion(self, file_path: Path) -> None:
|
||||
"""Handle a file deletion event."""
|
||||
try:
|
||||
if file_path.parent.name == "guilds" and file_path.name.startswith("guild-"):
|
||||
# Extract guild ID from filename
|
||||
guild_id_str = file_path.stem.replace("guild-", "")
|
||||
try:
|
||||
guild_id = int(guild_id_str)
|
||||
if guild_id in self.guild_configs:
|
||||
del self.guild_configs[guild_id]
|
||||
logger.info(f"Removed guild config for deleted file: {file_path}")
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse guild ID from filename: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling file deletion {file_path}: {e}")
|
||||
|
||||
async def _rollback_config(self, file_path: Path) -> None:
|
||||
"""Rollback to previous configuration on error."""
|
||||
try:
|
||||
if file_path.parent.name == "guilds" and file_path.name.startswith("guild-"):
|
||||
guild_id_str = file_path.stem.replace("guild-", "")
|
||||
guild_id = int(guild_id_str)
|
||||
|
||||
if guild_id in self._backup_configs:
|
||||
self.guild_configs[guild_id] = self._backup_configs[guild_id]
|
||||
logger.info(f"Rolled back guild config for {guild_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during rollback for {file_path}: {e}")
|
||||
|
||||
async def _notify_config_change(self, guild_id: int, config: GuildConfig) -> None:
|
||||
"""Notify registered callbacks of configuration changes."""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(guild_id, config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in config change callback: {e}")
|
||||
|
||||
def register_change_callback(self, callback: Callable[[int, GuildConfig], None]) -> None:
|
||||
"""Register a callback for configuration changes."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def get_guild_config(self, guild_id: int) -> Optional[GuildConfig]:
|
||||
"""Get configuration for a specific guild."""
|
||||
return self.guild_configs.get(guild_id)
|
||||
|
||||
def get_all_guild_configs(self) -> Dict[int, GuildConfig]:
|
||||
"""Get all guild configurations."""
|
||||
return self.guild_configs.copy()
|
||||
|
||||
def get_wordlist_config(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get wordlist configuration."""
|
||||
return self.wordlist_config.content if self.wordlist_config else None
|
||||
|
||||
def get_allowlist_config(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get domain allowlist configuration."""
|
||||
return self.allowlist_config.content if self.allowlist_config else None
|
||||
|
||||
def get_external_sources_config(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get external sources configuration."""
|
||||
return self.external_sources_config.content if self.external_sources_config else None
|
||||
|
||||
async def create_guild_config(self, guild_id: int, name: str, owner_id: Optional[int] = None) -> Path:
|
||||
"""Create a new guild configuration file from template."""
|
||||
guild_file = self.config_dir / "guilds" / f"guild-{guild_id}.yml"
|
||||
template_file = self.config_dir / "templates" / "guild-default.yml"
|
||||
|
||||
if guild_file.exists():
|
||||
raise ConfigurationError(
|
||||
str(guild_file),
|
||||
"Guild configuration already exists"
|
||||
)
|
||||
|
||||
# Load template
|
||||
if template_file.exists():
|
||||
with open(template_file, 'r', encoding='utf-8') as f:
|
||||
template_content = yaml.safe_load(f)
|
||||
else:
|
||||
# Create basic template if file doesn't exist
|
||||
template_content = await self._create_basic_template()
|
||||
|
||||
# Customize template
|
||||
template_content['guild_id'] = guild_id
|
||||
template_content['name'] = name
|
||||
if owner_id:
|
||||
template_content['owner_id'] = owner_id
|
||||
|
||||
# Write configuration file
|
||||
with open(guild_file, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(template_content, f, default_flow_style=False, indent=2)
|
||||
|
||||
logger.info(f"Created guild configuration: {guild_file}")
|
||||
|
||||
# Load the new configuration
|
||||
await self._load_guild_config(guild_file)
|
||||
|
||||
return guild_file
|
||||
|
||||
async def _create_basic_template(self) -> Dict[str, Any]:
|
||||
"""Create a basic configuration template."""
|
||||
return {
|
||||
"guild_id": 0,
|
||||
"name": "",
|
||||
"premium": False,
|
||||
"settings": {
|
||||
"general": {
|
||||
"prefix": "!",
|
||||
"locale": "en"
|
||||
},
|
||||
"channels": {
|
||||
"log_channel_id": None,
|
||||
"mod_log_channel_id": None,
|
||||
"welcome_channel_id": None
|
||||
},
|
||||
"roles": {
|
||||
"mute_role_id": None,
|
||||
"verified_role_id": None,
|
||||
"mod_role_ids": []
|
||||
},
|
||||
"moderation": {
|
||||
"automod_enabled": True,
|
||||
"anti_spam_enabled": True,
|
||||
"link_filter_enabled": False,
|
||||
"strike_actions": {
|
||||
"1": {"action": "warn"},
|
||||
"3": {"action": "timeout", "duration": 300},
|
||||
"5": {"action": "kick"},
|
||||
"7": {"action": "ban"}
|
||||
}
|
||||
},
|
||||
"automod": {
|
||||
"message_rate_limit": 5,
|
||||
"message_rate_window": 5,
|
||||
"duplicate_threshold": 3,
|
||||
"mention_limit": 5,
|
||||
"mention_rate_limit": 10,
|
||||
"mention_rate_window": 60,
|
||||
"scam_allowlist": []
|
||||
},
|
||||
"ai_moderation": {
|
||||
"enabled": True,
|
||||
"sensitivity": 80,
|
||||
"confidence_threshold": 0.7,
|
||||
"log_only": False,
|
||||
"nsfw_detection_enabled": True,
|
||||
"nsfw_only_filtering": False
|
||||
},
|
||||
"verification": {
|
||||
"enabled": False,
|
||||
"type": "button"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async def export_from_database(self, guild_id: int) -> Optional[Path]:
|
||||
"""Export guild configuration from database to file."""
|
||||
if not self.database:
|
||||
raise ConfigurationError("", "Database not available for export")
|
||||
|
||||
try:
|
||||
# Get guild settings from database
|
||||
async with self.database.session() as session:
|
||||
# This would need to be implemented based on your database service
|
||||
# For now, return None to indicate not implemented
|
||||
pass
|
||||
|
||||
logger.info(f"Exported guild {guild_id} configuration to file")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export guild {guild_id} from database: {e}")
|
||||
raise ConfigurationError(
|
||||
f"guild-{guild_id}.yml",
|
||||
f"Database export failed: {str(e)}"
|
||||
)
|
||||
|
||||
def validate_config(self, config_data: Dict[str, Any], schema_name: str = "guild") -> List[str]:
|
||||
"""Validate configuration data against schema."""
|
||||
errors = []
|
||||
|
||||
if schema_name in self._schemas:
|
||||
try:
|
||||
jsonschema.validate(config_data, self._schemas[schema_name])
|
||||
except jsonschema.ValidationError as e:
|
||||
errors.append(str(e))
|
||||
else:
|
||||
errors.append(f"Schema '{schema_name}' not found")
|
||||
|
||||
return errors
|
||||
|
||||
async def backup_config(self, guild_id: int) -> Path:
|
||||
"""Create a backup of guild configuration."""
|
||||
config = self.get_guild_config(guild_id)
|
||||
if not config:
|
||||
raise ConfigurationError(f"guild-{guild_id}.yml", "Guild configuration not found")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_file = self.config_dir / "backups" / f"guild-{guild_id}_{timestamp}.yml"
|
||||
|
||||
# Copy current configuration file
|
||||
import shutil
|
||||
shutil.copy2(config.file_path, backup_file)
|
||||
|
||||
logger.info(f"Created backup: {backup_file}")
|
||||
return backup_file
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Verification service for new member challenges."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChallengeType(str, Enum):
|
||||
"""Types of verification challenges."""
|
||||
|
||||
BUTTON = "button" # Simple button click
|
||||
CAPTCHA = "captcha" # Text-based captcha
|
||||
MATH = "math" # Simple math problem
|
||||
EMOJI = "emoji" # Select correct emoji
|
||||
QUESTIONS = "questions" # Custom questions
|
||||
|
||||
|
||||
@dataclass
|
||||
class Challenge:
|
||||
"""Represents a verification challenge."""
|
||||
|
||||
challenge_type: ChallengeType
|
||||
question: str
|
||||
answer: str
|
||||
options: list[str] = field(default_factory=list) # For multiple choice
|
||||
expires_at: datetime = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc) + timedelta(minutes=10)
|
||||
)
|
||||
attempts: int = 0
|
||||
max_attempts: int = 3
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now(timezone.utc) > self.expires_at
|
||||
|
||||
def check_answer(self, response: str) -> bool:
|
||||
"""Check if the response is correct."""
|
||||
self.attempts += 1
|
||||
return response.strip().lower() == self.answer.lower()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingVerification:
|
||||
"""Tracks a pending verification for a user."""
|
||||
|
||||
user_id: int
|
||||
guild_id: int
|
||||
challenge: Challenge
|
||||
message_id: int | None = None
|
||||
channel_id: int | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ChallengeGenerator(ABC):
|
||||
"""Abstract base class for challenge generators."""
|
||||
|
||||
@abstractmethod
|
||||
def generate(self) -> Challenge:
|
||||
"""Generate a new challenge."""
|
||||
pass
|
||||
|
||||
|
||||
class ButtonChallengeGenerator(ChallengeGenerator):
|
||||
"""Generates simple button click challenges."""
|
||||
|
||||
def generate(self) -> Challenge:
|
||||
return Challenge(
|
||||
challenge_type=ChallengeType.BUTTON,
|
||||
question="Click the button below to verify you're human.",
|
||||
answer="verified",
|
||||
)
|
||||
|
||||
|
||||
class CaptchaChallengeGenerator(ChallengeGenerator):
|
||||
"""Generates text-based captcha challenges."""
|
||||
|
||||
def __init__(self, length: int = 6) -> None:
|
||||
self.length = length
|
||||
|
||||
def generate(self) -> Challenge:
|
||||
# Generate random alphanumeric code (avoiding confusing chars)
|
||||
chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
code = "".join(random.choices(chars, k=self.length))
|
||||
|
||||
# Create visual representation with some obfuscation
|
||||
visual = self._create_visual(code)
|
||||
|
||||
return Challenge(
|
||||
challenge_type=ChallengeType.CAPTCHA,
|
||||
question=f"Enter the code shown below:\n```\n{visual}\n```",
|
||||
answer=code,
|
||||
)
|
||||
|
||||
def _create_visual(self, code: str) -> str:
|
||||
"""Create a simple text-based visual captcha."""
|
||||
lines = []
|
||||
# Add some noise characters
|
||||
noise_chars = ".-*~^"
|
||||
|
||||
for _ in range(2):
|
||||
lines.append("".join(random.choices(noise_chars, k=len(code) * 2)))
|
||||
|
||||
# Add the code with spacing
|
||||
spaced = " ".join(code)
|
||||
lines.append(spaced)
|
||||
|
||||
for _ in range(2):
|
||||
lines.append("".join(random.choices(noise_chars, k=len(code) * 2)))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MathChallengeGenerator(ChallengeGenerator):
|
||||
"""Generates simple math problem challenges."""
|
||||
|
||||
def generate(self) -> Challenge:
|
||||
# Generate simple addition/subtraction/multiplication
|
||||
operation = random.choice(["+", "-", "*"])
|
||||
|
||||
if operation == "*":
|
||||
a = random.randint(2, 10)
|
||||
b = random.randint(2, 10)
|
||||
else:
|
||||
a = random.randint(10, 50)
|
||||
b = random.randint(1, 20)
|
||||
|
||||
if operation == "+":
|
||||
answer = a + b
|
||||
elif operation == "-":
|
||||
# Ensure positive result
|
||||
if b > a:
|
||||
a, b = b, a
|
||||
answer = a - b
|
||||
else:
|
||||
answer = a * b
|
||||
|
||||
return Challenge(
|
||||
challenge_type=ChallengeType.MATH,
|
||||
question=f"Solve this math problem: **{a} {operation} {b} = ?**",
|
||||
answer=str(answer),
|
||||
)
|
||||
|
||||
|
||||
class EmojiChallengeGenerator(ChallengeGenerator):
|
||||
"""Generates emoji selection challenges."""
|
||||
|
||||
EMOJI_SETS = [
|
||||
("animals", ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼"]),
|
||||
("fruits", ["🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓"]),
|
||||
("weather", ["☀️", "🌙", "⭐", "🌧️", "❄️", "🌈", "⚡", "🌪️"]),
|
||||
("sports", ["⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏉", "🎱"]),
|
||||
]
|
||||
|
||||
def generate(self) -> Challenge:
|
||||
category, emojis = random.choice(self.EMOJI_SETS)
|
||||
target = random.choice(emojis)
|
||||
|
||||
# Create options with the target and some others
|
||||
options = [target]
|
||||
other_emojis = [e for e in emojis if e != target]
|
||||
options.extend(random.sample(other_emojis, min(3, len(other_emojis))))
|
||||
random.shuffle(options)
|
||||
|
||||
return Challenge(
|
||||
challenge_type=ChallengeType.EMOJI,
|
||||
question=f"Select the {self._emoji_name(target)} emoji:",
|
||||
answer=target,
|
||||
options=options,
|
||||
)
|
||||
|
||||
def _emoji_name(self, emoji: str) -> str:
|
||||
"""Get a description of the emoji."""
|
||||
names = {
|
||||
"🐶": "dog",
|
||||
"🐱": "cat",
|
||||
"🐭": "mouse",
|
||||
"🐹": "hamster",
|
||||
"🐰": "rabbit",
|
||||
"🦊": "fox",
|
||||
"🐻": "bear",
|
||||
"🐼": "panda",
|
||||
"🍎": "apple",
|
||||
"🍐": "pear",
|
||||
"🍊": "orange",
|
||||
"🍋": "lemon",
|
||||
"🍌": "banana",
|
||||
"🍉": "watermelon",
|
||||
"🍇": "grapes",
|
||||
"🍓": "strawberry",
|
||||
"☀️": "sun",
|
||||
"🌙": "moon",
|
||||
"⭐": "star",
|
||||
"🌧️": "rain",
|
||||
"❄️": "snowflake",
|
||||
"🌈": "rainbow",
|
||||
"⚡": "lightning",
|
||||
"🌪️": "tornado",
|
||||
"⚽": "soccer ball",
|
||||
"🏀": "basketball",
|
||||
"🏈": "football",
|
||||
"⚾": "baseball",
|
||||
"🎾": "tennis",
|
||||
"🏐": "volleyball",
|
||||
"🏉": "rugby",
|
||||
"🎱": "pool ball",
|
||||
}
|
||||
return names.get(emoji, "correct")
|
||||
|
||||
|
||||
class QuestionsChallengeGenerator(ChallengeGenerator):
|
||||
"""Generates custom question challenges."""
|
||||
|
||||
DEFAULT_QUESTIONS = [
|
||||
("What color is the sky on a clear day?", "blue"),
|
||||
("Type the word 'verified' to continue.", "verified"),
|
||||
("What is 2 + 2?", "4"),
|
||||
("What planet do we live on?", "earth"),
|
||||
]
|
||||
|
||||
def __init__(self, questions: list[tuple[str, str]] | None = None) -> None:
|
||||
self.questions = questions or self.DEFAULT_QUESTIONS
|
||||
|
||||
def generate(self) -> Challenge:
|
||||
question, answer = random.choice(self.questions)
|
||||
return Challenge(
|
||||
challenge_type=ChallengeType.QUESTIONS,
|
||||
question=question,
|
||||
answer=answer,
|
||||
)
|
||||
|
||||
|
||||
class VerificationService:
|
||||
"""Service for managing member verification."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Pending verifications: {(guild_id, user_id): PendingVerification}
|
||||
self._pending: dict[tuple[int, int], PendingVerification] = {}
|
||||
|
||||
# Challenge generators
|
||||
self._generators: dict[ChallengeType, ChallengeGenerator] = {
|
||||
ChallengeType.BUTTON: ButtonChallengeGenerator(),
|
||||
ChallengeType.CAPTCHA: CaptchaChallengeGenerator(),
|
||||
ChallengeType.MATH: MathChallengeGenerator(),
|
||||
ChallengeType.EMOJI: EmojiChallengeGenerator(),
|
||||
ChallengeType.QUESTIONS: QuestionsChallengeGenerator(),
|
||||
}
|
||||
|
||||
def create_challenge(
|
||||
self,
|
||||
user_id: int,
|
||||
guild_id: int,
|
||||
challenge_type: ChallengeType = ChallengeType.BUTTON,
|
||||
) -> PendingVerification:
|
||||
"""Create a new verification challenge for a user."""
|
||||
generator = self._generators.get(challenge_type)
|
||||
if not generator:
|
||||
generator = self._generators[ChallengeType.BUTTON]
|
||||
|
||||
challenge = generator.generate()
|
||||
pending = PendingVerification(
|
||||
user_id=user_id,
|
||||
guild_id=guild_id,
|
||||
challenge=challenge,
|
||||
)
|
||||
|
||||
self._pending[(guild_id, user_id)] = pending
|
||||
return pending
|
||||
|
||||
def get_pending(self, guild_id: int, user_id: int) -> PendingVerification | None:
|
||||
"""Get a pending verification for a user."""
|
||||
return self._pending.get((guild_id, user_id))
|
||||
|
||||
def verify(self, guild_id: int, user_id: int, response: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Attempt to verify a user's response.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
pending = self._pending.get((guild_id, user_id))
|
||||
|
||||
if not pending:
|
||||
return False, "No pending verification found."
|
||||
|
||||
if pending.challenge.is_expired:
|
||||
self._pending.pop((guild_id, user_id), None)
|
||||
return False, "Verification expired. Please request a new one."
|
||||
|
||||
if pending.challenge.attempts >= pending.challenge.max_attempts:
|
||||
self._pending.pop((guild_id, user_id), None)
|
||||
return False, "Too many failed attempts. Please request a new verification."
|
||||
|
||||
if pending.challenge.check_answer(response):
|
||||
self._pending.pop((guild_id, user_id), None)
|
||||
return True, "Verification successful!"
|
||||
|
||||
remaining = pending.challenge.max_attempts - pending.challenge.attempts
|
||||
return False, f"Incorrect. {remaining} attempt(s) remaining."
|
||||
|
||||
def cancel(self, guild_id: int, user_id: int) -> bool:
|
||||
"""Cancel a pending verification."""
|
||||
return self._pending.pop((guild_id, user_id), None) is not None
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove expired verifications. Returns count of removed."""
|
||||
expired = [key for key, pending in self._pending.items() if pending.challenge.is_expired]
|
||||
for key in expired:
|
||||
self._pending.pop(key, None)
|
||||
return len(expired)
|
||||
|
||||
def get_pending_count(self, guild_id: int) -> int:
|
||||
"""Get count of pending verifications for a guild."""
|
||||
return sum(1 for (gid, _) in self._pending if gid == guild_id)
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Managed wordlist sync service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from guardden.config import Settings, WordlistSourceConfig
|
||||
from guardden.models import BannedWord, Guild
|
||||
from guardden.services.database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_WORDLIST_ENTRY_LENGTH = 128
|
||||
REQUEST_TIMEOUT = 20.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WordlistSource:
|
||||
name: str
|
||||
url: str
|
||||
category: str
|
||||
action: str
|
||||
reason: str
|
||||
is_regex: bool = False
|
||||
|
||||
|
||||
DEFAULT_SOURCES: list[WordlistSource] = [
|
||||
WordlistSource(
|
||||
name="ldnoobw_en",
|
||||
url="https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/en",
|
||||
category="soft",
|
||||
action="warn",
|
||||
reason="Auto list: profanity",
|
||||
is_regex=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_entry(line: str) -> str:
|
||||
text = line.strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) > MAX_WORDLIST_ENTRY_LENGTH:
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _parse_wordlist(text: str) -> list[str]:
|
||||
entries: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("#") or line.startswith("//") or line.startswith(";"):
|
||||
continue
|
||||
normalized = _normalize_entry(line)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
entries.append(normalized)
|
||||
seen.add(normalized)
|
||||
return entries
|
||||
|
||||
|
||||
class WordlistService:
|
||||
"""Fetches and syncs managed wordlists into per-guild bans."""
|
||||
|
||||
def __init__(self, database: Database, settings: Settings) -> None:
|
||||
self.database = database
|
||||
self.settings = settings
|
||||
self.sources = self._load_sources(settings)
|
||||
self.update_interval = timedelta(hours=settings.wordlist_update_hours)
|
||||
self.last_sync: datetime | None = None
|
||||
|
||||
@staticmethod
|
||||
def _load_sources(settings: Settings) -> list[WordlistSource]:
|
||||
if settings.wordlist_sources:
|
||||
sources: list[WordlistSource] = []
|
||||
for src in settings.wordlist_sources:
|
||||
if not src.enabled:
|
||||
continue
|
||||
sources.append(
|
||||
WordlistSource(
|
||||
name=src.name,
|
||||
url=src.url,
|
||||
category=src.category,
|
||||
action=src.action,
|
||||
reason=src.reason,
|
||||
is_regex=src.is_regex,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
return list(DEFAULT_SOURCES)
|
||||
|
||||
async def _fetch_source(self, source: WordlistSource) -> list[str]:
|
||||
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
||||
response = await client.get(source.url)
|
||||
response.raise_for_status()
|
||||
return _parse_wordlist(response.text)
|
||||
|
||||
async def sync_all(self) -> None:
|
||||
if not self.settings.wordlist_enabled:
|
||||
logger.info("Managed wordlist sync disabled")
|
||||
return
|
||||
if not self.sources:
|
||||
logger.warning("No wordlist sources configured")
|
||||
return
|
||||
|
||||
logger.info("Starting managed wordlist sync (%d sources)", len(self.sources))
|
||||
async with self.database.session() as session:
|
||||
guild_ids = list((await session.execute(select(Guild.id))).scalars().all())
|
||||
|
||||
for source in self.sources:
|
||||
try:
|
||||
entries = await self._fetch_source(source)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to fetch wordlist %s: %s", source.name, exc)
|
||||
continue
|
||||
|
||||
if not entries:
|
||||
logger.warning("Wordlist %s returned no entries", source.name)
|
||||
continue
|
||||
|
||||
await self._sync_source_to_guilds(source, entries, guild_ids)
|
||||
|
||||
self.last_sync = datetime.now(timezone.utc)
|
||||
logger.info("Managed wordlist sync completed")
|
||||
|
||||
async def _sync_source_to_guilds(
|
||||
self, source: WordlistSource, entries: Iterable[str], guild_ids: list[int]
|
||||
) -> None:
|
||||
entry_set = set(entries)
|
||||
async with self.database.session() as session:
|
||||
for guild_id in guild_ids:
|
||||
result = await session.execute(
|
||||
select(BannedWord).where(
|
||||
BannedWord.guild_id == guild_id,
|
||||
BannedWord.managed.is_(True),
|
||||
BannedWord.source == source.name,
|
||||
)
|
||||
)
|
||||
existing = list(result.scalars().all())
|
||||
existing_set = {word.pattern.lower() for word in existing}
|
||||
|
||||
to_add = entry_set - existing_set
|
||||
to_remove = existing_set - entry_set
|
||||
|
||||
if to_remove:
|
||||
await session.execute(
|
||||
delete(BannedWord).where(
|
||||
BannedWord.guild_id == guild_id,
|
||||
BannedWord.managed.is_(True),
|
||||
BannedWord.source == source.name,
|
||||
BannedWord.pattern.in_(to_remove),
|
||||
)
|
||||
)
|
||||
|
||||
if to_add:
|
||||
session.add_all(
|
||||
[
|
||||
BannedWord(
|
||||
guild_id=guild_id,
|
||||
pattern=pattern,
|
||||
is_regex=source.is_regex,
|
||||
action=source.action,
|
||||
reason=source.reason,
|
||||
source=source.name,
|
||||
category=source.category,
|
||||
managed=True,
|
||||
added_by=0,
|
||||
)
|
||||
for pattern in to_add
|
||||
]
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Utility functions for sending moderation notifications."""
|
||||
|
||||
import logging
|
||||
|
||||
import discord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_moderation_notification(
|
||||
user: discord.User | discord.Member,
|
||||
channel: discord.TextChannel,
|
||||
embed: discord.Embed,
|
||||
send_in_channel: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Send moderation notification to user.
|
||||
|
||||
Attempts to DM the user first. If DM fails and send_in_channel is True,
|
||||
sends a temporary PUBLIC message in the channel that auto-deletes after 10 seconds.
|
||||
|
||||
WARNING: In-channel messages are PUBLIC and visible to all users in the channel.
|
||||
They are NOT private or ephemeral due to Discord API limitations.
|
||||
|
||||
Args:
|
||||
user: The user to notify
|
||||
channel: The channel to send fallback message in
|
||||
embed: The embed to send
|
||||
send_in_channel: Whether to send PUBLIC in-channel message if DM fails (default: False)
|
||||
|
||||
Returns:
|
||||
True if notification was delivered (via DM or channel), False otherwise
|
||||
"""
|
||||
# Try to DM the user first
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
logger.debug(f"Sent moderation notification DM to {user}")
|
||||
return True
|
||||
except discord.Forbidden:
|
||||
logger.debug(f"User {user} has DMs disabled, attempting in-channel notification")
|
||||
pass
|
||||
except discord.HTTPException as e:
|
||||
logger.warning(f"Failed to DM user {user}: {e}")
|
||||
pass
|
||||
|
||||
# DM failed, try in-channel notification if enabled
|
||||
if not send_in_channel:
|
||||
logger.debug(f"In-channel warnings disabled, notification to {user} not sent")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create a simplified message for in-channel notification
|
||||
# Mention the user so they see it, but keep it brief
|
||||
in_channel_embed = discord.Embed(
|
||||
title="⚠️ Moderation Notice",
|
||||
description=f"{user.mention}, your message was flagged by moderation.\n\n"
|
||||
f"**Reason:** {embed.description or 'Violation detected'}\n\n"
|
||||
f"_This message will be deleted in 10 seconds._",
|
||||
color=embed.color or discord.Color.orange(),
|
||||
)
|
||||
|
||||
# Add timeout info if present
|
||||
for field in embed.fields:
|
||||
if field.name in ("Timeout", "Action"):
|
||||
in_channel_embed.add_field(
|
||||
name=field.name,
|
||||
value=field.value,
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await channel.send(embed=in_channel_embed, delete_after=10)
|
||||
logger.info(f"Sent in-channel moderation notification to {user} in {channel}")
|
||||
return True
|
||||
except discord.Forbidden:
|
||||
logger.warning(f"Cannot send in-channel notification in {channel}: missing permissions")
|
||||
return False
|
||||
except discord.HTTPException as e:
|
||||
logger.warning(f"Failed to send in-channel notification in {channel}: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user