diff --git a/.env.example b/.env.example index c0cf821..3bee18d 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,8 @@ GUARDDEN_DISCORD_TOKEN=your_discord_bot_token_here GUARDDEN_DISCORD_PREFIX=! -# Optional access control (comma-separated IDs) -# Example: "123456789012345678,987654321098765432" +# Optional access control (comma-separated IDs, no quotes) +# Example: 123456789012345678,987654321098765432 GUARDDEN_ALLOWED_GUILDS= GUARDDEN_OWNER_IDS= @@ -24,15 +24,3 @@ GUARDDEN_ANTHROPIC_API_KEY= # OpenAI API key (required if AI_PROVIDER=openai) # Get your key at: https://platform.openai.com/api-keys GUARDDEN_OPENAI_API_KEY= - -# Dashboard configuration -GUARDDEN_DASHBOARD_BASE_URL=http://localhost:8080 -GUARDDEN_DASHBOARD_SECRET_KEY=change-me -GUARDDEN_DASHBOARD_ENTRA_TENANT_ID= -GUARDDEN_DASHBOARD_ENTRA_CLIENT_ID= -GUARDDEN_DASHBOARD_ENTRA_CLIENT_SECRET= -GUARDDEN_DASHBOARD_DISCORD_CLIENT_ID= -GUARDDEN_DASHBOARD_DISCORD_CLIENT_SECRET= -GUARDDEN_DASHBOARD_OWNER_DISCORD_ID= -GUARDDEN_DASHBOARD_OWNER_ENTRA_OBJECT_ID= -GUARDDEN_DASHBOARD_CORS_ORIGINS= diff --git a/CLAUDE.md b/CLAUDE.md index 285a090..b77241a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,8 @@ docker compose up -d - 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` ## Verification System @@ -82,6 +84,17 @@ docker compose up -d - `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 + ## Adding New Cogs 1. Create file in `src/guardden/cogs/` diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index e5a1b4d..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,371 +0,0 @@ -# GuardDen Migration Guide: Discord Commands to File-Based Configuration - -This guide explains how to migrate from Discord command-based configuration to the new file-based YAML configuration system. - -## Why Migrate? - -The new file-based configuration system offers several advantages: - -- **✅ Version Control**: Track configuration changes with Git -- **✅ No Discord Dependencies**: Configure without being in Discord -- **✅ Backup & Restore**: Easy configuration backups and restoration -- **✅ Hot-Reloading**: Changes apply without bot restarts -- **✅ Better Organization**: Clean, structured configuration files -- **✅ Schema Validation**: Automatic error checking and prevention -- **✅ Bulk Operations**: Configure multiple servers efficiently - -## Migration Overview - -### Phase 1: Preparation -1. ✅ Update GuardDen to the latest version -2. ✅ Install new dependencies: `pip install -e ".[dev,ai]"` -3. ✅ Backup your current configuration (optional but recommended) - -### Phase 2: Export Existing Settings -4. ✅ Run the migration tool to export Discord settings to files -5. ✅ Verify migration results -6. ✅ Review and customize exported configurations - -### Phase 3: Switch to File-Based Configuration -7. ✅ Test the new configuration system -8. ✅ (Optional) Clean up database configurations - -## Step-by-Step Migration - -### Step 1: Update Dependencies - -```bash -# Install new required packages -pip install -e ".[dev,ai]" - -# Or if you prefer individual packages: -pip install pyyaml jsonschema watchfiles -``` - -### Step 2: Run Migration Tool - -Export your existing Discord command settings to YAML files: - -```bash -# Export all guild configurations from database to files -python -m guardden.cli.config migrate from-database - -# This will create files like: -# config/guilds/guild-123456789.yml -# config/guilds/guild-987654321.yml -# etc. -``` - -**Migration Output Example:** -``` -🔄 Starting migration from database to files... -📦 Existing files will be backed up - -📊 Migration Results: - ✅ Migrated: 3 guilds - ❌ Failed: 0 guilds - ⏭️ Skipped: 0 guilds - 📝 Banned words migrated: 45 - -✅ Successfully migrated guilds: - • 123456789: My Gaming Server (12 banned words) - • 987654321: Friends Chat (8 banned words) - • 555666777: Test Server (0 banned words) -``` - -### Step 3: Verify Migration - -Check that the migration was successful: - -```bash -# Verify all guilds -python -m guardden.cli.config migrate verify - -# Or verify specific guilds -python -m guardden.cli.config migrate verify 123456789 987654321 -``` - -### Step 4: Review Generated Configurations - -Examine the generated configuration files: - -```bash -# List all configurations -python -m guardden.cli.config guild list - -# Validate configurations -python -m guardden.cli.config guild validate -``` - -**Example Generated Configuration:** -```yaml -# config/guilds/guild-123456789.yml -guild_id: 123456789 -name: "My Gaming Server" -owner_id: 987654321 -premium: false - -settings: - general: - prefix: "!" - locale: "en" - - ai_moderation: - enabled: true - sensitivity: 80 - nsfw_only_filtering: false # ← Your new NSFW-only feature! - confidence_threshold: 0.7 - - automod: - message_rate_limit: 5 - scam_allowlist: - - "discord.com" - - "github.com" - -# Migrated banned words (if any) -banned_words: - - pattern: "spam" - action: delete - is_regex: false - reason: "Anti-spam filter" -``` - -### Step 5: Customize Your Configuration - -Now you can edit the YAML files directly or use the CLI: - -```bash -# Enable NSFW-only filtering (only block sexual content) -python -m guardden.cli.config guild edit 123456789 ai_moderation.nsfw_only_filtering true - -# Adjust AI sensitivity -python -m guardden.cli.config guild edit 123456789 ai_moderation.sensitivity 75 - -# Validate changes -python -m guardden.cli.config guild validate 123456789 -``` - -**Or edit files directly:** -```yaml -# Edit config/guilds/guild-123456789.yml -ai_moderation: - enabled: true - sensitivity: 75 # Changed from 80 - nsfw_only_filtering: true # Changed from false - confidence_threshold: 0.7 -``` - -### Step 6: Test the New System - -1. **Restart GuardDen** to load the file-based configuration: - ```bash - python -m guardden - ``` - -2. **Test hot-reloading** by editing a config file: - ```bash - # Edit a setting in config/guilds/guild-123456789.yml - # Changes should apply within seconds (check bot logs) - ``` - -3. **Verify settings in Discord** using read-only commands: - ``` - !config # View current settings - !ai # View AI moderation settings - !automod # View automod settings - ``` - -### Step 7: Manage Wordlists (Optional) - -Review and customize wordlist configurations: - -```bash -# View wordlist status -python -m guardden.cli.config wordlist info - -# Edit wordlists directly: -nano config/wordlists/banned-words.yml -nano config/wordlists/domain-allowlists.yml -nano config/wordlists/external-sources.yml -``` - -## Post-Migration Tasks - -### Backup Your Configuration - -```bash -# Create backups of specific guilds -python -m guardden.cli.config guild backup 123456789 - -# Or backup the entire config directory -cp -r config config-backup-$(date +%Y%m%d) -``` - -### Version Control Setup - -Add configuration to Git for version tracking: - -```bash -# Initialize Git repository (if not already) -git init -git add config/ -git commit -m "Add GuardDen file-based configuration" - -# Create .gitignore to exclude backups -echo "config/backups/" >> .gitignore -``` - -### Clean Up Database (Optional) - -**⚠️ WARNING: Only do this AFTER verifying migration is successful!** - -```bash -# This permanently deletes old configuration from database -python -c " -import asyncio -from guardden.services.config_migration import ConfigurationMigrator -from guardden.services.database import Database -from guardden.services.guild_config import GuildConfigService -from guardden.services.file_config import FileConfigurationManager - -async def cleanup(): - db = Database('your-db-url') - guild_service = GuildConfigService(db) - file_manager = FileConfigurationManager() - migrator = ConfigurationMigrator(db, guild_service, file_manager) - - # ⚠️ This deletes all guild settings from database - results = await migrator.cleanup_database_configs(confirm=True) - print(f'Cleaned up: {results}') - await db.close() - -asyncio.run(cleanup()) -" -``` - -## Troubleshooting - -### Common Issues - -**1. Migration Failed for Some Guilds** -```bash -# Check the specific error messages -python -m guardden.cli.config migrate from-database - -# Try migrating individual guilds if needed -# (This may require manual file creation) -``` - -**2. Configuration Validation Errors** -```bash -# Validate and see specific errors -python -m guardden.cli.config guild validate - -# Common fixes: -# - Check YAML syntax (indentation, colons, quotes) -# - Verify Discord IDs are numbers, not strings -# - Ensure boolean values are true/false, not True/False -``` - -**3. Hot-Reload Not Working** -- Check bot logs for configuration errors -- Ensure YAML syntax is correct -- Verify file permissions are readable -- Restart bot if needed: `python -m guardden` - -**4. Lost Configuration During Migration** -- Check `config/backups/` directory for backup files -- Database configurations are preserved during migration -- Re-run migration if needed: `python -m guardden.cli.config migrate from-database` - -### Getting Help - -**View CLI Help:** -```bash -python -m guardden.cli.config --help -python -m guardden.cli.config guild --help -python -m guardden.cli.config migrate --help -``` - -**Check Configuration Status:** -```bash -python -m guardden.cli.config guild list -python -m guardden.cli.config guild validate -python -m guardden.cli.config wordlist info -``` - -**Backup and Recovery:** -```bash -# Create backup before making changes -python -m guardden.cli.config guild backup - -# Recovery from backup (manual file copy) -cp config/backups/guild-123456789_20260124_123456.yml config/guilds/guild-123456789.yml -``` - -## Configuration Examples - -### NSFW-Only Filtering Setup - -For gaming communities that want to allow violence but block sexual content: - -```yaml -# config/guilds/guild-123456789.yml -ai_moderation: - enabled: true - sensitivity: 80 - nsfw_only_filtering: true # Only block sexual content - confidence_threshold: 0.7 - nsfw_detection_enabled: true - log_only: false -``` - -### High-Security Server Setup - -For family-friendly or professional servers: - -```yaml -ai_moderation: - enabled: true - sensitivity: 95 # Very strict - nsfw_only_filtering: false # Block all inappropriate content - confidence_threshold: 0.6 # Lower threshold = more sensitive - log_only: false - -automod: - message_rate_limit: 3 # Stricter rate limiting - message_rate_window: 5 - duplicate_threshold: 2 # Less tolerance for duplicates -``` - -### Development/Testing Server Setup - -For development or testing environments: - -```yaml -ai_moderation: - enabled: true - sensitivity: 50 # More lenient - nsfw_only_filtering: false - confidence_threshold: 0.8 # Higher threshold = less sensitive - log_only: true # Only log, don't take action - -automod: - message_rate_limit: 10 # More relaxed limits - message_rate_window: 5 -``` - -## Benefits of File-Based Configuration - -After migration, you'll enjoy: - -1. **Easy Bulk Changes**: Edit multiple server configs at once -2. **Configuration as Code**: Version control your bot settings -3. **Environment Management**: Different configs for dev/staging/prod -4. **Disaster Recovery**: Easy backup and restore of all settings -5. **No Discord Dependency**: Configure servers before bot joins -6. **Better Organization**: All settings in structured, documented files -7. **Hot-Reloading**: Changes apply instantly without restarts -8. **Schema Validation**: Automatic error checking prevents misconfigurations - -**Welcome to the new GuardDen configuration system! 🎉** \ No newline at end of file diff --git a/README.md b/README.md index 92fe695..be8e5d2 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm ### AI Moderation - **Text Analysis** - AI-powered content moderation using Claude or GPT - **NSFW Image Detection** - Automatic flagging of inappropriate images -- **NSFW-Only Filtering** - Option to only filter sexual content, allowing violence/harassment +- **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 ### Verification System - **Multiple Challenge Types** - Button, captcha, math problems, emoji selection @@ -36,13 +37,6 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm - Ban/unban events - All moderation actions -### Web Dashboard -- Servers overview with plan status and quick config links -- Users view with cross-guild search and strike totals -- Chats view for moderated message logs with filters -- Moderation logs, analytics, and configuration updates -- Config export for backups - ## Quick Start ### Prerequisites @@ -108,7 +102,6 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm ```bash docker compose up -d ``` -4. Open the dashboard (if configured): `http://localhost:8080` ### Local Development @@ -254,13 +247,23 @@ 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: false # true = only sexual content, false = all content + 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:** +**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) -- `true` = Only block sexual/nude content, allow violence and other content types + +**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 @@ -310,16 +313,6 @@ Configuration changes are automatically detected and applied without restarting | `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_DASHBOARD_BASE_URL` | Dashboard base URL for OAuth callbacks | `http://localhost:8080` | -| `GUARDDEN_DASHBOARD_SECRET_KEY` | Session secret for dashboard | Required | -| `GUARDDEN_DASHBOARD_ENTRA_TENANT_ID` | Entra tenant ID | Required | -| `GUARDDEN_DASHBOARD_ENTRA_CLIENT_ID` | Entra client ID | Required | -| `GUARDDEN_DASHBOARD_ENTRA_CLIENT_SECRET` | Entra client secret | Required | -| `GUARDDEN_DASHBOARD_DISCORD_CLIENT_ID` | Discord OAuth client ID | Required | -| `GUARDDEN_DASHBOARD_DISCORD_CLIENT_SECRET` | Discord OAuth client secret | Required | -| `GUARDDEN_DASHBOARD_OWNER_DISCORD_ID` | Discord user ID allowed | Required | -| `GUARDDEN_DASHBOARD_OWNER_ENTRA_OBJECT_ID` | Entra object ID allowed | Required | -| `GUARDDEN_DASHBOARD_CORS_ORIGINS` | Dashboard CORS origins | (empty = none) | | `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) | @@ -427,20 +420,6 @@ Edit config/wordlists/banned-words.yml | `!verify test [type]` | Test a verification challenge | | `!verify reset @user` | Reset verification for a user | -## Dashboard - -The dashboard provides owner-only visibility and configuration across all servers, including -servers, users, chats, moderation logs, analytics, and settings. - -1. Configure Entra + Discord OAuth credentials in `.env`. -2. Run with Docker: `docker compose up -d dashboard` (builds the dashboard UI). -3. For local development without Docker, build the frontend: - `cd dashboard/frontend && npm install && npm run build` -4. Start the dashboard: `python -m guardden.dashboard` -5. OAuth callbacks: - - Entra: `http://localhost:8080/auth/entra/callback` - - Discord: `http://localhost:8080/auth/discord/callback` - ## CI (Gitea Actions) Workflows live under `.gitea/workflows/` and mirror the previous GitHub Actions @@ -481,7 +460,6 @@ guardden/ │ └── templates/ # Configuration templates ├── tests/ # Test suite ├── migrations/ # Database migrations -├── dashboard/ # Web dashboard (FastAPI + React) ├── docker-compose.yml # Docker deployment ├── pyproject.toml # Dependencies ├── README.md # This file @@ -558,22 +536,64 @@ The AI analyzes content for: 3. Actions are taken based on guild sensitivity settings 4. All AI actions are logged to the mod log channel -### NSFW-Only Filtering Mode +### NSFW-Only Filtering Mode (Enabled by Default) -For communities that only want to filter sexual content while allowing other content types: +**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. -``` -!ai nsfwonly true -``` - -**When enabled:** +**When enabled (DEFAULT):** - ✅ **Blocked:** Sexual content, nude images, explicit material - ❌ **Allowed:** Violence, harassment, hate speech, self-harm content -**When disabled (normal mode):** +**When disabled (strict mode):** - ✅ **Blocked:** All inappropriate content categories -This mode is useful for gaming communities, mature discussion servers, or communities with specific content policies that allow violence but prohibit sexual material. +**To change to strict mode:** +```yaml +# Edit config/guilds/guild-.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-.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 @@ -598,11 +618,21 @@ mypy src # Type checking 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 + ## 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 -- [x] Web dashboard +- [ ] Slash commands with true ephemeral messages +- [ ] Custom notification templates +- [ ] Advanced analytics dashboard diff --git a/config/guilds/example-guild-123456789.yml b/config/guilds/example-guild-123456789.yml deleted file mode 100644 index bdff1b8..0000000 --- a/config/guilds/example-guild-123456789.yml +++ /dev/null @@ -1,149 +0,0 @@ -# Example Guild Configuration -# Copy this file to guild-{YOUR_GUILD_ID}.yml and customize - -# Basic Guild Information -guild_id: 123456789012345678 # Replace with your Discord server ID -name: "Example Gaming Community" # Your server name -owner_id: 987654321098765432 # Guild owner's Discord user ID -premium: false # Set to true if you have premium features - -settings: - # General Settings - general: - prefix: "!" # Command prefix (for read-only commands) - locale: "en" # Language code - - # Discord Channel Configuration (use actual channel IDs or null) - channels: - log_channel_id: null # General event logging - mod_log_channel_id: 888999000111222333 # Moderation action logging - welcome_channel_id: null # New member welcome messages - - # Discord Role Configuration (use actual role IDs or null) - roles: - mute_role_id: 444555666777888999 # Role for timed-out members - verified_role_id: 111222333444555666 # Role given after verification - mod_role_ids: # List of moderator role IDs - - 777888999000111222 - - 333444555666777888 - - # Moderation System Configuration - moderation: - automod_enabled: true # Enable automatic moderation - anti_spam_enabled: true # Enable anti-spam protection - link_filter_enabled: true # Enable suspicious link detection - - # Strike System - Actions at different strike thresholds - strike_actions: - "1": # At 1 strike: warn user - action: warn - "3": # At 3 strikes: 1 hour timeout - action: timeout - duration: 300 - "5": # At 5 strikes: kick from server - action: kick - "7": # At 7 strikes: ban from server - action: ban - - # Automatic Moderation Thresholds - automod: - # Message Rate Limiting - message_rate_limit: 5 # Max messages per time window - message_rate_window: 5 # Time window in seconds - duplicate_threshold: 3 # Duplicate messages to trigger action - - # Mention Spam Protection - mention_limit: 5 # Max mentions per single message - mention_rate_limit: 10 # Max mentions per time window - mention_rate_window: 60 # Mention time window in seconds - - # Scam Protection - Domains that bypass scam detection - scam_allowlist: - - "discord.com" # Official Discord - - "github.com" # Code repositories - - "youtube.com" # Video platform - - "imgur.com" # Image hosting - - "steam.com" # Gaming platform - # Add your trusted domains here - - # AI-Powered Content Moderation - ai_moderation: - enabled: true # Enable AI content analysis - sensitivity: 75 # AI sensitivity (0-100, higher = stricter) - confidence_threshold: 0.7 # Minimum confidence to take action (0.0-1.0) - log_only: false # Only log violations vs take action - nsfw_detection_enabled: true # Enable NSFW image detection - - # NSFW-Only Filtering Mode (NEW FEATURE!) - nsfw_only_filtering: true # true = Only block sexual content - # false = Block all inappropriate content - - # Member Verification System - verification: - enabled: false # Enable verification for new members - type: "captcha" # Verification type: button, captcha, math, emoji - -# Guild-Specific Banned Words (optional) -# These are in addition to patterns in config/wordlists/banned-words.yml -banned_words: - - pattern: "guild-specific-word" - action: delete - is_regex: false - reason: "Server-specific rule" - category: harassment - - - pattern: "sp[a4]m.*bot" - action: timeout - is_regex: true # This is a regex pattern - reason: "Spam bot detection" - category: spam - -# Configuration Notes and Examples: -# -# === NSFW-ONLY FILTERING EXPLAINED === -# This is perfect for gaming communities that discuss violence but want to block sexual content: -# -# nsfw_only_filtering: true -# ✅ BLOCKS: Sexual content, nude images, explicit material -# ❌ ALLOWS: Violence, gore, harassment, hate speech, self-harm discussions -# -# nsfw_only_filtering: false -# ✅ BLOCKS: All inappropriate content (sexual, violence, harassment, hate speech, etc.) -# -# === AI SENSITIVITY GUIDE === -# 0-30 = Very lenient (only extreme violations) -# 31-50 = Lenient (clear violations only) -# 51-70 = Balanced (moderate detection) - RECOMMENDED -# 71-85 = Strict (catches most potential issues) -# 86-100 = Very strict (may have false positives) -# -# === VERIFICATION TYPES === -# button = Simple button click (easiest for users) -# captcha = Text-based captcha entry (more secure) -# math = Solve simple math problem (educational) -# emoji = Select correct emoji from options (fun) -# -# === AUTOMOD ACTIONS === -# warn = Send warning message to user -# delete = Delete the offending message -# timeout = Temporarily mute user (requires duration) -# kick = Remove user from server (can rejoin) -# ban = Permanently ban user from server -# -# === CONFIGURATION TIPS === -# 1. Start with balanced settings and adjust based on your community -# 2. Use nsfw_only_filtering: true for gaming/mature discussion servers -# 3. Set higher sensitivity (80+) for family-friendly servers -# 4. Test settings with !ai analyze "test message" command -# 5. Monitor mod logs to tune your settings -# 6. Back up your config: python -m guardden.cli.config guild backup {guild_id} -# -# === HOT-RELOAD TESTING === -# Edit this file and save - changes apply within seconds! -# Watch the bot logs to see configuration reload messages. -# Use "!config" in Discord to verify your settings loaded correctly. -# -# === GETTING HELP === -# Run: python -m guardden.cli.config --help -# Validate: python -m guardden.cli.config guild validate {guild_id} -# Check status: python -m guardden.cli.config guild list \ No newline at end of file diff --git a/config/schemas/guild-schema.yml b/config/schemas/guild-schema.yml deleted file mode 100644 index a569896..0000000 --- a/config/schemas/guild-schema.yml +++ /dev/null @@ -1,224 +0,0 @@ -# Guild Configuration Schema -# This defines the structure and validation rules for guild configurations - -type: object -required: - - guild_id - - name - - settings - -properties: - guild_id: - type: integer - description: "Discord guild (server) ID" - minimum: 1 - - name: - type: string - description: "Human-readable guild name" - maxLength: 100 - - owner_id: - type: integer - description: "Guild owner's Discord user ID" - minimum: 1 - - premium: - type: boolean - description: "Whether this guild has premium features" - default: false - - settings: - type: object - required: - - general - - channels - - roles - - moderation - - automod - - ai_moderation - - verification - - properties: - general: - type: object - properties: - prefix: - type: string - description: "Command prefix" - minLength: 1 - maxLength: 10 - default: "!" - locale: - type: string - description: "Language locale" - pattern: "^[a-z]{2}$" - default: "en" - - channels: - type: object - description: "Channel configuration (Discord channel IDs)" - properties: - log_channel_id: - type: [integer, "null"] - description: "General event log channel" - minimum: 1 - mod_log_channel_id: - type: [integer, "null"] - description: "Moderation action log channel" - minimum: 1 - welcome_channel_id: - type: [integer, "null"] - description: "Welcome message channel" - minimum: 1 - - roles: - type: object - description: "Role configuration (Discord role IDs)" - properties: - mute_role_id: - type: [integer, "null"] - description: "Role for timed-out members" - minimum: 1 - verified_role_id: - type: [integer, "null"] - description: "Role given after verification" - minimum: 1 - mod_role_ids: - type: array - description: "Moderator roles" - items: - type: integer - minimum: 1 - default: [] - - moderation: - type: object - properties: - automod_enabled: - type: boolean - description: "Enable automatic moderation" - default: true - anti_spam_enabled: - type: boolean - description: "Enable anti-spam protection" - default: true - link_filter_enabled: - type: boolean - description: "Enable link filtering" - default: false - strike_actions: - type: object - description: "Actions to take at strike thresholds" - patternProperties: - "^[0-9]+$": - type: object - required: [action] - properties: - action: - type: string - enum: [warn, timeout, kick, ban] - duration: - type: integer - minimum: 1 - description: "Duration in seconds (for timeout/ban)" - default: - "1": {action: warn} - "3": {action: timeout, duration: 300} - "5": {action: kick} - "7": {action: ban} - - automod: - type: object - description: "Automatic moderation settings" - properties: - message_rate_limit: - type: integer - minimum: 1 - maximum: 50 - description: "Messages per time window" - default: 5 - message_rate_window: - type: integer - minimum: 1 - maximum: 300 - description: "Time window in seconds" - default: 5 - duplicate_threshold: - type: integer - minimum: 1 - maximum: 20 - description: "Duplicate messages to trigger action" - default: 3 - mention_limit: - type: integer - minimum: 1 - maximum: 50 - description: "Maximum mentions per message" - default: 5 - mention_rate_limit: - type: integer - minimum: 1 - maximum: 100 - description: "Mentions per time window" - default: 10 - mention_rate_window: - type: integer - minimum: 1 - maximum: 3600 - description: "Mention time window in seconds" - default: 60 - scam_allowlist: - type: array - description: "Domains allowed to bypass scam detection" - items: - type: string - pattern: "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - default: [] - - ai_moderation: - type: object - description: "AI-powered moderation settings" - properties: - enabled: - type: boolean - description: "Enable AI moderation" - default: true - sensitivity: - type: integer - minimum: 0 - maximum: 100 - description: "AI sensitivity level (higher = stricter)" - default: 80 - confidence_threshold: - type: number - minimum: 0.0 - maximum: 1.0 - description: "Minimum confidence to take action" - default: 0.7 - log_only: - type: boolean - description: "Only log violations, don't take action" - default: false - nsfw_detection_enabled: - type: boolean - description: "Enable NSFW image detection" - default: true - nsfw_only_filtering: - type: boolean - description: "Only filter sexual content, allow violence/harassment" - default: false - - verification: - type: object - description: "Member verification settings" - properties: - enabled: - type: boolean - description: "Enable verification for new members" - default: false - type: - type: string - enum: [button, captcha, math, emoji] - description: "Verification challenge type" - default: button \ No newline at end of file diff --git a/config/schemas/wordlists-schema.yml b/config/schemas/wordlists-schema.yml deleted file mode 100644 index a26a9a5..0000000 --- a/config/schemas/wordlists-schema.yml +++ /dev/null @@ -1,175 +0,0 @@ -# Wordlists Configuration Schema -# Defines structure for banned words and domain whitelists - -banned_words: - type: object - description: "Banned words and patterns configuration" - properties: - global_patterns: - type: array - description: "Patterns applied to all guilds (unless overridden)" - items: - type: object - required: [pattern, action] - properties: - pattern: - type: string - description: "Word or regex pattern to match" - minLength: 1 - maxLength: 200 - action: - type: string - enum: [delete, warn, strike, timeout] - description: "Action to take when pattern matches" - is_regex: - type: boolean - description: "Whether pattern is a regular expression" - default: false - reason: - type: string - description: "Reason for this rule" - maxLength: 500 - category: - type: string - description: "Category of banned content" - enum: [profanity, hate_speech, spam, scam, harassment, sexual, violence] - severity: - type: integer - minimum: 1 - maximum: 10 - description: "Severity level (1-10)" - default: 5 - enabled: - type: boolean - description: "Whether this rule is active" - default: true - - guild_patterns: - type: object - description: "Guild-specific pattern overrides" - patternProperties: - "^[0-9]+$": # Guild ID - type: array - items: - type: object - required: [pattern, action] - properties: - pattern: - type: string - minLength: 1 - maxLength: 200 - action: - type: string - enum: [delete, warn, strike, timeout] - is_regex: - type: boolean - default: false - reason: - type: string - maxLength: 500 - category: - type: string - enum: [profanity, hate_speech, spam, scam, harassment, sexual, violence] - severity: - type: integer - minimum: 1 - maximum: 10 - default: 5 - enabled: - type: boolean - default: true - override_global: - type: boolean - description: "Whether this rule overrides global patterns" - default: false - -external_sources: - type: object - description: "External wordlist sources configuration" - properties: - sources: - type: array - items: - type: object - required: [name, url, category, action] - properties: - name: - type: string - description: "Unique identifier for this source" - pattern: "^[a-zA-Z0-9_-]+$" - url: - type: string - description: "URL to fetch wordlist from" - format: uri - category: - type: string - enum: [profanity, hate_speech, spam, scam, harassment, sexual, violence] - action: - type: string - enum: [delete, warn, strike, timeout] - reason: - type: string - description: "Default reason for words from this source" - enabled: - type: boolean - default: true - update_interval_hours: - type: integer - minimum: 1 - maximum: 8760 # 1 year - description: "How often to update from source" - default: 168 # 1 week - applies_to_guilds: - type: array - description: "Guild IDs to apply this source to (empty = all guilds)" - items: - type: integer - minimum: 1 - default: [] - -domain_allowlists: - type: object - description: "Domain whitelist configuration" - properties: - global_allowlist: - type: array - description: "Domains allowed for all guilds" - items: - type: object - required: [domain] - properties: - domain: - type: string - description: "Domain name to allow" - pattern: "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - reason: - type: string - description: "Why this domain is allowed" - added_by: - type: string - description: "Who added this domain" - added_date: - type: string - format: date-time - description: "When this domain was added" - - guild_allowlists: - type: object - description: "Guild-specific domain allowlists" - patternProperties: - "^[0-9]+$": # Guild ID - type: array - items: - type: object - required: [domain] - properties: - domain: - type: string - pattern: "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" - reason: - type: string - added_by: - type: string - added_date: - type: string - format: date-time \ No newline at end of file diff --git a/config/templates/guild-default.yml b/config/templates/guild-default.yml deleted file mode 100644 index d656964..0000000 --- a/config/templates/guild-default.yml +++ /dev/null @@ -1,102 +0,0 @@ -# Default Guild Configuration Template -# Copy this file to config/guilds/guild-{GUILD_ID}.yml and customize - -# Guild Information -guild_id: 123456789012345678 # Replace with your Discord server ID -name: "My Discord Server" # Replace with your server name -owner_id: 987654321098765432 # Replace with owner's Discord user ID -premium: false # Set to true if you have premium features - -settings: - # General Settings - general: - prefix: "!" # Command prefix (if keeping read-only commands) - locale: "en" # Language code (en, es, fr, de, etc.) - - # Channel Configuration (Discord Channel IDs) - # Set to null to disable, or use actual channel IDs - channels: - log_channel_id: null # General event logging - mod_log_channel_id: null # Moderation action logging - welcome_channel_id: null # New member welcome messages - - # Role Configuration (Discord Role IDs) - roles: - mute_role_id: null # Role for timed-out members - verified_role_id: null # Role given after verification - mod_role_ids: [] # List of moderator role IDs - - # Moderation Settings - moderation: - automod_enabled: true # Enable automatic moderation - anti_spam_enabled: true # Enable anti-spam protection - link_filter_enabled: false # Enable suspicious link filtering - - # Strike System - Actions taken when users reach strike thresholds - strike_actions: - "1": # At 1 strike - action: warn - "3": # At 3 strikes - action: timeout - duration: 300 # 5 minute timeout - "5": # At 5 strikes - action: kick - "7": # At 7 strikes - action: ban - - # Automatic Moderation Thresholds - automod: - # Message Rate Limiting - message_rate_limit: 5 # Max messages per time window - message_rate_window: 5 # Time window in seconds - duplicate_threshold: 3 # Duplicate messages to trigger action - - # Mention Spam Protection - mention_limit: 5 # Max mentions per message - mention_rate_limit: 10 # Max mentions per time window - mention_rate_window: 60 # Mention time window in seconds - - # Scam Protection - Domains allowed to bypass scam detection - scam_allowlist: - - "discord.com" # Example: Allow Discord links - - "github.com" # Example: Allow GitHub links - # Add trusted domains here - - # AI-Powered Moderation - ai_moderation: - enabled: true # Enable AI content analysis - sensitivity: 80 # AI sensitivity (0-100, higher = stricter) - confidence_threshold: 0.7 # Minimum confidence to take action (0.0-1.0) - log_only: false # Only log violations (true) or take action (false) - nsfw_detection_enabled: true # Enable NSFW image detection - nsfw_only_filtering: false # Only filter sexual content (true) vs all content (false) - - # Member Verification System - verification: - enabled: false # Enable verification for new members - type: "button" # Verification type: button, captcha, math, emoji - -# Configuration Notes: -# -# NSFW-Only Filtering: -# false = Block all inappropriate content (sexual, violence, harassment, hate speech) -# true = Only block sexual/nude content, allow violence and harassment -# -# AI Sensitivity Guide: -# 0-30 = Very lenient (only extreme violations) -# 31-50 = Lenient (clear violations) -# 51-70 = Balanced (moderate detection) -# 71-85 = Strict (catches most issues) -# 86-100 = Very strict (may have false positives) -# -# Verification Types: -# button = Simple button click (easiest) -# captcha = Text-based captcha entry -# math = Solve simple math problem -# emoji = Select correct emoji from options -# -# Strike Actions: -# warn = Send warning message -# timeout = Temporarily mute user (requires duration in seconds) -# kick = Remove user from server (can rejoin) -# ban = Permanently ban user from server \ No newline at end of file diff --git a/config/wordlists/banned-words.yml b/config/wordlists/banned-words.yml deleted file mode 100644 index 8f8938b..0000000 --- a/config/wordlists/banned-words.yml +++ /dev/null @@ -1,95 +0,0 @@ -# Banned Words Configuration -# Manage blocked words and patterns for content filtering - -# Global patterns applied to all guilds (unless overridden) -global_patterns: - # Basic profanity filter - - pattern: "badword1" - action: delete - is_regex: false - reason: "Basic profanity filter" - category: profanity - severity: 5 - enabled: true - - - pattern: "badword2" - action: warn - is_regex: false - reason: "Mild profanity" - category: profanity - severity: 3 - enabled: true - - # Regex example for variations - - pattern: "sp[a4]mm*[i1]ng" - action: delete - is_regex: true - reason: "Spam pattern detection" - category: spam - severity: 7 - enabled: true - - # Hate speech prevention - - pattern: "hate.*speech.*example" - action: timeout - is_regex: true - reason: "Hate speech filter" - category: hate_speech - severity: 9 - enabled: true - -# Guild-specific pattern overrides -# Use your Discord server ID as the key -guild_patterns: - 123456789012345678: # Replace with actual guild ID - - pattern: "guild-specific-word" - action: warn - is_regex: false - reason: "Server-specific rule" - category: harassment - severity: 4 - enabled: true - override_global: false - - - pattern: "allowed-here" - action: delete - is_regex: false - reason: "Disable global pattern for this guild" - category: profanity - severity: 1 - enabled: false # Disabled = allows the word in this guild - override_global: true # Overrides global patterns - - # Add more guild IDs as needed - # 987654321098765432: - # - pattern: "another-server-rule" - # action: strike - # [...] - -# Configuration Notes: -# -# Actions Available: -# delete = Delete the message immediately -# warn = Send warning to user and log -# strike = Add strike to user (triggers escalation) -# timeout = Temporarily mute user -# -# Regex Patterns: -# is_regex: true allows advanced pattern matching -# Examples: -# - "hell+o+" matches "hello", "helllo", "helloooo" -# - "[a4]dmin" matches "admin" or "4dmin" -# - "spam.*bot" matches "spam bot", "spambot", "spam detection bot" -# -# Categories: -# profanity, hate_speech, spam, scam, harassment, sexual, violence -# -# Severity (1-10): -# 1-3 = Mild violations (warnings) -# 4-6 = Moderate violations (delete message) -# 7-8 = Serious violations (timeout) -# 9-10 = Severe violations (kick/ban) -# -# Override Global: -# false = Use this rule in addition to global patterns -# true = This rule replaces global patterns for this guild \ No newline at end of file diff --git a/config/wordlists/domain-allowlists.yml b/config/wordlists/domain-allowlists.yml deleted file mode 100644 index 1df4504..0000000 --- a/config/wordlists/domain-allowlists.yml +++ /dev/null @@ -1,99 +0,0 @@ -# Domain Allowlists Configuration -# Configure domains that bypass scam/phishing detection - -# Global allowlist - applies to all guilds -global_allowlist: - - domain: "discord.com" - reason: "Official Discord domain" - added_by: "system" - added_date: "2026-01-24T00:00:00Z" - - - domain: "github.com" - reason: "Popular code repository platform" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "youtube.com" - reason: "Popular video platform" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "youtu.be" - reason: "YouTube short links" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "imgur.com" - reason: "Popular image hosting" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "reddit.com" - reason: "Popular discussion platform" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "wikipedia.org" - reason: "Educational content" - added_by: "admin" - added_date: "2026-01-24T00:00:00Z" - -# Guild-specific allowlists -# Use your Discord server ID as the key -guild_allowlists: - 123456789012345678: # Replace with actual guild ID - - domain: "example-gaming-site.com" - reason: "Popular gaming community site" - added_by: "guild_admin" - added_date: "2026-01-24T00:00:00Z" - - - domain: "guild-specific-forum.com" - reason: "Guild's official forum" - added_by: "guild_owner" - added_date: "2026-01-24T00:00:00Z" - - # Add more guild IDs as needed - # 987654321098765432: - # - domain: "another-server-site.com" - # reason: "Server-specific trusted site" - # added_by: "admin" - # added_date: "2026-01-24T00:00:00Z" - -# Configuration Notes: -# -# Domain Format: -# - Use base domain only (e.g., "example.com" not "https://www.example.com/path") -# - Subdomains are automatically included (allowing "example.com" also allows "www.example.com") -# - Do not include protocols (http/https) or paths -# -# Why Allowlist Domains: -# - Prevent false positives in scam detection -# - Allow trusted community sites and resources -# - Whitelist official platforms and services -# - Support educational and reference materials -# -# Security Considerations: -# - Only add domains you trust completely -# - Regularly review and update the list -# - Remove domains that become compromised -# - Be cautious with URL shorteners -# -# Common Domains to Consider: -# - Social platforms: twitter.com, instagram.com, tiktok.com -# - Gaming: steam.com, epicgames.com, battle.net, minecraft.net -# - Development: gitlab.com, stackoverflow.com, npm.org -# - Media: twitch.tv, spotify.com, soundcloud.com -# - Education: khan.org, coursera.org, edx.org -# - News: bbc.com, reuters.com, apnews.com -# -# Guild-Specific vs Global: -# - Global allowlist applies to all servers -# - Guild-specific allowlists are additional (don't override global) -# - Use guild-specific for community-specific trusted sites -# - Use global for widely trusted platforms -# -# Maintenance: -# - Review allowlist monthly for security -# - Document reasons for all additions -# - Track who added each domain for accountability -# - Monitor for changes in domain ownership or compromise \ No newline at end of file diff --git a/config/wordlists/external-sources.yml b/config/wordlists/external-sources.yml deleted file mode 100644 index 3612ca5..0000000 --- a/config/wordlists/external-sources.yml +++ /dev/null @@ -1,74 +0,0 @@ -# External Wordlist Sources Configuration -# Configure automatic wordlist updates from external sources - -sources: - # Default profanity list (LDNOOBW) - - name: "ldnoobw_en" - url: "https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/en" - category: profanity - action: warn - reason: "External profanity list (English)" - enabled: true - update_interval_hours: 168 # Update weekly - applies_to_guilds: [] # Empty = applies to all guilds - - # Additional language support (uncomment and configure as needed) - # - name: "ldnoobw_es" - # url: "https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/es" - # category: profanity - # action: warn - # reason: "External profanity list (Spanish)" - # enabled: false - # update_interval_hours: 168 - # applies_to_guilds: [] - - # Custom external source example - # - name: "custom_hate_speech" - # url: "https://example.com/hate-speech-list.txt" - # category: hate_speech - # action: delete - # reason: "Custom hate speech prevention" - # enabled: false - # update_interval_hours: 24 # Update daily - # applies_to_guilds: [123456789012345678] # Only for specific guild - - # Scam/phishing domains (if available) - # - name: "phishing_domains" - # url: "https://example.com/phishing-domains.txt" - # category: scam - # action: delete - # reason: "Known phishing domains" - # enabled: false - # update_interval_hours: 4 # Update every 4 hours - # applies_to_guilds: [] - -# Configuration Notes: -# -# Update Intervals: -# 1-6 hours = High-risk content (scams, phishing) -# 12-24 hours = Moderate risk content -# 168 hours = Weekly updates (default for profanity) -# 720 hours = Monthly updates (stable lists) -# -# Applies to Guilds: -# [] = Apply to all guilds -# [123, 456] = Only apply to specific guild IDs -# ["all_premium"] = Apply only to premium guilds (if implemented) -# -# Categories determine how content is classified and what AI moderation -# settings apply to the detected content. -# -# Actions determine the default action taken when words from this source -# are detected. Guild-specific overrides can modify this behavior. -# -# URL Requirements: -# - Must be publicly accessible -# - Should return plain text with one word/pattern per line -# - HTTPS URLs preferred for security -# - Consider rate limiting and source reliability -# -# Security Notes: -# - External sources are validated before applying -# - Malformed or suspicious content is logged but not applied -# - Sources that fail repeatedly are automatically disabled -# - All updates are logged for audit purposes \ No newline at end of file diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile deleted file mode 100644 index cb6bfa3..0000000 --- a/dashboard/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM node:20-alpine AS frontend - -WORKDIR /app/dashboard/frontend - -COPY dashboard/frontend/package.json ./ -RUN npm install - -COPY dashboard/frontend/ ./ -RUN npm run build - -FROM python:3.11-slim - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -COPY pyproject.toml README.md ./ -COPY src/ ./src/ -COPY migrations/ ./migrations/ -COPY alembic.ini ./ -COPY dashboard/ ./dashboard/ - -RUN pip install --no-cache-dir ".[ai]" - -COPY --from=frontend /app/dashboard/frontend/dist /app/dashboard/frontend/dist - -RUN useradd -m -u 1000 guardden && chown -R guardden:guardden /app -USER guardden - -CMD ["uvicorn", "guardden.dashboard.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/dashboard/frontend/index.html b/dashboard/frontend/index.html deleted file mode 100644 index a31cfb5..0000000 --- a/dashboard/frontend/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - GuardDen Dashboard - - -
- - - diff --git a/dashboard/frontend/package.json b/dashboard/frontend/package.json deleted file mode 100644 index 9d20925..0000000 --- a/dashboard/frontend/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "guardden-dashboard", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "lint": "eslint src --ext ts,tsx", - "format": "prettier --write \"src/**/*.{ts,tsx,css}\"" - }, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.22.0", - "@tanstack/react-query": "^5.20.0", - "recharts": "^2.12.0", - "react-hook-form": "^7.50.0", - "zod": "^3.22.4", - "@hookform/resolvers": "^3.3.4", - "clsx": "^2.1.0", - "date-fns": "^3.3.0" - }, - "devDependencies": { - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "typescript": "^5.4.2", - "vite": "^5.1.6", - "tailwindcss": "^3.4.1", - "postcss": "^8.4.35", - "autoprefixer": "^10.4.17", - "@typescript-eslint/eslint-plugin": "^6.20.0", - "@typescript-eslint/parser": "^6.20.0", - "eslint": "^8.56.0", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^3.2.5" - } -} diff --git a/dashboard/frontend/postcss.config.js b/dashboard/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/dashboard/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/dashboard/frontend/src/App.tsx b/dashboard/frontend/src/App.tsx deleted file mode 100644 index 857f121..0000000 --- a/dashboard/frontend/src/App.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Main application with routing - */ - -import { Routes, Route } from "react-router-dom"; -import { Layout } from "./components/Layout"; -import { Dashboard } from "./pages/Dashboard"; -import { Analytics } from "./pages/Analytics"; -import { Servers } from "./pages/Servers"; -import { Users } from "./pages/Users"; -import { Chats } from "./pages/Chats"; -import { Moderation } from "./pages/Moderation"; -import { Settings } from "./pages/Settings"; - -export default function App() { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -} diff --git a/dashboard/frontend/src/components/Layout.tsx b/dashboard/frontend/src/components/Layout.tsx deleted file mode 100644 index 803900b..0000000 --- a/dashboard/frontend/src/components/Layout.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Main dashboard layout with navigation - */ - -import { Link, Outlet, useLocation } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { authApi } from "../services/api"; - -const navigation = [ - { name: "Dashboard", href: "/" }, - { name: "Servers", href: "/servers" }, - { name: "Users", href: "/users" }, - { name: "Chats", href: "/chats" }, - { name: "Moderation", href: "/moderation" }, - { name: "Analytics", href: "/analytics" }, - { name: "Settings", href: "/settings" }, -]; - -export function Layout() { - const location = useLocation(); - const { data: me } = useQuery({ - queryKey: ["me"], - queryFn: authApi.getMe, - }); - - return ( -
- {/* Header */} -
-
-
-
-

GuardDen

- -
- -
- {me?.owner ? ( -
- - {me.entra ? "✓ Entra" : ""} {me.discord ? "✓ Discord" : ""} - - - Logout - -
- ) : ( - - )} -
-
-
-
- - {/* Main content */} -
- {!me?.owner ? ( -
-

- Authentication Required -

-

- Please authenticate with both Entra ID and Discord to access the - dashboard. -

- -
- ) : ( - - )} -
- - {/* Footer */} -
-
- © {new Date().getFullYear()} GuardDen. Discord Moderation Bot. -
-
-
- ); -} diff --git a/dashboard/frontend/src/index.css b/dashboard/frontend/src/index.css deleted file mode 100644 index 3ac674f..0000000 --- a/dashboard/frontend/src/index.css +++ /dev/null @@ -1,51 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - body { - @apply bg-gray-50 text-gray-900; - } -} - -@layer components { - .card { - @apply bg-white rounded-lg shadow-sm border border-gray-200 p-6; - } - - .btn { - @apply px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2; - } - - .btn-primary { - @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; - } - - .btn-secondary { - @apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500; - } - - .btn-danger { - @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; - } - - .input { - @apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500; - } - - .label { - @apply block text-sm font-medium text-gray-700 mb-1; - } - - .stat-card { - @apply card; - } - - .stat-label { - @apply text-sm font-medium text-gray-600; - } - - .stat-value { - @apply text-2xl font-bold text-gray-900 mt-1; - } -} diff --git a/dashboard/frontend/src/main.tsx b/dashboard/frontend/src/main.tsx deleted file mode 100644 index 9b0759e..0000000 --- a/dashboard/frontend/src/main.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import App from "./App"; -import "./index.css"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - retry: 1, - staleTime: 30000, - }, - }, -}); - -const container = document.getElementById("root"); -if (!container) { - throw new Error("Root container missing"); -} - -createRoot(container).render( - - - - - - - , -); diff --git a/dashboard/frontend/src/pages/Analytics.tsx b/dashboard/frontend/src/pages/Analytics.tsx deleted file mode 100644 index c2ac775..0000000 --- a/dashboard/frontend/src/pages/Analytics.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Analytics page with detailed charts and metrics - */ - -import { useQuery } from '@tanstack/react-query'; -import { analyticsApi, guildsApi } from '../services/api'; -import { useState } from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; - -export function Analytics() { - const [selectedGuildId, setSelectedGuildId] = useState(); - const [days, setDays] = useState(30); - - const { data: guilds } = useQuery({ - queryKey: ['guilds'], - queryFn: guildsApi.list, - }); - - const { data: moderationStats, isLoading } = useQuery({ - queryKey: ['analytics', 'moderation-stats', selectedGuildId, days], - queryFn: () => analyticsApi.getModerationStats(selectedGuildId, days), - }); - - return ( -
- {/* Header */} -
-
-

Analytics

-

Detailed moderation statistics and trends

-
-
- - -
-
- - {isLoading ? ( -
Loading...
- ) : moderationStats ? ( - <> - {/* Summary Stats */} -
-
-
Total Actions
-
{moderationStats.total_actions}
-
-
-
Automatic Actions
-
{moderationStats.automatic_vs_manual.automatic || 0}
-
-
-
Manual Actions
-
{moderationStats.automatic_vs_manual.manual || 0}
-
-
- - {/* Actions Timeline */} -
-

Moderation Activity Over Time

- - - - new Date(value).toLocaleDateString()} - /> - - new Date(value as string).toLocaleDateString()} - /> - - - - -
- - {/* Actions by Type */} -
-

Actions by Type

-
- {Object.entries(moderationStats.actions_by_type).map(([action, count]) => ( -
-
{action}
-
{count}
-
- ))} -
-
- - ) : null} -
- ); -} diff --git a/dashboard/frontend/src/pages/Chats.tsx b/dashboard/frontend/src/pages/Chats.tsx deleted file mode 100644 index 85e01a0..0000000 --- a/dashboard/frontend/src/pages/Chats.tsx +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Message logs page - */ - -import { useQuery } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; -import { format } from 'date-fns'; -import { guildsApi, moderationApi } from '../services/api'; - -const ACTION_OPTIONS = [ - { value: '', label: 'All Actions' }, - { value: 'delete', label: 'Delete' }, - { value: 'warn', label: 'Warn' }, - { value: 'strike', label: 'Strike' }, - { value: 'timeout', label: 'Timeout' }, - { value: 'kick', label: 'Kick' }, - { value: 'ban', label: 'Ban' }, -]; - -export function Chats() { - const [selectedGuildId, setSelectedGuildId] = useState(); - const [searchTerm, setSearchTerm] = useState(''); - const [actionFilter, setActionFilter] = useState(''); - const [page, setPage] = useState(0); - const limit = 50; - - const { data: guilds } = useQuery({ - queryKey: ['guilds'], - queryFn: guildsApi.list, - }); - - const guildMap = useMemo(() => { - return new Map((guilds ?? []).map((guild) => [guild.id, guild.name])); - }, [guilds]); - - const { data: logs, isLoading } = useQuery({ - queryKey: ['chat-logs', selectedGuildId, page, searchTerm, actionFilter], - queryFn: () => - moderationApi.getLogs({ - guildId: selectedGuildId, - limit, - offset: page * limit, - messageOnly: true, - search: searchTerm || undefined, - action: actionFilter || undefined, - }), - }); - - const totalPages = logs ? Math.ceil(logs.total / limit) : 0; - const showGuildColumn = !selectedGuildId; - - return ( -
- {/* Header */} -
-
-

Chats

-

Messages captured by moderation actions

-
- -
- - {/* Filters */} -
-
-
- - { - setSearchTerm(e.target.value); - setPage(0); - }} - placeholder="Search message content, user, or reason..." - className="input" - /> -
-
- - -
-
-
- - {/* Table */} -
- {isLoading ? ( -
Loading...
- ) : logs && logs.items.length > 0 ? ( - <> -
- - - - - {showGuildColumn && ( - - )} - - - - - - - - - {logs.items.map((log) => { - const messageLink = - log.channel_id && log.message_id - ? `https://discord.com/channels/${log.guild_id}/${log.channel_id}/${log.message_id}` - : null; - const guildName = guildMap.get(log.guild_id) ?? `Guild ${log.guild_id}`; - - return ( - - - {showGuildColumn && ( - - )} - - - - - - - ); - })} - -
TimeGuildUserActionMessageReasonType
- {format(new Date(log.created_at), 'MMM d, yyyy HH:mm')} - {guildName}{log.target_name} - - {log.action} - - -
- {log.message_content} -
-
- {log.channel_id ? `Channel ${log.channel_id}` : 'Channel unknown'} - {messageLink ? ( - <> - {' '} - ·{' '} - - Open in Discord - - - ) : null} -
-
- {log.reason || '—'} - - - {log.is_automatic ? 'Auto' : 'Manual'} - -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page + 1} of {totalPages} - - -
- )} - - ) : ( -
No chat logs found
- )} -
-
- ); -} diff --git a/dashboard/frontend/src/pages/Dashboard.tsx b/dashboard/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index 3a7fd3f..0000000 --- a/dashboard/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Main dashboard overview page - */ - -import { useQuery } from '@tanstack/react-query'; -import { analyticsApi, guildsApi } from '../services/api'; -import { useState } from 'react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; - -const COLORS = ['#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#84cc16']; - -export function Dashboard() { - const [selectedGuildId, setSelectedGuildId] = useState(); - - const { data: guilds } = useQuery({ - queryKey: ['guilds'], - queryFn: guildsApi.list, - }); - - const { data: analytics, isLoading } = useQuery({ - queryKey: ['analytics', 'summary', selectedGuildId], - queryFn: () => analyticsApi.getSummary(selectedGuildId, 7), - }); - - const actionTypeData = analytics - ? Object.entries(analytics.moderation_stats.actions_by_type).map(([name, value]) => ({ - name, - value, - })) - : []; - - const automaticVsManualData = analytics - ? Object.entries(analytics.moderation_stats.automatic_vs_manual).map(([name, value]) => ({ - name, - value, - })) - : []; - - return ( -
- {/* Header */} -
-
-

Dashboard

-

Overview of your server moderation activity

-
- -
- - {isLoading ? ( -
Loading...
- ) : analytics ? ( - <> - {/* Stats Grid */} -
-
-
Total Actions
-
{analytics.moderation_stats.total_actions}
-
-
-
Active Users
-
{analytics.user_activity.active_users}
-
-
-
Total Messages
-
{analytics.user_activity.total_messages.toLocaleString()}
-
-
-
AI Checks
-
{analytics.ai_performance.total_checks}
-
-
- - {/* User Activity */} -
-
-

New Joins

-
-
- Today - {analytics.user_activity.new_joins_today} -
-
- This Week - {analytics.user_activity.new_joins_week} -
-
-
- -
-

AI Performance

-
-
- Flagged Content - {analytics.ai_performance.flagged_content} -
-
- Avg Confidence - - {(analytics.ai_performance.avg_confidence * 100).toFixed(1)}% - -
-
- Avg Response Time - - {analytics.ai_performance.avg_response_time_ms.toFixed(0)}ms - -
-
-
-
- - {/* Charts */} -
-
-

Actions by Type

- - - - {actionTypeData.map((entry, index) => ( - - ))} - - - - -
- -
-

Automatic vs Manual

- - - - - - - - - -
-
- - {/* Timeline */} -
-

Moderation Activity (Last 7 Days)

- - - - new Date(value).toLocaleDateString()} - /> - - new Date(value as string).toLocaleDateString()} - /> - - - -
- - ) : null} -
- ); -} diff --git a/dashboard/frontend/src/pages/Moderation.tsx b/dashboard/frontend/src/pages/Moderation.tsx deleted file mode 100644 index dbbc79c..0000000 --- a/dashboard/frontend/src/pages/Moderation.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Moderation logs page (enhanced version of original) - */ - -import { useQuery } from "@tanstack/react-query"; -import { moderationApi, guildsApi } from "../services/api"; -import { useState } from "react"; -import { format } from "date-fns"; - -export function Moderation() { - const [selectedGuildId, setSelectedGuildId] = useState(); - const [page, setPage] = useState(0); - const limit = 50; - - const { data: guilds } = useQuery({ - queryKey: ["guilds"], - queryFn: guildsApi.list, - }); - - const { data: logs, isLoading } = useQuery({ - queryKey: ["moderation-logs", selectedGuildId, page], - queryFn: () => - moderationApi.getLogs({ - guildId: selectedGuildId, - limit, - offset: page * limit, - }), - }); - - const totalPages = logs ? Math.ceil(logs.total / limit) : 0; - - return ( -
- {/* Header */} -
-
-

Moderation Logs

-

- View all moderation actions ({logs?.total || 0} total) -

-
- -
- - {/* Table */} -
- {isLoading ? ( -
Loading...
- ) : logs && logs.items.length > 0 ? ( - <> -
- - - - - - - - - - - - - {logs.items.map((log) => ( - - - - - - - - - ))} - -
- Time - - Target - - Action - - Moderator - - Reason - - Type -
- {format(new Date(log.created_at), "MMM d, yyyy HH:mm")} - - {log.target_name} - - - {log.action} - - - {log.moderator_name} - - {log.reason || "—"} - - - {log.is_automatic ? "Auto" : "Manual"} - -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page + 1} of {totalPages} - - -
- )} - - ) : ( -
- No moderation logs found -
- )} -
-
- ); -} diff --git a/dashboard/frontend/src/pages/Servers.tsx b/dashboard/frontend/src/pages/Servers.tsx deleted file mode 100644 index 62732d7..0000000 --- a/dashboard/frontend/src/pages/Servers.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Servers overview page - */ - -import { useQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; -import { guildsApi } from '../services/api'; - -export function Servers() { - const { data: guilds, isLoading } = useQuery({ - queryKey: ['guilds'], - queryFn: guildsApi.list, - }); - - const total = guilds?.length ?? 0; - const premiumCount = guilds?.filter((guild) => guild.premium).length ?? 0; - const standardCount = total - premiumCount; - - return ( -
- {/* Header */} -
-
-

Servers

-

All servers that have added GuardDen

-
-
- - {/* Stats */} -
-
-
Total Servers
-
{total}
-
-
-
Premium Servers
-
{premiumCount}
-
-
-
Standard Servers
-
{standardCount}
-
-
- - {/* Table */} -
- {isLoading ? ( -
Loading...
- ) : guilds && guilds.length > 0 ? ( -
- - - - - - - - - - - - {guilds.map((guild) => ( - - - - - - - - ))} - -
ServerServer IDOwner IDPlanActions
{guild.name}{guild.id}{guild.owner_id} - - {guild.premium ? 'Premium' : 'Standard'} - - - - Configure - -
-
- ) : ( -
No servers found
- )} -
-
- ); -} diff --git a/dashboard/frontend/src/pages/Settings.tsx b/dashboard/frontend/src/pages/Settings.tsx deleted file mode 100644 index 1f28d35..0000000 --- a/dashboard/frontend/src/pages/Settings.tsx +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Guild settings page - */ - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { guildsApi } from "../services/api"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { useSearchParams } from "react-router-dom"; -import type { - AutomodRuleConfig, - GuildSettings as GuildSettingsType, -} from "../types/api"; - -export function Settings() { - const [searchParams] = useSearchParams(); - const [selectedGuildId, setSelectedGuildId] = useState( - () => { - const guildParam = searchParams.get("guild"); - if (!guildParam) { - return undefined; - } - const parsed = Number(guildParam); - return Number.isNaN(parsed) ? undefined : parsed; - }, - ); - const queryClient = useQueryClient(); - - const { data: guilds } = useQuery({ - queryKey: ["guilds"], - queryFn: guildsApi.list, - }); - - useEffect(() => { - const guildParam = searchParams.get("guild"); - if (!guildParam) { - return; - } - const parsed = Number(guildParam); - if (!Number.isNaN(parsed) && parsed !== selectedGuildId) { - setSelectedGuildId(parsed); - } - }, [searchParams, selectedGuildId]); - - const { data: settings } = useQuery({ - queryKey: ["guild-settings", selectedGuildId], - queryFn: () => guildsApi.getSettings(selectedGuildId!), - enabled: !!selectedGuildId, - }); - - const { data: automodConfig } = useQuery({ - queryKey: ["automod-config", selectedGuildId], - queryFn: () => guildsApi.getAutomodConfig(selectedGuildId!), - enabled: !!selectedGuildId, - }); - - const updateSettingsMutation = useMutation({ - mutationFn: (data: GuildSettingsType) => - guildsApi.updateSettings(selectedGuildId!, data), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["guild-settings", selectedGuildId], - }); - }, - }); - - const updateAutomodMutation = useMutation({ - mutationFn: (data: AutomodRuleConfig) => - guildsApi.updateAutomodConfig(selectedGuildId!, data), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["automod-config", selectedGuildId], - }); - }, - }); - - const { - register: registerSettings, - handleSubmit: handleSubmitSettings, - formState: { isDirty: isSettingsDirty }, - } = useForm({ - values: settings, - }); - - const { - register: registerAutomod, - handleSubmit: handleSubmitAutomod, - formState: { isDirty: isAutomodDirty }, - } = useForm({ - values: automodConfig, - }); - - const onSubmitSettings = (data: GuildSettingsType) => { - updateSettingsMutation.mutate(data); - }; - - const onSubmitAutomod = (data: AutomodRuleConfig) => { - updateAutomodMutation.mutate(data); - }; - - const handleExport = async () => { - if (!selectedGuildId) return; - const blob = await guildsApi.exportConfig(selectedGuildId); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `guild_${selectedGuildId}_config.json`; - a.click(); - URL.revokeObjectURL(url); - }; - - return ( -
- {/* Header */} -
-
-

Settings

-

- Configure your guild settings and automod rules -

-
- -
- - {!selectedGuildId ? ( -
-

- Please select a guild to configure settings -

-
- ) : ( - <> - {/* General Settings */} -
-
-

General Settings

- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - - -
- -
- -
-
-
- - {/* Automod Configuration */} -
-

Automod Rules

-
-
- - - - -
- -
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
- - )} -
- ); -} diff --git a/dashboard/frontend/src/pages/Users.tsx b/dashboard/frontend/src/pages/Users.tsx deleted file mode 100644 index 911ee9f..0000000 --- a/dashboard/frontend/src/pages/Users.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * User management page - */ - -import { useQuery } from "@tanstack/react-query"; -import { usersApi, guildsApi } from "../services/api"; -import { useState } from "react"; -import { format } from "date-fns"; - -export function Users() { - const [selectedGuildId, setSelectedGuildId] = useState(); - const [searchTerm, setSearchTerm] = useState(""); - const [minStrikes, setMinStrikes] = useState(""); - - const { data: guilds } = useQuery({ - queryKey: ["guilds"], - queryFn: guildsApi.list, - }); - - const { data: users, isLoading } = useQuery({ - queryKey: ["users", selectedGuildId, searchTerm, minStrikes], - queryFn: () => - usersApi.search( - selectedGuildId, - searchTerm || undefined, - minStrikes ? Number(minStrikes) : undefined, - ), - }); - - const showGuildColumn = !selectedGuildId; - - return ( -
- {/* Header */} -
-
-

User Management

-

- Search and manage users across your servers -

-
- -
- - {/* Search */} -
-
-
- - setSearchTerm(e.target.value)} - placeholder="Enter username..." - className="input" - /> -
-
- - setMinStrikes(e.target.value)} - placeholder="0" - className="input" - /> -
-
-
- - {/* Results */} -
- {isLoading ? ( -
Loading...
- ) : users && users.length > 0 ? ( -
- - - - {showGuildColumn && ( - - )} - - - - - - - - - - - {users.map((user) => ( - - {showGuildColumn && ( - - )} - - - - - - - - - ))} - -
- Guild - - Username - - Strikes - - Warnings - - Kicks - - Bans - - Timeouts - - First Seen -
- {user.guild_name} - {user.username} - 5 - ? "bg-red-100 text-red-800" - : user.strike_count > 2 - ? "bg-yellow-100 text-yellow-800" - : "bg-gray-100 text-gray-800" - }`} - > - {user.strike_count} - - - {user.total_warnings} - - {user.total_kicks} - {user.total_bans} - {user.total_timeouts} - - {format(new Date(user.first_seen), "MMM d, yyyy")} -
-
- ) : ( -
- {searchTerm || minStrikes - ? "No users found matching your filters" - : "No user activity found"} -
- )} -
-
- ); -} diff --git a/dashboard/frontend/src/services/api.ts b/dashboard/frontend/src/services/api.ts deleted file mode 100644 index 089c420..0000000 --- a/dashboard/frontend/src/services/api.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * API client for GuardDen Dashboard - */ - -import type { - AnalyticsSummary, - AutomodRuleConfig, - CreateUserNote, - Guild, - GuildSettings, - Me, - ModerationStats, - PaginatedLogs, - UserNote, - UserProfile, -} from "../types/api"; - -const BASE_URL = ""; - -async function fetchJson(url: string, options?: RequestInit): Promise { - const response = await fetch(BASE_URL + url, { - ...options, - credentials: "include", - headers: { - "Content-Type": "application/json", - ...options?.headers, - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Request failed: ${response.status} - ${error}`); - } - - return response.json() as Promise; -} - -// Auth API -export const authApi = { - getMe: () => fetchJson("/api/me"), -}; - -// Guilds API -export const guildsApi = { - list: () => fetchJson("/api/guilds"), - getSettings: (guildId: number) => - fetchJson(`/api/guilds/${guildId}/settings`), - updateSettings: (guildId: number, settings: GuildSettings) => - fetchJson(`/api/guilds/${guildId}/settings`, { - method: "PUT", - body: JSON.stringify(settings), - }), - getAutomodConfig: (guildId: number) => - fetchJson(`/api/guilds/${guildId}/automod`), - updateAutomodConfig: (guildId: number, config: AutomodRuleConfig) => - fetchJson(`/api/guilds/${guildId}/automod`, { - method: "PUT", - body: JSON.stringify(config), - }), - exportConfig: (guildId: number) => - fetch(`${BASE_URL}/api/guilds/${guildId}/export`, { - credentials: "include", - }).then((res) => res.blob()), -}; - -// Moderation API -type ModerationLogQuery = { - guildId?: number; - limit?: number; - offset?: number; - action?: string; - messageOnly?: boolean; - search?: string; -}; - -export const moderationApi = { - getLogs: ({ - guildId, - limit = 50, - offset = 0, - action, - messageOnly, - search, - }: ModerationLogQuery = {}) => { - const params = new URLSearchParams({ - limit: String(limit), - offset: String(offset), - }); - if (guildId) { - params.set("guild_id", String(guildId)); - } - if (action) { - params.set("action", action); - } - if (messageOnly) { - params.set("message_only", "true"); - } - if (search) { - params.set("search", search); - } - return fetchJson(`/api/moderation/logs?${params}`); - }, -}; - -// Analytics API -export const analyticsApi = { - getSummary: (guildId?: number, days = 7) => { - const params = new URLSearchParams({ days: String(days) }); - if (guildId) { - params.set("guild_id", String(guildId)); - } - return fetchJson(`/api/analytics/summary?${params}`); - }, - getModerationStats: (guildId?: number, days = 30) => { - const params = new URLSearchParams({ days: String(days) }); - if (guildId) { - params.set("guild_id", String(guildId)); - } - return fetchJson( - `/api/analytics/moderation-stats?${params}`, - ); - }, -}; - -// Users API -export const usersApi = { - search: ( - guildId?: number, - username?: string, - minStrikes?: number, - limit = 50, - ) => { - const params = new URLSearchParams({ limit: String(limit) }); - if (guildId) { - params.set("guild_id", String(guildId)); - } - if (username) { - params.set("username", username); - } - if (minStrikes !== undefined) { - params.set("min_strikes", String(minStrikes)); - } - return fetchJson(`/api/users/search?${params}`); - }, - getProfile: (userId: number, guildId: number) => - fetchJson(`/api/users/${userId}/profile?guild_id=${guildId}`), - getNotes: (userId: number, guildId: number) => - fetchJson(`/api/users/${userId}/notes?guild_id=${guildId}`), - createNote: (userId: number, guildId: number, note: CreateUserNote) => - fetchJson(`/api/users/${userId}/notes?guild_id=${guildId}`, { - method: "POST", - body: JSON.stringify(note), - }), - deleteNote: (userId: number, noteId: number, guildId: number) => - fetchJson( - `/api/users/${userId}/notes/${noteId}?guild_id=${guildId}`, - { - method: "DELETE", - }, - ), -}; diff --git a/dashboard/frontend/src/services/websocket.ts b/dashboard/frontend/src/services/websocket.ts deleted file mode 100644 index fcfeefa..0000000 --- a/dashboard/frontend/src/services/websocket.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * WebSocket service for real-time updates - */ - -import type { WebSocketEvent } from '../types/api'; - -type EventHandler = (event: WebSocketEvent) => void; - -export class WebSocketService { - private ws: WebSocket | null = null; - private handlers: Map> = new Map(); - private reconnectTimeout: number | null = null; - private reconnectAttempts = 0; - private maxReconnectAttempts = 5; - private guildId: number | null = null; - - connect(guildId: number): void { - this.guildId = guildId; - this.reconnectAttempts = 0; - this.doConnect(); - } - - private doConnect(): void { - if (this.guildId === null) return; - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/events?guild_id=${this.guildId}`; - - this.ws = new WebSocket(wsUrl); - - this.ws.onopen = () => { - console.log('WebSocket connected'); - this.reconnectAttempts = 0; - }; - - this.ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WebSocketEvent; - this.emit(data.type, data); - this.emit('*', data); // Emit to wildcard handlers - } catch (error) { - console.error('Failed to parse WebSocket message:', error); - } - }; - - this.ws.onerror = (error) => { - console.error('WebSocket error:', error); - }; - - this.ws.onclose = () => { - console.log('WebSocket closed'); - this.scheduleReconnect(); - }; - } - - private scheduleReconnect(): void { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('Max reconnect attempts reached'); - return; - } - - const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000); - this.reconnectTimeout = window.setTimeout(() => { - this.reconnectAttempts++; - console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`); - this.doConnect(); - }, delay); - } - - disconnect(): void { - if (this.reconnectTimeout !== null) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - - if (this.ws) { - this.ws.close(); - this.ws = null; - } - - this.guildId = null; - } - - on(eventType: string, handler: EventHandler): void { - if (!this.handlers.has(eventType)) { - this.handlers.set(eventType, new Set()); - } - this.handlers.get(eventType)!.add(handler); - } - - off(eventType: string, handler: EventHandler): void { - const handlers = this.handlers.get(eventType); - if (handlers) { - handlers.delete(handler); - if (handlers.size === 0) { - this.handlers.delete(eventType); - } - } - } - - private emit(eventType: string, event: WebSocketEvent): void { - const handlers = this.handlers.get(eventType); - if (handlers) { - handlers.forEach((handler) => handler(event)); - } - } - - send(data: unknown): void { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(data)); - } - } - - ping(): void { - this.send('ping'); - } -} - -// Singleton instance -export const wsService = new WebSocketService(); diff --git a/dashboard/frontend/src/types/api.ts b/dashboard/frontend/src/types/api.ts deleted file mode 100644 index 71262cd..0000000 --- a/dashboard/frontend/src/types/api.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * API types for GuardDen Dashboard - */ - -// Auth types -export interface Me { - entra: boolean; - discord: boolean; - owner: boolean; - entra_oid?: string | null; - discord_id?: string | null; -} - -// Guild types -export interface Guild { - id: number; - name: string; - owner_id: number; - premium: boolean; -} - -// Moderation types -export interface ModerationLog { - id: number; - guild_id: number; - target_id: number; - target_name: string; - moderator_id: number; - moderator_name: string; - action: string; - reason: string | null; - duration: number | null; - expires_at: string | null; - channel_id: number | null; - message_id: number | null; - message_content: string | null; - is_automatic: boolean; - created_at: string; -} - -export interface PaginatedLogs { - total: number; - items: ModerationLog[]; -} - -// Analytics types -export interface TimeSeriesDataPoint { - timestamp: string; - value: number; -} - -export interface ModerationStats { - total_actions: number; - actions_by_type: Record; - actions_over_time: TimeSeriesDataPoint[]; - automatic_vs_manual: Record; -} - -export interface UserActivityStats { - active_users: number; - total_messages: number; - new_joins_today: number; - new_joins_week: number; -} - -export interface AIPerformanceStats { - total_checks: number; - flagged_content: number; - avg_confidence: number; - false_positives: number; - avg_response_time_ms: number; -} - -export interface AnalyticsSummary { - moderation_stats: ModerationStats; - user_activity: UserActivityStats; - ai_performance: AIPerformanceStats; -} - -// User management types -export interface UserProfile { - guild_id: number; - guild_name: string; - user_id: number; - username: string; - strike_count: number; - total_warnings: number; - total_kicks: number; - total_bans: number; - total_timeouts: number; - first_seen: string; - last_action: string | null; -} - -export interface UserNote { - id: number; - user_id: number; - guild_id: number; - moderator_id: number; - moderator_name: string; - content: string; - created_at: string; -} - -export interface CreateUserNote { - content: string; -} - -// Configuration types -export interface GuildSettings { - guild_id: number; - prefix: string | null; - log_channel_id: number | null; - automod_enabled: boolean; - ai_moderation_enabled: boolean; - ai_sensitivity: number; - verification_enabled: boolean; - verification_role_id: number | null; - max_warns_before_action: number; -} - -export interface AutomodRuleConfig { - guild_id: number; - banned_words_enabled: boolean; - scam_detection_enabled: boolean; - spam_detection_enabled: boolean; - invite_filter_enabled: boolean; - max_mentions: number; - max_emojis: number; - spam_threshold: number; -} - -// WebSocket event types -export interface WebSocketEvent { - type: string; - guild_id: number; - timestamp: string; - data: Record; -} diff --git a/dashboard/frontend/tailwind.config.js b/dashboard/frontend/tailwind.config.js deleted file mode 100644 index 744256b..0000000 --- a/dashboard/frontend/tailwind.config.js +++ /dev/null @@ -1,26 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: { - colors: { - primary: { - 50: '#f0f9ff', - 100: '#e0f2fe', - 200: '#bae6fd', - 300: '#7dd3fc', - 400: '#38bdf8', - 500: '#0ea5e9', - 600: '#0284c7', - 700: '#0369a1', - 800: '#075985', - 900: '#0c4a6e', - }, - }, - }, - }, - plugins: [], -} diff --git a/dashboard/frontend/tsconfig.json b/dashboard/frontend/tsconfig.json deleted file mode 100644 index ffd04f5..0000000 --- a/dashboard/frontend/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src"] -} diff --git a/dashboard/frontend/vite.config.ts b/dashboard/frontend/vite.config.ts deleted file mode 100644 index 290e242..0000000 --- a/dashboard/frontend/vite.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - proxy: { - "/api": "http://localhost:8000", - "/auth": "http://localhost:8000", - }, - }, - build: { - outDir: "dist", - emptyOutDir: true, - }, -}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 53af99b..d7c26e4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" # Development overrides for docker-compose.yml # Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up @@ -28,32 +28,10 @@ services: - ./logs:/app/logs command: ["python", "-m", "guardden", "--reload"] ports: - - "5678:5678" # Debugger port + - "5678:5678" # Debugger port stdin_open: true tty: true - dashboard: - build: - context: . - dockerfile: Dockerfile - target: development - image: guardden-dashboard:dev - container_name: guardden-dashboard-dev - environment: - - GUARDDEN_LOG_LEVEL=DEBUG - - PYTHONDONTWRITEBYTECODE=1 - - PYTHONUNBUFFERED=1 - volumes: - # Mount source code for hot reloading - - ./src:/app/src:ro - - ./migrations:/app/migrations:ro - # Serve locally built dashboard assets (optional) - - ./dashboard/frontend/dist:/app/dashboard/frontend/dist:ro - command: ["python", "-m", "guardden.dashboard", "--reload", "--host", "0.0.0.0"] - ports: - - "8080:8000" - - "5679:5678" # Debugger port - db: environment: - POSTGRES_PASSWORD=guardden_dev @@ -78,8 +56,8 @@ services: container_name: guardden-mailhog restart: unless-stopped ports: - - "1025:1025" # SMTP - - "8025:8025" # Web UI + - "1025:1025" # SMTP + - "8025:8025" # Web UI networks: - guardden diff --git a/docker-compose.yml b/docker-compose.yml index 70484a6..873d963 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,36 +31,6 @@ services: retries: 3 start_period: 60s - dashboard: - build: - context: . - dockerfile: dashboard/Dockerfile - image: guardden-dashboard:latest - container_name: guardden-dashboard - restart: unless-stopped - depends_on: - db: - condition: service_healthy - ports: - - "${DASHBOARD_PORT:-8080}:8000" - environment: - - GUARDDEN_DATABASE_URL=postgresql://guardden:guardden@db:5432/guardden - - GUARDDEN_DASHBOARD_BASE_URL=${GUARDDEN_DASHBOARD_BASE_URL:-http://localhost:8080} - - GUARDDEN_DASHBOARD_SECRET_KEY=${GUARDDEN_DASHBOARD_SECRET_KEY} - - GUARDDEN_DASHBOARD_ENTRA_TENANT_ID=${GUARDDEN_DASHBOARD_ENTRA_TENANT_ID} - - GUARDDEN_DASHBOARD_ENTRA_CLIENT_ID=${GUARDDEN_DASHBOARD_ENTRA_CLIENT_ID} - - GUARDDEN_DASHBOARD_ENTRA_CLIENT_SECRET=${GUARDDEN_DASHBOARD_ENTRA_CLIENT_SECRET} - - GUARDDEN_DASHBOARD_DISCORD_CLIENT_ID=${GUARDDEN_DASHBOARD_DISCORD_CLIENT_ID} - - GUARDDEN_DASHBOARD_DISCORD_CLIENT_SECRET=${GUARDDEN_DASHBOARD_DISCORD_CLIENT_SECRET} - - GUARDDEN_DASHBOARD_OWNER_DISCORD_ID=${GUARDDEN_DASHBOARD_OWNER_DISCORD_ID} - - GUARDDEN_DASHBOARD_OWNER_ENTRA_OBJECT_ID=${GUARDDEN_DASHBOARD_OWNER_ENTRA_OBJECT_ID} - - GUARDDEN_DASHBOARD_CORS_ORIGINS=${GUARDDEN_DASHBOARD_CORS_ORIGINS:-} - volumes: - - guardden_logs:/app/logs:ro - networks: - - guardden - command: ["python", "-m", "guardden.dashboard"] - db: image: postgres:15-alpine container_name: guardden-db @@ -102,27 +72,6 @@ services: networks: - guardden - # Optional: Monitoring stack - prometheus: - image: prom/prometheus:latest - container_name: guardden-prometheus - restart: unless-stopped - profiles: - - monitoring - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.path=/prometheus" - - "--web.console.libraries=/etc/prometheus/console_libraries" - - "--web.console.templates=/etc/prometheus/consoles" - - "--web.enable-lifecycle" - ports: - - "${PROMETHEUS_PORT:-9090}:9090" - volumes: - - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus_data:/prometheus - networks: - - guardden - networks: guardden: driver: bridge @@ -133,4 +82,3 @@ volumes: redis_data: guardden_data: guardden_logs: - prometheus_data: diff --git a/migrations/versions/20260124_add_nsfw_only_filtering.py b/migrations/versions/20260124_add_nsfw_only_filtering.py index 3df5d6c..f5529f7 100644 --- a/migrations/versions/20260124_add_nsfw_only_filtering.py +++ b/migrations/versions/20260124_add_nsfw_only_filtering.py @@ -18,16 +18,15 @@ depends_on = None def upgrade() -> None: """Add nsfw_only_filtering column to guild_settings table.""" op.add_column( - "guild_settings", - sa.Column("nsfw_only_filtering", sa.Boolean, nullable=False, default=False) + "guild_settings", sa.Column("nsfw_only_filtering", sa.Boolean, nullable=False, default=True) ) - + # Set default value for existing records op.execute( sa.text( """ UPDATE guild_settings - SET nsfw_only_filtering = FALSE + SET nsfw_only_filtering = TRUE WHERE nsfw_only_filtering IS NULL """ ) @@ -36,4 +35,4 @@ def upgrade() -> None: def downgrade() -> None: """Remove nsfw_only_filtering column from guild_settings table.""" - op.drop_column("guild_settings", "nsfw_only_filtering") \ No newline at end of file + op.drop_column("guild_settings", "nsfw_only_filtering") diff --git a/migrations/versions/20260125_add_in_channel_warnings.py b/migrations/versions/20260125_add_in_channel_warnings.py new file mode 100644 index 0000000..4af4773 --- /dev/null +++ b/migrations/versions/20260125_add_in_channel_warnings.py @@ -0,0 +1,39 @@ +"""Add send_in_channel_warnings column to guild_settings table. + +Revision ID: 20260125_add_in_channel_warnings +Revises: 20260124_add_nsfw_only_filtering +Create Date: 2026-01-25 00:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "20260125_add_in_channel_warnings" +down_revision = "20260124_add_nsfw_only_filtering" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add send_in_channel_warnings column to guild_settings table.""" + op.add_column( + "guild_settings", + sa.Column("send_in_channel_warnings", sa.Boolean, nullable=False, default=False), + ) + + # Set default value for existing records + op.execute( + sa.text( + """ + UPDATE guild_settings + SET send_in_channel_warnings = FALSE + WHERE send_in_channel_warnings IS NULL + """ + ) + ) + + +def downgrade() -> None: + """Remove send_in_channel_warnings column from guild_settings table.""" + op.drop_column("guild_settings", "send_in_channel_warnings") diff --git a/monitoring/README.md b/monitoring/README.md deleted file mode 100644 index 2c685bb..0000000 --- a/monitoring/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Monitoring integration - -GuardDen exposes Prometheus metrics from the bot and dashboard services. You can -keep using your existing Grafana instance by pointing it at your Prometheus -server (yours or the optional one in this repo). - -## Option A: Use the bundled Prometheus, external Grafana - -Start only Prometheus: - -```bash -docker compose --profile monitoring up -d prometheus -``` - -Add a Prometheus datasource in your Grafana: -- URL from the same Docker network: `http://guardden-prometheus:9090` -- URL from the host: `http://localhost:9090` (or your mapped port) - -## Option B: Use your own Prometheus + Grafana - -Merge the scrape jobs from `monitoring/prometheus.yml` into your Prometheus -config. The main endpoints are: -- bot: `http://:8001/metrics` -- dashboard: `http://:8000/metrics` -- postgres-exporter: `http://:9187/metrics` -- redis-exporter: `http://:9121/metrics` - -Then add (or reuse) a Prometheus datasource in Grafana that points to your -Prometheus URL. - -## Optional internal Grafana - -If you want the repo's Grafana container, enable its profile: - -```bash -docker compose --profile monitoring --profile monitoring-grafana up -d -``` diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml deleted file mode 100644 index 80bea3b..0000000 --- a/monitoring/grafana/provisioning/dashboards/dashboard.yml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: 1 - -providers: - - name: 'default' - orgId: 1 - folder: '' - type: file - disableDeletion: false - updateIntervalSeconds: 10 - allowUiUpdates: true - options: - path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml deleted file mode 100644 index 8d10695..0000000 --- a/monitoring/grafana/provisioning/datasources/prometheus.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: 1 - -datasources: - - name: Prometheus - type: prometheus - access: proxy - url: http://prometheus:9090 - isDefault: true - basicAuth: false - jsonData: - timeInterval: 15s \ No newline at end of file diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml deleted file mode 100644 index 2e64e97..0000000 --- a/monitoring/prometheus.yml +++ /dev/null @@ -1,34 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -rule_files: - # - "first_rules.yml" - # - "second_rules.yml" - -scrape_configs: - - job_name: 'prometheus' - static_configs: - - targets: ['localhost:9090'] - - - job_name: 'guardden-bot' - static_configs: - - targets: ['bot:8001'] - scrape_interval: 10s - metrics_path: '/metrics' - - - job_name: 'guardden-dashboard' - static_configs: - - targets: ['dashboard:8000'] - scrape_interval: 10s - metrics_path: '/metrics' - - - job_name: 'postgres-exporter' - static_configs: - - targets: ['postgres-exporter:9187'] - scrape_interval: 30s - - - job_name: 'redis-exporter' - static_configs: - - targets: ['redis-exporter:9121'] - scrape_interval: 30s \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fe2dfc8..7e09314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,11 +28,7 @@ dependencies = [ "python-dotenv>=1.0.0", "alembic>=1.13.0", "sqlalchemy>=2.0.0", - "fastapi>=0.110.0", - "uvicorn>=0.27.0", - "authlib>=1.3.0", "httpx>=0.27.0", - "itsdangerous>=2.1.2", "pyyaml>=6.0", "jsonschema>=4.20.0", "watchfiles>=0.21.0", @@ -59,15 +55,6 @@ voice = [ "speechrecognition>=3.10.0", "pydub>=0.25.0", ] -monitoring = [ - "structlog>=23.2.0", - "prometheus-client>=0.19.0", - "opentelemetry-api>=1.21.0", - "opentelemetry-sdk>=1.21.0", - "opentelemetry-instrumentation>=0.42b0", - "psutil>=5.9.0", - "aiohttp>=3.9.0", -] [project.scripts] guardden = "guardden.__main__:main" diff --git a/src/guardden/cogs/admin.py b/src/guardden/cogs/admin.py index 786d4c2..5291b23 100644 --- a/src/guardden/cogs/admin.py +++ b/src/guardden/cogs/admin.py @@ -106,6 +106,13 @@ class Admin(commands.Cog): 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") @@ -263,6 +270,47 @@ class Admin(commands.Cog): 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.command(name="sync") @commands.is_owner() async def sync_commands(self, ctx: commands.Context) -> None: diff --git a/src/guardden/cogs/ai_moderation.py b/src/guardden/cogs/ai_moderation.py index 10c5763..c0e2a84 100644 --- a/src/guardden/cogs/ai_moderation.py +++ b/src/guardden/cogs/ai_moderation.py @@ -11,6 +11,7 @@ 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__) @@ -166,22 +167,27 @@ class AIModeration(commands.Cog): return # Notify user - try: - embed = discord.Embed( - title=f"Message Flagged in {message.guild.name}", - description=result.explanation, - color=discord.Color.red(), - timestamp=datetime.now(timezone.utc), + 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, ) - 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") - await message.author.send(embed=embed) - except discord.Forbidden: - pass async def _log_ai_action( self, @@ -328,7 +334,7 @@ class AIModeration(commands.Cog): # 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: @@ -346,7 +352,6 @@ class AIModeration(commands.Cog): 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 @@ -396,7 +401,7 @@ class AIModeration(commands.Cog): # 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: @@ -414,7 +419,6 @@ class AIModeration(commands.Cog): 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 @@ -578,18 +582,18 @@ class AIModeration(commands.Cog): @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**.", + "Violence, harassment, hate speech, and other content types will be **allowed**.", color=discord.Color.orange(), ) embed.add_field( @@ -607,10 +611,10 @@ class AIModeration(commands.Cog): embed = discord.Embed( title="NSFW-Only Mode Disabled", description="✅ Normal content filtering restored.\n" - "All inappropriate content types will now be filtered.", + "All inappropriate content types will now be filtered.", color=discord.Color.green(), ) - + await ctx.send(embed=embed) @ai_cmd.command(name="analyze") diff --git a/src/guardden/cogs/automod.py b/src/guardden/cogs/automod.py index c83ef39..e9b1b35 100644 --- a/src/guardden/cogs/automod.py +++ b/src/guardden/cogs/automod.py @@ -16,6 +16,7 @@ from guardden.services.automod import ( SpamConfig, normalize_domain, ) +from guardden.utils.notifications import send_moderation_notification from guardden.utils.ratelimit import RateLimitExceeded logger = logging.getLogger(__name__) @@ -187,27 +188,35 @@ class Automod(commands.Cog): 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): + 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 via DM - try: - embed = discord.Embed( - title=f"Message Removed in {message.guild.name}", - description=result.reason, - color=discord.Color.orange(), - timestamp=datetime.now(timezone.utc), + # 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, ) - if result.should_timeout: - embed.add_field( - name="Timeout", - value=f"You have been timed out for {result.timeout_duration} seconds.", - ) - await message.author.send(embed=embed) - except discord.Forbidden: - pass # User has DMs disabled async def _log_automod_action( self, @@ -472,7 +481,9 @@ class Automod(commands.Cog): results.append(f"**Banned Words**: {result.reason}") # Check scam links - result = self.automod.check_scam_links(text, allowlist=config.scam_allowlist if config else []) + result = self.automod.check_scam_links( + text, allowlist=config.scam_allowlist if config else [] + ) if result: results.append(f"**Scam Detection**: {result.reason}") diff --git a/src/guardden/cogs/moderation.py b/src/guardden/cogs/moderation.py index a2385ca..634e9ab 100644 --- a/src/guardden/cogs/moderation.py +++ b/src/guardden/cogs/moderation.py @@ -10,6 +10,7 @@ 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__) @@ -140,17 +141,23 @@ class Moderation(commands.Cog): await ctx.send(embed=embed) - # Try to DM the user - try: - dm_embed = discord.Embed( - title=f"Warning in {ctx.guild.name}", - description=f"You have been warned.", - color=discord.Color.yellow(), + # 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, ) - dm_embed.add_field(name="Reason", value=reason) - await member.send(embed=dm_embed) - except discord.Forbidden: - pass @commands.command(name="strike") @commands.has_permissions(kick_members=True) @@ -328,17 +335,23 @@ class Moderation(commands.Cog): await ctx.send("You cannot kick someone with a higher or equal role.") return - # Try to DM the user before kicking - try: - dm_embed = discord.Embed( - title=f"Kicked from {ctx.guild.name}", - description=f"You have been kicked from the server.", - color=discord.Color.red(), + # 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, ) - dm_embed.add_field(name="Reason", value=reason) - await member.send(embed=dm_embed) - except discord.Forbidden: - pass try: await member.kick(reason=f"{ctx.author}: {reason}") @@ -348,7 +361,7 @@ class Moderation(commands.Cog): 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( @@ -381,17 +394,23 @@ class Moderation(commands.Cog): await ctx.send("You cannot ban someone with a higher or equal role.") return - # Try to DM the user before banning - try: - 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(), + # 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, ) - dm_embed.add_field(name="Reason", value=reason) - await member.send(embed=dm_embed) - except discord.Forbidden: - pass try: await ctx.guild.ban(member, reason=f"{ctx.author}: {reason}", delete_message_days=0) @@ -401,7 +420,7 @@ class Moderation(commands.Cog): 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( diff --git a/src/guardden/dashboard/__init__.py b/src/guardden/dashboard/__init__.py deleted file mode 100644 index 954d0d9..0000000 --- a/src/guardden/dashboard/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Dashboard application package.""" diff --git a/src/guardden/dashboard/__main__.py b/src/guardden/dashboard/__main__.py deleted file mode 100644 index c2a7bd3..0000000 --- a/src/guardden/dashboard/__main__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Dashboard entrypoint for `python -m guardden.dashboard`.""" - -import os - -import uvicorn - - -def main() -> None: - host = os.getenv("GUARDDEN_DASHBOARD_HOST", "0.0.0.0") - port = int(os.getenv("GUARDDEN_DASHBOARD_PORT", "8000")) - log_level = os.getenv("GUARDDEN_LOG_LEVEL", "info").lower() - uvicorn.run("guardden.dashboard.main:app", host=host, port=port, log_level=log_level) - - -if __name__ == "__main__": - main() diff --git a/src/guardden/dashboard/analytics.py b/src/guardden/dashboard/analytics.py deleted file mode 100644 index e38b841..0000000 --- a/src/guardden/dashboard/analytics.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Analytics API routes for the GuardDen dashboard.""" - -from collections.abc import AsyncIterator -from datetime import datetime, timedelta - -from fastapi import APIRouter, Depends, Query, Request -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from guardden.dashboard.auth import require_owner -from guardden.dashboard.config import DashboardSettings -from guardden.dashboard.db import DashboardDatabase -from guardden.dashboard.schemas import ( - AIPerformanceStats, - AnalyticsSummary, - ModerationStats, - TimeSeriesDataPoint, - UserActivityStats, -) -from guardden.models import AICheck, MessageActivity, ModerationLog, UserActivity - - -def create_analytics_router( - settings: DashboardSettings, - database: DashboardDatabase, -) -> APIRouter: - """Create the analytics API router.""" - router = APIRouter(prefix="/api/analytics") - - async def get_session() -> AsyncIterator[AsyncSession]: - async for session in database.session(): - yield session - - def require_owner_dep(request: Request) -> None: - require_owner(settings, request) - - @router.get( - "/summary", - response_model=AnalyticsSummary, - dependencies=[Depends(require_owner_dep)], - ) - async def analytics_summary( - guild_id: int | None = Query(default=None), - days: int = Query(default=7, ge=1, le=90), - session: AsyncSession = Depends(get_session), - ) -> AnalyticsSummary: - """Get analytics summary for the specified time period.""" - start_date = datetime.now() - timedelta(days=days) - - # Moderation stats - mod_query = select(ModerationLog).where(ModerationLog.created_at >= start_date) - if guild_id: - mod_query = mod_query.where(ModerationLog.guild_id == guild_id) - - mod_result = await session.execute(mod_query) - mod_logs = mod_result.scalars().all() - - total_actions = len(mod_logs) - actions_by_type: dict[str, int] = {} - automatic_count = 0 - manual_count = 0 - - for log in mod_logs: - actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1 - if log.is_automatic: - automatic_count += 1 - else: - manual_count += 1 - - # Time series data (group by day) - time_series: dict[str, int] = {} - for log in mod_logs: - day_key = log.created_at.strftime("%Y-%m-%d") - time_series[day_key] = time_series.get(day_key, 0) + 1 - - actions_over_time = [ - TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count) - for day, count in sorted(time_series.items()) - ] - - moderation_stats = ModerationStats( - total_actions=total_actions, - actions_by_type=actions_by_type, - actions_over_time=actions_over_time, - automatic_vs_manual={"automatic": automatic_count, "manual": manual_count}, - ) - - # User activity stats - activity_query = select(MessageActivity).where(MessageActivity.date >= start_date) - if guild_id: - activity_query = activity_query.where(MessageActivity.guild_id == guild_id) - - activity_result = await session.execute(activity_query) - activities = activity_result.scalars().all() - - total_messages = sum(a.total_messages for a in activities) - active_users = max((a.active_users for a in activities), default=0) - - # New joins - today = datetime.now().date() - week_ago = today - timedelta(days=7) - new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today) - new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago) - - user_activity = UserActivityStats( - active_users=active_users, - total_messages=total_messages, - new_joins_today=new_joins_today, - new_joins_week=new_joins_week, - ) - - # AI performance stats - ai_query = select(AICheck).where(AICheck.created_at >= start_date) - if guild_id: - ai_query = ai_query.where(AICheck.guild_id == guild_id) - - ai_result = await session.execute(ai_query) - ai_checks = ai_result.scalars().all() - - total_checks = len(ai_checks) - flagged_content = sum(1 for c in ai_checks if c.flagged) - avg_confidence = ( - sum(c.confidence for c in ai_checks) / total_checks if total_checks > 0 else 0.0 - ) - false_positives = sum(1 for c in ai_checks if c.is_false_positive) - avg_response_time = ( - sum(c.response_time_ms for c in ai_checks) / total_checks if total_checks > 0 else 0.0 - ) - - ai_performance = AIPerformanceStats( - total_checks=total_checks, - flagged_content=flagged_content, - avg_confidence=avg_confidence, - false_positives=false_positives, - avg_response_time_ms=avg_response_time, - ) - - return AnalyticsSummary( - moderation_stats=moderation_stats, - user_activity=user_activity, - ai_performance=ai_performance, - ) - - @router.get( - "/moderation-stats", - response_model=ModerationStats, - dependencies=[Depends(require_owner_dep)], - ) - async def moderation_stats( - guild_id: int | None = Query(default=None), - days: int = Query(default=30, ge=1, le=90), - session: AsyncSession = Depends(get_session), - ) -> ModerationStats: - """Get detailed moderation statistics.""" - start_date = datetime.now() - timedelta(days=days) - - query = select(ModerationLog).where(ModerationLog.created_at >= start_date) - if guild_id: - query = query.where(ModerationLog.guild_id == guild_id) - - result = await session.execute(query) - logs = result.scalars().all() - - total_actions = len(logs) - actions_by_type: dict[str, int] = {} - automatic_count = 0 - manual_count = 0 - - for log in logs: - actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1 - if log.is_automatic: - automatic_count += 1 - else: - manual_count += 1 - - # Time series data - time_series: dict[str, int] = {} - for log in logs: - day_key = log.created_at.strftime("%Y-%m-%d") - time_series[day_key] = time_series.get(day_key, 0) + 1 - - actions_over_time = [ - TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count) - for day, count in sorted(time_series.items()) - ] - - return ModerationStats( - total_actions=total_actions, - actions_by_type=actions_by_type, - actions_over_time=actions_over_time, - automatic_vs_manual={"automatic": automatic_count, "manual": manual_count}, - ) - - @router.get( - "/user-activity", - response_model=UserActivityStats, - dependencies=[Depends(require_owner_dep)], - ) - async def user_activity_stats( - guild_id: int | None = Query(default=None), - days: int = Query(default=7, ge=1, le=90), - session: AsyncSession = Depends(get_session), - ) -> UserActivityStats: - """Get user activity statistics.""" - start_date = datetime.now() - timedelta(days=days) - - query = select(MessageActivity).where(MessageActivity.date >= start_date) - if guild_id: - query = query.where(MessageActivity.guild_id == guild_id) - - result = await session.execute(query) - activities = result.scalars().all() - - total_messages = sum(a.total_messages for a in activities) - active_users = max((a.active_users for a in activities), default=0) - - today = datetime.now().date() - week_ago = today - timedelta(days=7) - new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today) - new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago) - - return UserActivityStats( - active_users=active_users, - total_messages=total_messages, - new_joins_today=new_joins_today, - new_joins_week=new_joins_week, - ) - - @router.get( - "/ai-performance", - response_model=AIPerformanceStats, - dependencies=[Depends(require_owner_dep)], - ) - async def ai_performance_stats( - guild_id: int | None = Query(default=None), - days: int = Query(default=30, ge=1, le=90), - session: AsyncSession = Depends(get_session), - ) -> AIPerformanceStats: - """Get AI moderation performance statistics.""" - start_date = datetime.now() - timedelta(days=days) - - query = select(AICheck).where(AICheck.created_at >= start_date) - if guild_id: - query = query.where(AICheck.guild_id == guild_id) - - result = await session.execute(query) - checks = result.scalars().all() - - total_checks = len(checks) - flagged_content = sum(1 for c in checks if c.flagged) - avg_confidence = ( - sum(c.confidence for c in checks) / total_checks if total_checks > 0 else 0.0 - ) - false_positives = sum(1 for c in checks if c.is_false_positive) - avg_response_time = ( - sum(c.response_time_ms for c in checks) / total_checks if total_checks > 0 else 0.0 - ) - - return AIPerformanceStats( - total_checks=total_checks, - flagged_content=flagged_content, - avg_confidence=avg_confidence, - false_positives=false_positives, - avg_response_time_ms=avg_response_time, - ) - - return router diff --git a/src/guardden/dashboard/auth.py b/src/guardden/dashboard/auth.py deleted file mode 100644 index ce6f688..0000000 --- a/src/guardden/dashboard/auth.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Authentication helpers for the dashboard.""" - -from typing import Any -from urllib.parse import urlencode - -import httpx -from authlib.integrations.starlette_client import OAuth -from fastapi import HTTPException, Request, status - -from guardden.dashboard.config import DashboardSettings - - -def build_oauth(settings: DashboardSettings) -> OAuth: - """Build OAuth client registrations.""" - oauth = OAuth() - oauth.register( - name="entra", - client_id=settings.entra_client_id, - client_secret=settings.entra_client_secret.get_secret_value(), - server_metadata_url=( - "https://login.microsoftonline.com/" - f"{settings.entra_tenant_id}/v2.0/.well-known/openid-configuration" - ), - client_kwargs={"scope": "openid profile email"}, - ) - return oauth - - -def discord_authorize_url(settings: DashboardSettings, state: str) -> str: - """Generate the Discord OAuth authorization URL.""" - query = urlencode( - { - "client_id": settings.discord_client_id, - "redirect_uri": settings.callback_url("discord"), - "response_type": "code", - "scope": "identify", - "state": state, - } - ) - return f"https://discord.com/oauth2/authorize?{query}" - - -async def exchange_discord_code(settings: DashboardSettings, code: str) -> dict[str, Any]: - """Exchange a Discord OAuth code for a user profile.""" - async with httpx.AsyncClient(timeout=10.0) as client: - token_response = await client.post( - "https://discord.com/api/oauth2/token", - data={ - "client_id": settings.discord_client_id, - "client_secret": settings.discord_client_secret.get_secret_value(), - "grant_type": "authorization_code", - "code": code, - "redirect_uri": settings.callback_url("discord"), - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - token_response.raise_for_status() - token_data = token_response.json() - - user_response = await client.get( - "https://discord.com/api/users/@me", - headers={"Authorization": f"Bearer {token_data['access_token']}"}, - ) - user_response.raise_for_status() - return user_response.json() - - -def require_owner(settings: DashboardSettings, request: Request) -> None: - """Ensure the current session is the configured owner.""" - session = request.session - entra_oid = session.get("entra_oid") - discord_id = session.get("discord_id") - if not entra_oid or not discord_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - if str(entra_oid) != settings.owner_entra_object_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") - if int(discord_id) != settings.owner_discord_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") diff --git a/src/guardden/dashboard/config.py b/src/guardden/dashboard/config.py deleted file mode 100644 index c370564..0000000 --- a/src/guardden/dashboard/config.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Configuration for the GuardDen dashboard.""" - -from pathlib import Path -from typing import Any - -from pydantic import Field, SecretStr, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class DashboardSettings(BaseSettings): - """Dashboard settings loaded from environment variables.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - env_prefix="GUARDDEN_DASHBOARD_", - ) - - database_url: SecretStr = Field( - validation_alias="GUARDDEN_DATABASE_URL", - description="Database connection URL", - ) - - base_url: str = Field( - default="http://localhost:8080", - description="Base URL for OAuth callbacks", - ) - secret_key: SecretStr = Field( - default=SecretStr("change-me"), - description="Session secret key", - ) - - entra_tenant_id: str = Field(description="Entra ID tenant ID") - entra_client_id: str = Field(description="Entra ID application client ID") - entra_client_secret: SecretStr = Field(description="Entra ID application client secret") - - discord_client_id: str = Field(description="Discord OAuth client ID") - discord_client_secret: SecretStr = Field(description="Discord OAuth client secret") - - owner_discord_id: int = Field(description="Discord user ID allowed to access dashboard") - owner_entra_object_id: str = Field(description="Entra ID object ID allowed to access") - - cors_origins: list[str] = Field(default_factory=list, description="Allowed CORS origins") - static_dir: Path = Field( - default=Path("dashboard/frontend/dist"), - description="Directory containing built frontend assets", - ) - - @field_validator("cors_origins", mode="before") - @classmethod - def _parse_origins(cls, value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] - text = str(value).strip() - if not text: - return [] - return [item.strip() for item in text.split(",") if item.strip()] - - def callback_url(self, provider: str) -> str: - return f"{self.base_url}/auth/{provider}/callback" - - -def get_dashboard_settings() -> DashboardSettings: - """Load dashboard settings from environment.""" - return DashboardSettings() diff --git a/src/guardden/dashboard/config_management.py b/src/guardden/dashboard/config_management.py deleted file mode 100644 index 8dcdcbb..0000000 --- a/src/guardden/dashboard/config_management.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Configuration management API routes for the GuardDen dashboard.""" - -import json -from collections.abc import AsyncIterator -from datetime import datetime - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status -from fastapi.responses import StreamingResponse -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from guardden.dashboard.auth import require_owner -from guardden.dashboard.config import DashboardSettings -from guardden.dashboard.db import DashboardDatabase -from guardden.dashboard.schemas import AutomodRuleConfig, ConfigExport, GuildSettings -from guardden.models import Guild -from guardden.models import GuildSettings as GuildSettingsModel - - -def create_config_router( - settings: DashboardSettings, - database: DashboardDatabase, -) -> APIRouter: - """Create the configuration management API router.""" - router = APIRouter(prefix="/api/guilds") - - async def get_session() -> AsyncIterator[AsyncSession]: - async for session in database.session(): - yield session - - def require_owner_dep(request: Request) -> None: - require_owner(settings, request) - - @router.get( - "/{guild_id}/settings", - response_model=GuildSettings, - dependencies=[Depends(require_owner_dep)], - ) - async def get_guild_settings( - guild_id: int = Path(...), - session: AsyncSession = Depends(get_session), - ) -> GuildSettings: - """Get guild settings.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - return GuildSettings( - guild_id=guild_settings.guild_id, - prefix=guild_settings.prefix, - log_channel_id=guild_settings.log_channel_id, - automod_enabled=guild_settings.automod_enabled, - ai_moderation_enabled=guild_settings.ai_moderation_enabled, - ai_sensitivity=guild_settings.ai_sensitivity, - verification_enabled=guild_settings.verification_enabled, - verification_role_id=guild_settings.verified_role_id, - max_warns_before_action=3, # Default value, could be derived from strike_actions - ) - - @router.put( - "/{guild_id}/settings", - response_model=GuildSettings, - dependencies=[Depends(require_owner_dep)], - ) - async def update_guild_settings( - guild_id: int = Path(...), - settings_data: GuildSettings = ..., - session: AsyncSession = Depends(get_session), - ) -> GuildSettings: - """Update guild settings.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - # Update settings - if settings_data.prefix is not None: - guild_settings.prefix = settings_data.prefix - if settings_data.log_channel_id is not None: - guild_settings.log_channel_id = settings_data.log_channel_id - guild_settings.automod_enabled = settings_data.automod_enabled - guild_settings.ai_moderation_enabled = settings_data.ai_moderation_enabled - guild_settings.ai_sensitivity = settings_data.ai_sensitivity - guild_settings.verification_enabled = settings_data.verification_enabled - if settings_data.verification_role_id is not None: - guild_settings.verified_role_id = settings_data.verification_role_id - - await session.commit() - await session.refresh(guild_settings) - - return GuildSettings( - guild_id=guild_settings.guild_id, - prefix=guild_settings.prefix, - log_channel_id=guild_settings.log_channel_id, - automod_enabled=guild_settings.automod_enabled, - ai_moderation_enabled=guild_settings.ai_moderation_enabled, - ai_sensitivity=guild_settings.ai_sensitivity, - verification_enabled=guild_settings.verification_enabled, - verification_role_id=guild_settings.verified_role_id, - max_warns_before_action=3, - ) - - @router.get( - "/{guild_id}/automod", - response_model=AutomodRuleConfig, - dependencies=[Depends(require_owner_dep)], - ) - async def get_automod_config( - guild_id: int = Path(...), - session: AsyncSession = Depends(get_session), - ) -> AutomodRuleConfig: - """Get automod rule configuration.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - return AutomodRuleConfig( - guild_id=guild_settings.guild_id, - banned_words_enabled=True, # Derived from automod_enabled - scam_detection_enabled=guild_settings.automod_enabled, - spam_detection_enabled=guild_settings.anti_spam_enabled, - invite_filter_enabled=guild_settings.link_filter_enabled, - max_mentions=guild_settings.mention_limit, - max_emojis=10, # Default value - spam_threshold=guild_settings.message_rate_limit, - ) - - @router.put( - "/{guild_id}/automod", - response_model=AutomodRuleConfig, - dependencies=[Depends(require_owner_dep)], - ) - async def update_automod_config( - guild_id: int = Path(...), - automod_data: AutomodRuleConfig = ..., - session: AsyncSession = Depends(get_session), - ) -> AutomodRuleConfig: - """Update automod rule configuration.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - # Update automod settings - guild_settings.automod_enabled = automod_data.scam_detection_enabled - guild_settings.anti_spam_enabled = automod_data.spam_detection_enabled - guild_settings.link_filter_enabled = automod_data.invite_filter_enabled - guild_settings.mention_limit = automod_data.max_mentions - guild_settings.message_rate_limit = automod_data.spam_threshold - - await session.commit() - await session.refresh(guild_settings) - - return AutomodRuleConfig( - guild_id=guild_settings.guild_id, - banned_words_enabled=automod_data.banned_words_enabled, - scam_detection_enabled=guild_settings.automod_enabled, - spam_detection_enabled=guild_settings.anti_spam_enabled, - invite_filter_enabled=guild_settings.link_filter_enabled, - max_mentions=guild_settings.mention_limit, - max_emojis=10, - spam_threshold=guild_settings.message_rate_limit, - ) - - @router.get( - "/{guild_id}/export", - dependencies=[Depends(require_owner_dep)], - ) - async def export_config( - guild_id: int = Path(...), - session: AsyncSession = Depends(get_session), - ) -> StreamingResponse: - """Export guild configuration as JSON.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - # Build export data - export_data = ConfigExport( - version="1.0", - guild_settings=GuildSettings( - guild_id=guild_settings.guild_id, - prefix=guild_settings.prefix, - log_channel_id=guild_settings.log_channel_id, - automod_enabled=guild_settings.automod_enabled, - ai_moderation_enabled=guild_settings.ai_moderation_enabled, - ai_sensitivity=guild_settings.ai_sensitivity, - verification_enabled=guild_settings.verification_enabled, - verification_role_id=guild_settings.verified_role_id, - max_warns_before_action=3, - ), - automod_rules=AutomodRuleConfig( - guild_id=guild_settings.guild_id, - banned_words_enabled=True, - scam_detection_enabled=guild_settings.automod_enabled, - spam_detection_enabled=guild_settings.anti_spam_enabled, - invite_filter_enabled=guild_settings.link_filter_enabled, - max_mentions=guild_settings.mention_limit, - max_emojis=10, - spam_threshold=guild_settings.message_rate_limit, - ), - exported_at=datetime.now(), - ) - - # Convert to JSON - json_data = export_data.model_dump_json(indent=2) - - return StreamingResponse( - iter([json_data]), - media_type="application/json", - headers={"Content-Disposition": f"attachment; filename=guild_{guild_id}_config.json"}, - ) - - @router.post( - "/{guild_id}/import", - response_model=GuildSettings, - dependencies=[Depends(require_owner_dep)], - ) - async def import_config( - guild_id: int = Path(...), - config_data: ConfigExport = ..., - session: AsyncSession = Depends(get_session), - ) -> GuildSettings: - """Import guild configuration from JSON.""" - query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id) - result = await session.execute(query) - guild_settings = result.scalar_one_or_none() - - if not guild_settings: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Guild settings not found", - ) - - # Import settings - settings = config_data.guild_settings - if settings.prefix is not None: - guild_settings.prefix = settings.prefix - if settings.log_channel_id is not None: - guild_settings.log_channel_id = settings.log_channel_id - guild_settings.automod_enabled = settings.automod_enabled - guild_settings.ai_moderation_enabled = settings.ai_moderation_enabled - guild_settings.ai_sensitivity = settings.ai_sensitivity - guild_settings.verification_enabled = settings.verification_enabled - if settings.verification_role_id is not None: - guild_settings.verified_role_id = settings.verification_role_id - - # Import automod rules - automod = config_data.automod_rules - guild_settings.anti_spam_enabled = automod.spam_detection_enabled - guild_settings.link_filter_enabled = automod.invite_filter_enabled - guild_settings.mention_limit = automod.max_mentions - guild_settings.message_rate_limit = automod.spam_threshold - - await session.commit() - await session.refresh(guild_settings) - - return GuildSettings( - guild_id=guild_settings.guild_id, - prefix=guild_settings.prefix, - log_channel_id=guild_settings.log_channel_id, - automod_enabled=guild_settings.automod_enabled, - ai_moderation_enabled=guild_settings.ai_moderation_enabled, - ai_sensitivity=guild_settings.ai_sensitivity, - verification_enabled=guild_settings.verification_enabled, - verification_role_id=guild_settings.verified_role_id, - max_warns_before_action=3, - ) - - return router diff --git a/src/guardden/dashboard/db.py b/src/guardden/dashboard/db.py deleted file mode 100644 index a7bde99..0000000 --- a/src/guardden/dashboard/db.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Database helpers for the dashboard.""" - -from collections.abc import AsyncIterator - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from guardden.dashboard.config import DashboardSettings - - -class DashboardDatabase: - """Async database session factory for the dashboard.""" - - def __init__(self, settings: DashboardSettings) -> None: - db_url = settings.database_url.get_secret_value() - if db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1) - - self._engine = create_async_engine(db_url, pool_pre_ping=True) - self._sessionmaker = async_sessionmaker(self._engine, expire_on_commit=False) - - async def session(self) -> AsyncIterator[AsyncSession]: - """Yield a database session.""" - async with self._sessionmaker() as session: - yield session diff --git a/src/guardden/dashboard/main.py b/src/guardden/dashboard/main.py deleted file mode 100644 index 2a4e20d..0000000 --- a/src/guardden/dashboard/main.py +++ /dev/null @@ -1,121 +0,0 @@ -"""FastAPI app for the GuardDen dashboard.""" - -import logging -import secrets -from pathlib import Path - -from fastapi import FastAPI, HTTPException, Request, status -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse -from starlette.middleware.sessions import SessionMiddleware -from starlette.staticfiles import StaticFiles - -from guardden.dashboard.analytics import create_analytics_router -from guardden.dashboard.auth import ( - build_oauth, - discord_authorize_url, - exchange_discord_code, - require_owner, -) -from guardden.dashboard.config import DashboardSettings, get_dashboard_settings -from guardden.dashboard.config_management import create_config_router -from guardden.dashboard.db import DashboardDatabase -from guardden.dashboard.routes import create_api_router -from guardden.dashboard.users import create_users_router -from guardden.dashboard.websocket import create_websocket_router - -logger = logging.getLogger(__name__) - - -def create_app() -> FastAPI: - settings = get_dashboard_settings() - database = DashboardDatabase(settings) - oauth = build_oauth(settings) - - app = FastAPI(title="GuardDen Dashboard") - app.add_middleware(SessionMiddleware, secret_key=settings.secret_key.get_secret_value()) - - if settings.cors_origins: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - def require_owner_dep(request: Request) -> None: - require_owner(settings, request) - - @app.get("/api/health") - async def health() -> dict[str, str]: - return {"status": "ok"} - - @app.get("/api/me") - async def me(request: Request) -> dict[str, bool | str | None]: - entra_oid = request.session.get("entra_oid") - discord_id = request.session.get("discord_id") - owner = str(entra_oid) == settings.owner_entra_object_id and str(discord_id) == str( - settings.owner_discord_id - ) - return { - "entra": bool(entra_oid), - "discord": bool(discord_id), - "owner": owner, - "entra_oid": entra_oid, - "discord_id": discord_id, - } - - @app.get("/auth/entra/login") - async def entra_login(request: Request) -> RedirectResponse: - redirect_uri = settings.callback_url("entra") - return await oauth.entra.authorize_redirect(request, redirect_uri) - - @app.get("/auth/entra/callback") - async def entra_callback(request: Request) -> RedirectResponse: - token = await oauth.entra.authorize_access_token(request) - user = await oauth.entra.parse_id_token(request, token) - request.session["entra_oid"] = user.get("oid") - return RedirectResponse(url="/") - - @app.get("/auth/discord/login") - async def discord_login(request: Request) -> RedirectResponse: - state = secrets.token_urlsafe(16) - request.session["discord_state"] = state - return RedirectResponse(url=discord_authorize_url(settings, state)) - - @app.get("/auth/discord/callback") - async def discord_callback(request: Request) -> RedirectResponse: - params = dict(request.query_params) - code = params.get("code") - state = params.get("state") - if not code or not state: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code") - if state != request.session.get("discord_state"): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state") - profile = await exchange_discord_code(settings, code) - request.session["discord_id"] = profile.get("id") - return RedirectResponse(url="/") - - @app.get("/auth/logout") - async def logout(request: Request) -> RedirectResponse: - request.session.clear() - return RedirectResponse(url="/") - - # Include all API routers - app.include_router(create_api_router(settings, database)) - app.include_router(create_analytics_router(settings, database)) - app.include_router(create_users_router(settings, database)) - app.include_router(create_config_router(settings, database)) - app.include_router(create_websocket_router(settings)) - - static_dir = Path(settings.static_dir) - if static_dir.exists(): - app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") - else: - logger.warning("Static directory not found: %s", static_dir) - - return app - - -app = create_app() diff --git a/src/guardden/dashboard/routes.py b/src/guardden/dashboard/routes.py deleted file mode 100644 index 3dead5e..0000000 --- a/src/guardden/dashboard/routes.py +++ /dev/null @@ -1,111 +0,0 @@ -"""API routes for the GuardDen dashboard.""" - -from collections.abc import AsyncIterator - -from fastapi import APIRouter, Depends, Query, Request -from sqlalchemy import func, or_, select -from sqlalchemy.ext.asyncio import AsyncSession - -from guardden.dashboard.auth import require_owner -from guardden.dashboard.config import DashboardSettings -from guardden.dashboard.db import DashboardDatabase -from guardden.dashboard.schemas import GuildSummary, ModerationLogEntry, PaginatedLogs -from guardden.models import Guild, ModerationLog - - -def create_api_router( - settings: DashboardSettings, - database: DashboardDatabase, -) -> APIRouter: - """Create the dashboard API router.""" - router = APIRouter(prefix="/api") - - async def get_session() -> AsyncIterator[AsyncSession]: - async for session in database.session(): - yield session - - def require_owner_dep(request: Request) -> None: - require_owner(settings, request) - - @router.get( - "/guilds", response_model=list[GuildSummary], dependencies=[Depends(require_owner_dep)] - ) - async def list_guilds( - session: AsyncSession = Depends(get_session), - ) -> list[GuildSummary]: - result = await session.execute(select(Guild).order_by(Guild.name.asc())) - guilds = result.scalars().all() - return [ - GuildSummary(id=g.id, name=g.name, owner_id=g.owner_id, premium=g.premium) - for g in guilds - ] - - @router.get( - "/moderation/logs", - response_model=PaginatedLogs, - dependencies=[Depends(require_owner_dep)], - ) - async def list_moderation_logs( - guild_id: int | None = Query(default=None), - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), - action: str | None = Query(default=None), - message_only: bool = Query(default=False), - search: str | None = Query(default=None), - session: AsyncSession = Depends(get_session), - ) -> PaginatedLogs: - query = select(ModerationLog) - count_query = select(func.count(ModerationLog.id)) - if guild_id: - query = query.where(ModerationLog.guild_id == guild_id) - count_query = count_query.where(ModerationLog.guild_id == guild_id) - - if action: - query = query.where(ModerationLog.action == action) - count_query = count_query.where(ModerationLog.action == action) - - if message_only: - query = query.where(ModerationLog.message_content.is_not(None)) - count_query = count_query.where(ModerationLog.message_content.is_not(None)) - - if search: - like = f"%{search}%" - search_filter = or_( - ModerationLog.target_name.ilike(like), - ModerationLog.moderator_name.ilike(like), - ModerationLog.reason.ilike(like), - ModerationLog.message_content.ilike(like), - ) - query = query.where(search_filter) - count_query = count_query.where(search_filter) - - query = query.order_by(ModerationLog.created_at.desc()).offset(offset).limit(limit) - total_result = await session.execute(count_query) - total = int(total_result.scalar() or 0) - - result = await session.execute(query) - logs = result.scalars().all() - items = [ - ModerationLogEntry( - id=log.id, - guild_id=log.guild_id, - target_id=log.target_id, - target_name=log.target_name, - moderator_id=log.moderator_id, - moderator_name=log.moderator_name, - action=log.action, - reason=log.reason, - duration=log.duration, - expires_at=log.expires_at, - channel_id=log.channel_id, - message_id=log.message_id, - message_content=log.message_content, - is_automatic=log.is_automatic, - created_at=log.created_at, - ) - for log in logs - ] - - return PaginatedLogs(total=total, items=items) - - return router diff --git a/src/guardden/dashboard/schemas.py b/src/guardden/dashboard/schemas.py deleted file mode 100644 index 69e087e..0000000 --- a/src/guardden/dashboard/schemas.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Pydantic schemas for dashboard APIs.""" - -from datetime import datetime - -from pydantic import BaseModel, Field - - -class GuildSummary(BaseModel): - id: int - name: str - owner_id: int - premium: bool - - -class ModerationLogEntry(BaseModel): - id: int - guild_id: int - target_id: int - target_name: str - moderator_id: int - moderator_name: str - action: str - reason: str | None - duration: int | None - expires_at: datetime | None - channel_id: int | None - message_id: int | None - message_content: str | None - is_automatic: bool - created_at: datetime - - -class PaginatedLogs(BaseModel): - total: int - items: list[ModerationLogEntry] - - -# Analytics Schemas -class TimeSeriesDataPoint(BaseModel): - timestamp: datetime - value: int - - -class ModerationStats(BaseModel): - total_actions: int - actions_by_type: dict[str, int] - actions_over_time: list[TimeSeriesDataPoint] - automatic_vs_manual: dict[str, int] - - -class UserActivityStats(BaseModel): - active_users: int - total_messages: int - new_joins_today: int - new_joins_week: int - - -class AIPerformanceStats(BaseModel): - total_checks: int - flagged_content: int - avg_confidence: float - false_positives: int = 0 - avg_response_time_ms: float = 0.0 - - -class AnalyticsSummary(BaseModel): - moderation_stats: ModerationStats - user_activity: UserActivityStats - ai_performance: AIPerformanceStats - - -# User Management Schemas -class UserProfile(BaseModel): - guild_id: int - guild_name: str - user_id: int - username: str - strike_count: int - total_warnings: int - total_kicks: int - total_bans: int - total_timeouts: int - first_seen: datetime - last_action: datetime | None - - -class UserNote(BaseModel): - id: int - user_id: int - guild_id: int - moderator_id: int - moderator_name: str - content: str - created_at: datetime - - -class CreateUserNote(BaseModel): - content: str = Field(min_length=1, max_length=2000) - - -class BulkModerationAction(BaseModel): - action: str = Field(pattern="^(ban|kick|timeout|warn)$") - user_ids: list[int] = Field(min_length=1, max_length=100) - reason: str | None = None - duration: int | None = None - - -class BulkActionResult(BaseModel): - success_count: int - failed_count: int - errors: dict[int, str] - - -# Configuration Schemas -class GuildSettings(BaseModel): - guild_id: int - prefix: str | None = None - log_channel_id: int | None = None - automod_enabled: bool = True - ai_moderation_enabled: bool = False - ai_sensitivity: int = Field(ge=0, le=100, default=50) - verification_enabled: bool = False - verification_role_id: int | None = None - max_warns_before_action: int = Field(ge=1, le=10, default=3) - - -class AutomodRuleConfig(BaseModel): - guild_id: int - banned_words_enabled: bool = True - scam_detection_enabled: bool = True - spam_detection_enabled: bool = True - invite_filter_enabled: bool = False - max_mentions: int = Field(ge=1, le=20, default=5) - max_emojis: int = Field(ge=1, le=50, default=10) - spam_threshold: int = Field(ge=1, le=20, default=5) - - -class ConfigExport(BaseModel): - version: str = "1.0" - guild_settings: GuildSettings - automod_rules: AutomodRuleConfig - exported_at: datetime - - -# WebSocket Event Schemas -class WebSocketEvent(BaseModel): - type: str - guild_id: int - timestamp: datetime - data: dict[str, object] - - -class ModerationEvent(WebSocketEvent): - type: str = "moderation_action" - data: dict[str, object] - - -class UserJoinEvent(WebSocketEvent): - type: str = "user_join" - data: dict[str, object] - - -class AIAlertEvent(WebSocketEvent): - type: str = "ai_alert" - data: dict[str, object] diff --git a/src/guardden/dashboard/users.py b/src/guardden/dashboard/users.py deleted file mode 100644 index 0c5452f..0000000 --- a/src/guardden/dashboard/users.py +++ /dev/null @@ -1,254 +0,0 @@ -"""User management API routes for the GuardDen dashboard.""" - -from collections.abc import AsyncIterator -from datetime import datetime - -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from guardden.dashboard.auth import require_owner -from guardden.dashboard.config import DashboardSettings -from guardden.dashboard.db import DashboardDatabase -from guardden.dashboard.schemas import CreateUserNote, UserNote, UserProfile -from guardden.models import Guild, ModerationLog, UserActivity -from guardden.models import UserNote as UserNoteModel - - -def create_users_router( - settings: DashboardSettings, - database: DashboardDatabase, -) -> APIRouter: - """Create the user management API router.""" - router = APIRouter(prefix="/api/users") - - async def get_session() -> AsyncIterator[AsyncSession]: - async for session in database.session(): - yield session - - def require_owner_dep(request: Request) -> None: - require_owner(settings, request) - - @router.get( - "/search", - response_model=list[UserProfile], - dependencies=[Depends(require_owner_dep)], - ) - async def search_users( - guild_id: int | None = Query(default=None), - username: str | None = Query(default=None), - min_strikes: int | None = Query(default=None, ge=0), - limit: int = Query(default=50, ge=1, le=200), - session: AsyncSession = Depends(get_session), - ) -> list[UserProfile]: - """Search for users with optional guild and filter parameters.""" - query = select(UserActivity, Guild.name).join(Guild, Guild.id == UserActivity.guild_id) - if guild_id: - query = query.where(UserActivity.guild_id == guild_id) - - if username: - query = query.where(UserActivity.username.ilike(f"%{username}%")) - - if min_strikes is not None: - query = query.where(UserActivity.strike_count >= min_strikes) - - query = query.order_by(UserActivity.last_seen.desc()).limit(limit) - - result = await session.execute(query) - users = result.all() - - # Get last moderation action for each user - profiles = [] - for user, guild_name in users: - last_action_query = ( - select(ModerationLog.created_at) - .where(ModerationLog.guild_id == user.guild_id) - .where(ModerationLog.target_id == user.user_id) - .order_by(ModerationLog.created_at.desc()) - .limit(1) - ) - last_action_result = await session.execute(last_action_query) - last_action = last_action_result.scalar() - - profiles.append( - UserProfile( - guild_id=user.guild_id, - guild_name=guild_name, - user_id=user.user_id, - username=user.username, - strike_count=user.strike_count, - total_warnings=user.warning_count, - total_kicks=user.kick_count, - total_bans=user.ban_count, - total_timeouts=user.timeout_count, - first_seen=user.first_seen, - last_action=last_action, - ) - ) - - return profiles - - @router.get( - "/{user_id}/profile", - response_model=UserProfile, - dependencies=[Depends(require_owner_dep)], - ) - async def get_user_profile( - user_id: int = Path(...), - guild_id: int = Query(...), - session: AsyncSession = Depends(get_session), - ) -> UserProfile: - """Get detailed profile for a specific user.""" - query = ( - select(UserActivity, Guild.name) - .join(Guild, Guild.id == UserActivity.guild_id) - .where(UserActivity.guild_id == guild_id) - .where(UserActivity.user_id == user_id) - ) - - result = await session.execute(query) - row = result.one_or_none() - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found in this guild", - ) - user, guild_name = row - - # Get last moderation action - last_action_query = ( - select(ModerationLog.created_at) - .where(ModerationLog.guild_id == guild_id) - .where(ModerationLog.target_id == user_id) - .order_by(ModerationLog.created_at.desc()) - .limit(1) - ) - last_action_result = await session.execute(last_action_query) - last_action = last_action_result.scalar() - - return UserProfile( - guild_id=user.guild_id, - guild_name=guild_name, - user_id=user.user_id, - username=user.username, - strike_count=user.strike_count, - total_warnings=user.warning_count, - total_kicks=user.kick_count, - total_bans=user.ban_count, - total_timeouts=user.timeout_count, - first_seen=user.first_seen, - last_action=last_action, - ) - - @router.get( - "/{user_id}/notes", - response_model=list[UserNote], - dependencies=[Depends(require_owner_dep)], - ) - async def get_user_notes( - user_id: int = Path(...), - guild_id: int = Query(...), - session: AsyncSession = Depends(get_session), - ) -> list[UserNote]: - """Get all notes for a specific user.""" - query = ( - select(UserNoteModel) - .where(UserNoteModel.guild_id == guild_id) - .where(UserNoteModel.user_id == user_id) - .order_by(UserNoteModel.created_at.desc()) - ) - - result = await session.execute(query) - notes = result.scalars().all() - - return [ - UserNote( - id=note.id, - user_id=note.user_id, - guild_id=note.guild_id, - moderator_id=note.moderator_id, - moderator_name=note.moderator_name, - content=note.content, - created_at=note.created_at, - ) - for note in notes - ] - - @router.post( - "/{user_id}/notes", - response_model=UserNote, - dependencies=[Depends(require_owner_dep)], - ) - async def create_user_note( - user_id: int = Path(...), - guild_id: int = Query(...), - note_data: CreateUserNote = ..., - request: Request = ..., - session: AsyncSession = Depends(get_session), - ) -> UserNote: - """Create a new note for a user.""" - # Get moderator info from session - moderator_id = request.session.get("discord_id") - if not moderator_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Discord authentication required", - ) - - # Create the note - new_note = UserNoteModel( - user_id=user_id, - guild_id=guild_id, - moderator_id=int(moderator_id), - moderator_name="Dashboard User", # TODO: Fetch actual username - content=note_data.content, - created_at=datetime.now(), - ) - - session.add(new_note) - await session.commit() - await session.refresh(new_note) - - return UserNote( - id=new_note.id, - user_id=new_note.user_id, - guild_id=new_note.guild_id, - moderator_id=new_note.moderator_id, - moderator_name=new_note.moderator_name, - content=new_note.content, - created_at=new_note.created_at, - ) - - @router.delete( - "/{user_id}/notes/{note_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_owner_dep)], - ) - async def delete_user_note( - user_id: int = Path(...), - note_id: int = Path(...), - guild_id: int = Query(...), - session: AsyncSession = Depends(get_session), - ) -> None: - """Delete a user note.""" - query = ( - select(UserNoteModel) - .where(UserNoteModel.id == note_id) - .where(UserNoteModel.guild_id == guild_id) - .where(UserNoteModel.user_id == user_id) - ) - - result = await session.execute(query) - note = result.scalar_one_or_none() - - if not note: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Note not found", - ) - - await session.delete(note) - await session.commit() - - return router diff --git a/src/guardden/dashboard/websocket.py b/src/guardden/dashboard/websocket.py deleted file mode 100644 index 4faafc2..0000000 --- a/src/guardden/dashboard/websocket.py +++ /dev/null @@ -1,221 +0,0 @@ -"""WebSocket support for real-time dashboard updates.""" - -import asyncio -import logging -from datetime import datetime -from typing import Any - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect - -from guardden.dashboard.config import DashboardSettings -from guardden.dashboard.schemas import WebSocketEvent - -logger = logging.getLogger(__name__) - - -class ConnectionManager: - """Manage WebSocket connections for real-time updates.""" - - def __init__(self) -> None: - self.active_connections: dict[int, list[WebSocket]] = {} - self._lock = asyncio.Lock() - - async def connect(self, websocket: WebSocket, guild_id: int) -> None: - """Accept a new WebSocket connection.""" - await websocket.accept() - async with self._lock: - if guild_id not in self.active_connections: - self.active_connections[guild_id] = [] - self.active_connections[guild_id].append(websocket) - logger.info("New WebSocket connection for guild %s", guild_id) - - async def disconnect(self, websocket: WebSocket, guild_id: int) -> None: - """Remove a WebSocket connection.""" - async with self._lock: - if guild_id in self.active_connections: - if websocket in self.active_connections[guild_id]: - self.active_connections[guild_id].remove(websocket) - if not self.active_connections[guild_id]: - del self.active_connections[guild_id] - logger.info("WebSocket disconnected for guild %s", guild_id) - - async def broadcast_to_guild(self, guild_id: int, event: WebSocketEvent) -> None: - """Broadcast an event to all connections for a specific guild.""" - async with self._lock: - connections = self.active_connections.get(guild_id, []).copy() - - if not connections: - return - - # Convert event to JSON - message = event.model_dump_json() - - # Send to all connections - dead_connections = [] - for connection in connections: - try: - await connection.send_text(message) - except Exception as e: - logger.warning("Failed to send message to WebSocket: %s", e) - dead_connections.append(connection) - - # Clean up dead connections - if dead_connections: - async with self._lock: - if guild_id in self.active_connections: - for conn in dead_connections: - if conn in self.active_connections[guild_id]: - self.active_connections[guild_id].remove(conn) - if not self.active_connections[guild_id]: - del self.active_connections[guild_id] - - async def broadcast_to_all(self, event: WebSocketEvent) -> None: - """Broadcast an event to all connections.""" - async with self._lock: - all_guilds = list(self.active_connections.keys()) - - for guild_id in all_guilds: - await self.broadcast_to_guild(guild_id, event) - - def get_connection_count(self, guild_id: int | None = None) -> int: - """Get the number of active connections.""" - if guild_id is not None: - return len(self.active_connections.get(guild_id, [])) - return sum(len(conns) for conns in self.active_connections.values()) - - -# Global connection manager -connection_manager = ConnectionManager() - - -def create_websocket_router(settings: DashboardSettings) -> APIRouter: - """Create the WebSocket API router.""" - router = APIRouter() - - @router.websocket("/ws/events") - async def websocket_events(websocket: WebSocket, guild_id: int) -> None: - """WebSocket endpoint for real-time events.""" - await connection_manager.connect(websocket, guild_id) - try: - # Send initial connection confirmation - await websocket.send_json( - { - "type": "connected", - "guild_id": guild_id, - "timestamp": datetime.now().isoformat(), - "data": {"message": "Connected to real-time events"}, - } - ) - - # Keep connection alive and handle incoming messages - while True: - try: - # Wait for messages from client (ping/pong, etc.) - data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0) - - # Echo back as heartbeat - if data == "ping": - await websocket.send_text("pong") - - except asyncio.TimeoutError: - # Send periodic ping to keep connection alive - await websocket.send_json( - { - "type": "ping", - "guild_id": guild_id, - "timestamp": datetime.now().isoformat(), - "data": {}, - } - ) - - except WebSocketDisconnect: - logger.info("Client disconnected from WebSocket for guild %s", guild_id) - except Exception as e: - logger.error("WebSocket error for guild %s: %s", guild_id, e) - finally: - await connection_manager.disconnect(websocket, guild_id) - - return router - - -# Helper functions to broadcast events from other parts of the application -async def broadcast_moderation_action( - guild_id: int, - action: str, - target_id: int, - target_name: str, - moderator_name: str, - reason: str | None = None, -) -> None: - """Broadcast a moderation action event.""" - event = WebSocketEvent( - type="moderation_action", - guild_id=guild_id, - timestamp=datetime.now(), - data={ - "action": action, - "target_id": target_id, - "target_name": target_name, - "moderator_name": moderator_name, - "reason": reason, - }, - ) - await connection_manager.broadcast_to_guild(guild_id, event) - - -async def broadcast_user_join( - guild_id: int, - user_id: int, - username: str, -) -> None: - """Broadcast a user join event.""" - event = WebSocketEvent( - type="user_join", - guild_id=guild_id, - timestamp=datetime.now(), - data={ - "user_id": user_id, - "username": username, - }, - ) - await connection_manager.broadcast_to_guild(guild_id, event) - - -async def broadcast_ai_alert( - guild_id: int, - user_id: int, - severity: str, - category: str, - confidence: float, -) -> None: - """Broadcast an AI moderation alert.""" - event = WebSocketEvent( - type="ai_alert", - guild_id=guild_id, - timestamp=datetime.now(), - data={ - "user_id": user_id, - "severity": severity, - "category": category, - "confidence": confidence, - }, - ) - await connection_manager.broadcast_to_guild(guild_id, event) - - -async def broadcast_system_event( - event_type: str, - data: dict[str, Any], - guild_id: int | None = None, -) -> None: - """Broadcast a generic system event.""" - event = WebSocketEvent( - type=event_type, - guild_id=guild_id or 0, - timestamp=datetime.now(), - data=data, - ) - if guild_id: - await connection_manager.broadcast_to_guild(guild_id, event) - else: - await connection_manager.broadcast_to_all(event) diff --git a/src/guardden/models/guild.py b/src/guardden/models/guild.py index 5bf2ffd..60057f9 100644 --- a/src/guardden/models/guild.py +++ b/src/guardden/models/guild.py @@ -97,7 +97,10 @@ class GuildSettings(Base, TimestampMixin): 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) nsfw_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - nsfw_only_filtering: Mapped[bool] = mapped_column(Boolean, default=False, 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) # Verification settings verification_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/src/guardden/utils/metrics.py b/src/guardden/utils/metrics.py deleted file mode 100644 index d42e661..0000000 --- a/src/guardden/utils/metrics.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Prometheus metrics utilities for GuardDen.""" - -import time -from functools import wraps -from typing import Dict, Optional, Any - -try: - from prometheus_client import Counter, Histogram, Gauge, Info, start_http_server, CollectorRegistry, REGISTRY - PROMETHEUS_AVAILABLE = True -except ImportError: - PROMETHEUS_AVAILABLE = False - # Mock objects when Prometheus client is not available - class MockMetric: - def inc(self, *args, **kwargs): pass - def observe(self, *args, **kwargs): pass - def set(self, *args, **kwargs): pass - def info(self, *args, **kwargs): pass - - Counter = Histogram = Gauge = Info = MockMetric - CollectorRegistry = REGISTRY = None - - -class GuardDenMetrics: - """Centralized metrics collection for GuardDen.""" - - def __init__(self, registry: Optional[CollectorRegistry] = None): - self.registry = registry or REGISTRY - self.enabled = PROMETHEUS_AVAILABLE - - if not self.enabled: - return - - # Bot metrics - self.bot_commands_total = Counter( - 'guardden_commands_total', - 'Total number of commands executed', - ['command', 'guild', 'status'], - registry=self.registry - ) - - self.bot_command_duration = Histogram( - 'guardden_command_duration_seconds', - 'Command execution duration in seconds', - ['command', 'guild'], - registry=self.registry - ) - - self.bot_guilds_total = Gauge( - 'guardden_guilds_total', - 'Total number of guilds the bot is in', - registry=self.registry - ) - - self.bot_users_total = Gauge( - 'guardden_users_total', - 'Total number of users across all guilds', - registry=self.registry - ) - - # Moderation metrics - self.moderation_actions_total = Counter( - 'guardden_moderation_actions_total', - 'Total number of moderation actions', - ['action', 'guild', 'automated'], - registry=self.registry - ) - - self.automod_triggers_total = Counter( - 'guardden_automod_triggers_total', - 'Total number of automod triggers', - ['filter_type', 'guild', 'action'], - registry=self.registry - ) - - # AI metrics - self.ai_requests_total = Counter( - 'guardden_ai_requests_total', - 'Total number of AI provider requests', - ['provider', 'operation', 'status'], - registry=self.registry - ) - - self.ai_request_duration = Histogram( - 'guardden_ai_request_duration_seconds', - 'AI request duration in seconds', - ['provider', 'operation'], - registry=self.registry - ) - - self.ai_confidence_score = Histogram( - 'guardden_ai_confidence_score', - 'AI confidence scores', - ['provider', 'operation'], - buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], - registry=self.registry - ) - - # Database metrics - self.database_connections_active = Gauge( - 'guardden_database_connections_active', - 'Number of active database connections', - registry=self.registry - ) - - self.database_query_duration = Histogram( - 'guardden_database_query_duration_seconds', - 'Database query duration in seconds', - ['operation'], - registry=self.registry - ) - - # System metrics - self.bot_info = Info( - 'guardden_bot_info', - 'Bot information', - registry=self.registry - ) - - self.last_heartbeat = Gauge( - 'guardden_last_heartbeat_timestamp', - 'Timestamp of last successful heartbeat', - registry=self.registry - ) - - def record_command(self, command: str, guild_id: Optional[int], status: str, duration: float): - """Record command execution metrics.""" - if not self.enabled: - return - - guild_str = str(guild_id) if guild_id else 'dm' - self.bot_commands_total.labels(command=command, guild=guild_str, status=status).inc() - self.bot_command_duration.labels(command=command, guild=guild_str).observe(duration) - - def record_moderation_action(self, action: str, guild_id: int, automated: bool): - """Record moderation action metrics.""" - if not self.enabled: - return - - self.moderation_actions_total.labels( - action=action, - guild=str(guild_id), - automated=str(automated).lower() - ).inc() - - def record_automod_trigger(self, filter_type: str, guild_id: int, action: str): - """Record automod trigger metrics.""" - if not self.enabled: - return - - self.automod_triggers_total.labels( - filter_type=filter_type, - guild=str(guild_id), - action=action - ).inc() - - def record_ai_request(self, provider: str, operation: str, status: str, duration: float, confidence: Optional[float] = None): - """Record AI request metrics.""" - if not self.enabled: - return - - self.ai_requests_total.labels( - provider=provider, - operation=operation, - status=status - ).inc() - - self.ai_request_duration.labels( - provider=provider, - operation=operation - ).observe(duration) - - if confidence is not None: - self.ai_confidence_score.labels( - provider=provider, - operation=operation - ).observe(confidence) - - def update_guild_count(self, count: int): - """Update total guild count.""" - if not self.enabled: - return - self.bot_guilds_total.set(count) - - def update_user_count(self, count: int): - """Update total user count.""" - if not self.enabled: - return - self.bot_users_total.set(count) - - def update_database_connections(self, active: int): - """Update active database connections.""" - if not self.enabled: - return - self.database_connections_active.set(active) - - def record_database_query(self, operation: str, duration: float): - """Record database query metrics.""" - if not self.enabled: - return - self.database_query_duration.labels(operation=operation).observe(duration) - - def update_bot_info(self, info: Dict[str, str]): - """Update bot information.""" - if not self.enabled: - return - self.bot_info.info(info) - - def heartbeat(self): - """Record heartbeat timestamp.""" - if not self.enabled: - return - self.last_heartbeat.set(time.time()) - - -# Global metrics instance -_metrics: Optional[GuardDenMetrics] = None - - -def get_metrics() -> GuardDenMetrics: - """Get the global metrics instance.""" - global _metrics - if _metrics is None: - _metrics = GuardDenMetrics() - return _metrics - - -def start_metrics_server(port: int = 8001) -> None: - """Start Prometheus metrics HTTP server.""" - if PROMETHEUS_AVAILABLE: - start_http_server(port) - - -def metrics_middleware(func): - """Decorator to automatically record command metrics.""" - @wraps(func) - async def wrapper(*args, **kwargs): - if not PROMETHEUS_AVAILABLE: - return await func(*args, **kwargs) - - start_time = time.time() - status = "success" - - try: - # Try to extract context information - ctx = None - if args and hasattr(args[0], 'qualified_name'): - # This is likely a command - command_name = args[0].qualified_name - if len(args) > 1 and hasattr(args[1], 'guild'): - ctx = args[1] - else: - command_name = func.__name__ - - result = await func(*args, **kwargs) - return result - except Exception as e: - status = "error" - raise - finally: - duration = time.time() - start_time - guild_id = ctx.guild.id if ctx and ctx.guild else None - - metrics = get_metrics() - metrics.record_command( - command=command_name, - guild_id=guild_id, - status=status, - duration=duration - ) - - return wrapper - - -class MetricsCollector: - """Periodic metrics collector for system stats.""" - - def __init__(self, bot): - self.bot = bot - self.metrics = get_metrics() - - async def collect_bot_metrics(self): - """Collect basic bot metrics.""" - if not PROMETHEUS_AVAILABLE: - return - - # Guild count - guild_count = len(self.bot.guilds) - self.metrics.update_guild_count(guild_count) - - # Total user count across all guilds - total_users = sum(guild.member_count or 0 for guild in self.bot.guilds) - self.metrics.update_user_count(total_users) - - # Database connections if available - if hasattr(self.bot, 'database') and self.bot.database._engine: - try: - pool = self.bot.database._engine.pool - if hasattr(pool, 'checkedout'): - active_connections = pool.checkedout() - self.metrics.update_database_connections(active_connections) - except Exception: - pass # Ignore database connection metrics errors - - # Bot info - self.metrics.update_bot_info({ - 'version': getattr(self.bot, 'version', 'unknown'), - 'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - 'discord_py_version': str(discord.__version__) if 'discord' in globals() else 'unknown', - }) - - # Heartbeat - self.metrics.heartbeat() - - -def setup_metrics(bot, port: int = 8001) -> Optional[MetricsCollector]: - """Set up metrics collection for the bot.""" - if not PROMETHEUS_AVAILABLE: - return None - - try: - start_metrics_server(port) - collector = MetricsCollector(bot) - return collector - except Exception as e: - # Log error but don't fail startup - logger = __import__('logging').getLogger(__name__) - logger.error(f"Failed to start metrics server: {e}") - return None \ No newline at end of file diff --git a/src/guardden/utils/notifications.py b/src/guardden/utils/notifications.py new file mode 100644 index 0000000..9b5ec36 --- /dev/null +++ b/src/guardden/utils/notifications.py @@ -0,0 +1,79 @@ +"""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