This commit is contained in:
2026-01-25 16:46:50 +01:00
parent 97c4bfd285
commit a9cf50986c
60 changed files with 377 additions and 5683 deletions

View File

@@ -2,8 +2,8 @@
GUARDDEN_DISCORD_TOKEN=your_discord_bot_token_here GUARDDEN_DISCORD_TOKEN=your_discord_bot_token_here
GUARDDEN_DISCORD_PREFIX=! GUARDDEN_DISCORD_PREFIX=!
# Optional access control (comma-separated IDs) # Optional access control (comma-separated IDs, no quotes)
# Example: "123456789012345678,987654321098765432" # Example: 123456789012345678,987654321098765432
GUARDDEN_ALLOWED_GUILDS= GUARDDEN_ALLOWED_GUILDS=
GUARDDEN_OWNER_IDS= GUARDDEN_OWNER_IDS=
@@ -24,15 +24,3 @@ GUARDDEN_ANTHROPIC_API_KEY=
# OpenAI API key (required if AI_PROVIDER=openai) # OpenAI API key (required if AI_PROVIDER=openai)
# Get your key at: https://platform.openai.com/api-keys # Get your key at: https://platform.openai.com/api-keys
GUARDDEN_OPENAI_API_KEY= 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=

View File

@@ -65,6 +65,8 @@ docker compose up -d
- Factory pattern via `create_ai_provider(provider, api_key)` - Factory pattern via `create_ai_provider(provider, api_key)`
- `ModerationResult` includes severity scoring based on confidence + category weights - `ModerationResult` includes severity scoring based on confidence + category weights
- Sensitivity setting (0-100) adjusts thresholds per guild - 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 ## Verification System
@@ -82,6 +84,17 @@ docker compose up -d
- `get_rate_limiter()` returns singleton instance - `get_rate_limiter()` returns singleton instance
- Default limits configured for commands, moderation, verification, messages - 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 ## Adding New Cogs
1. Create file in `src/guardden/cogs/` 1. Create file in `src/guardden/cogs/`

View File

@@ -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 <guild_id>
# 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! 🎉**

124
README.md
View File

@@ -19,9 +19,10 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm
### AI Moderation ### AI Moderation
- **Text Analysis** - AI-powered content moderation using Claude or GPT - **Text Analysis** - AI-powered content moderation using Claude or GPT
- **NSFW Image Detection** - Automatic flagging of inappropriate images - **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 - **Phishing Analysis** - AI-enhanced detection of scam URLs
- **Configurable Sensitivity** - Adjust strictness per server (0-100) - **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 ### Verification System
- **Multiple Challenge Types** - Button, captcha, math problems, emoji selection - **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 - Ban/unban events
- All moderation actions - 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 ## Quick Start
### Prerequisites ### Prerequisites
@@ -108,7 +102,6 @@ GuardDen is a comprehensive Discord moderation bot designed to protect your comm
```bash ```bash
docker compose up -d docker compose up -d
``` ```
4. Open the dashboard (if configured): `http://localhost:8080`
### Local Development ### Local Development
@@ -254,13 +247,23 @@ ai_moderation:
enabled: true # Enable AI content analysis enabled: true # Enable AI content analysis
sensitivity: 80 # 0-100 scale (higher = stricter) sensitivity: 80 # 0-100 scale (higher = stricter)
confidence_threshold: 0.7 # 0.0-1.0 confidence required 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 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) - `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:** **Automod Configuration:**
```yaml ```yaml
@@ -310,16 +313,6 @@ Configuration changes are automatically detected and applied without restarting
| `GUARDDEN_AI_PROVIDER` | AI provider (anthropic/openai/none) | `none` | | `GUARDDEN_AI_PROVIDER` | AI provider (anthropic/openai/none) | `none` |
| `GUARDDEN_ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - | | `GUARDDEN_ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - |
| `GUARDDEN_OPENAI_API_KEY` | OpenAI API key (if using GPT) | - | | `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_ENABLED` | Enable managed wordlist sync | `true` |
| `GUARDDEN_WORDLIST_UPDATE_HOURS` | Managed wordlist sync interval | `168` | | `GUARDDEN_WORDLIST_UPDATE_HOURS` | Managed wordlist sync interval | `168` |
| `GUARDDEN_WORDLIST_SOURCES` | JSON array of wordlist sources | (empty = defaults) | | `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 test [type]` | Test a verification challenge |
| `!verify reset @user` | Reset verification for a user | | `!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) ## CI (Gitea Actions)
Workflows live under `.gitea/workflows/` and mirror the previous GitHub Actions Workflows live under `.gitea/workflows/` and mirror the previous GitHub Actions
@@ -481,7 +460,6 @@ guardden/
│ └── templates/ # Configuration templates │ └── templates/ # Configuration templates
├── tests/ # Test suite ├── tests/ # Test suite
├── migrations/ # Database migrations ├── migrations/ # Database migrations
├── dashboard/ # Web dashboard (FastAPI + React)
├── docker-compose.yml # Docker deployment ├── docker-compose.yml # Docker deployment
├── pyproject.toml # Dependencies ├── pyproject.toml # Dependencies
├── README.md # This file ├── README.md # This file
@@ -558,22 +536,64 @@ The AI analyzes content for:
3. Actions are taken based on guild sensitivity settings 3. Actions are taken based on guild sensitivity settings
4. All AI actions are logged to the mod log channel 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.
``` **When enabled (DEFAULT):**
!ai nsfwonly true
```
**When enabled:**
- ✅ **Blocked:** Sexual content, nude images, explicit material - ✅ **Blocked:** Sexual content, nude images, explicit material
- ❌ **Allowed:** Violence, harassment, hate speech, self-harm content - ❌ **Allowed:** Violence, harassment, hate speech, self-harm content
**When disabled (normal mode):** **When disabled (strict mode):**
- ✅ **Blocked:** All inappropriate content categories - ✅ **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-<id>.yml
ai_moderation:
nsfw_only_filtering: false
```
This default is useful for:
- Gaming communities (violence in gaming discussions)
- Mature discussion servers (politics, news)
- Communities with specific content policies that allow violence but prohibit sexual material
### Public In-Channel Warnings (Disabled by Default)
**IMPORTANT PRIVACY NOTICE**: In-channel warnings are **PUBLIC** and visible to all users in the channel, NOT private messages. This is a Discord API limitation.
When enabled and users have DMs disabled, moderation warnings are sent as temporary public messages in the channel where the violation occurred.
**How it works:**
1. Bot tries to DM the user about the violation
2. If DM fails (user has DMs disabled):
- If `send_in_channel_warnings: true`: Sends a **PUBLIC** temporary message in the channel mentioning the user
- If `send_in_channel_warnings: false` (DEFAULT): Silent failure, no notification sent
- Message includes violation reason and any timeout information
- Message auto-deletes after 10 seconds
3. If DM succeeds, no channel message is sent
**To enable in-channel warnings:**
```yaml
# Edit config/guilds/guild-<id>.yml
notifications:
send_in_channel_warnings: true
```
**Considerations:**
**Pros:**
- Users are always notified of moderation actions, even with DMs disabled
- Public transparency about what content is not allowed
- Educational for other members
**Cons:**
- **NOT PRIVATE** - Violation details visible to all users for 10 seconds
- May embarrass users publicly
- Could expose sensitive moderation information
- Privacy-conscious communities may prefer silent failures
## Development ## Development
@@ -598,11 +618,21 @@ mypy src # Type checking
MIT License - see LICENSE file for details. 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 ## Roadmap
- [x] AI-powered content moderation (Claude/OpenAI integration) - [x] AI-powered content moderation (Claude/OpenAI integration)
- [x] NSFW image detection - [x] NSFW image detection
- [x] NSFW-only filtering mode (default)
- [x] Optional public in-channel warnings when DMs disabled
- [x] Verification/captcha system - [x] Verification/captcha system
- [x] Rate limiting - [x] Rate limiting
- [ ] Voice channel moderation - [ ] Voice channel moderation
- [x] Web dashboard - [ ] Slash commands with true ephemeral messages
- [ ] Custom notification templates
- [ ] Advanced analytics dashboard

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GuardDen Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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 (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="analytics" element={<Analytics />} />
<Route path="users" element={<Users />} />
<Route path="chats" element={<Chats />} />
<Route path="moderation" element={<Moderation />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900">GuardDen</h1>
<nav className="ml-10 flex space-x-4">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{item.name}
</Link>
);
})}
</nav>
</div>
<div className="flex items-center space-x-4">
{me?.owner ? (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">
{me.entra ? "✓ Entra" : ""} {me.discord ? "✓ Discord" : ""}
</span>
<a
href="/auth/logout"
className="text-sm text-gray-600 hover:text-gray-900"
>
Logout
</a>
</div>
) : (
<div className="flex space-x-2">
<a href="/auth/entra/login" className="btn-secondary text-sm">
Login with Entra
</a>
<a href="/auth/discord/login" className="btn-primary text-sm">
Connect Discord
</a>
</div>
)}
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{!me?.owner ? (
<div className="card text-center py-12">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Authentication Required
</h2>
<p className="text-gray-600 mb-6">
Please authenticate with both Entra ID and Discord to access the
dashboard.
</p>
<div className="flex justify-center space-x-4">
<a href="/auth/entra/login" className="btn-secondary">
Login with Entra
</a>
<a href="/auth/discord/login" className="btn-primary">
Connect Discord
</a>
</div>
</div>
) : (
<Outlet />
)}
</main>
{/* Footer */}
<footer className="mt-12 border-t border-gray-200 py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500">
© {new Date().getFullYear()} GuardDen. Discord Moderation Bot.
</div>
</footer>
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Analytics</h1>
<p className="text-gray-600 mt-1">Detailed moderation statistics and trends</p>
</div>
<div className="flex space-x-4">
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="input max-w-xs"
>
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
<select
value={selectedGuildId || ''}
onChange={(e) => setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined)}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
</div>
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : moderationStats ? (
<>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="stat-card">
<div className="stat-label">Total Actions</div>
<div className="stat-value">{moderationStats.total_actions}</div>
</div>
<div className="stat-card">
<div className="stat-label">Automatic Actions</div>
<div className="stat-value">{moderationStats.automatic_vs_manual.automatic || 0}</div>
</div>
<div className="stat-card">
<div className="stat-label">Manual Actions</div>
<div className="stat-value">{moderationStats.automatic_vs_manual.manual || 0}</div>
</div>
</div>
{/* Actions Timeline */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Moderation Activity Over Time</h3>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={moderationStats.actions_over_time}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis />
<Tooltip
labelFormatter={(value) => new Date(value as string).toLocaleDateString()}
/>
<Legend />
<Line
type="monotone"
dataKey="value"
stroke="#0ea5e9"
strokeWidth={2}
name="Actions"
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Actions by Type */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Actions by Type</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(moderationStats.actions_by_type).map(([action, count]) => (
<div key={action} className="border border-gray-200 rounded-lg p-4">
<div className="text-sm text-gray-600 capitalize">{action}</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{count}</div>
</div>
))}
</div>
</div>
</>
) : null}
</div>
);
}

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Chats</h1>
<p className="text-gray-600 mt-1">Messages captured by moderation actions</p>
</div>
<select
value={selectedGuildId || ''}
onChange={(e) => {
setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined);
setPage(0);
}}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Search Messages</label>
<input
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(0);
}}
placeholder="Search message content, user, or reason..."
className="input"
/>
</div>
<div>
<label className="label">Action Filter</label>
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(0);
}}
className="input"
>
{ACTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Table */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : logs && logs.items.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Time</th>
{showGuildColumn && (
<th className="text-left py-3 px-4 font-semibold text-gray-700">Guild</th>
)}
<th className="text-left py-3 px-4 font-semibold text-gray-700">User</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Action</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Message</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Reason</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Type</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(log.created_at), 'MMM d, yyyy HH:mm')}
</td>
{showGuildColumn && (
<td className="py-3 px-4 text-sm text-gray-600">{guildName}</td>
)}
<td className="py-3 px-4 font-medium">{log.target_name}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.action === 'ban'
? 'bg-red-100 text-red-800'
: log.action === 'kick'
? 'bg-orange-100 text-orange-800'
: log.action === 'timeout'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{log.action}
</span>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-900 whitespace-pre-wrap">
{log.message_content}
</div>
<div className="text-xs text-gray-500 mt-2">
{log.channel_id ? `Channel ${log.channel_id}` : 'Channel unknown'}
{messageLink ? (
<>
{' '}
·{' '}
<a
href={messageLink}
target="_blank"
rel="noreferrer"
className="text-primary-600 hover:text-primary-700"
>
Open in Discord
</a>
</>
) : null}
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{log.reason || '—'}
</td>
<td className="py-3 px-4">
<span
className={`text-xs ${
log.is_automatic ? 'text-blue-600' : 'text-gray-600'
}`}
>
{log.is_automatic ? 'Auto' : 'Manual'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-6 pt-4 border-t border-gray-200">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 text-gray-600">No chat logs found</div>
)}
</div>
</div>
);
}

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">Overview of your server moderation activity</p>
</div>
<select
value={selectedGuildId || ''}
onChange={(e) => setSelectedGuildId(e.target.value ? Number(e.target.value) : undefined)}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : analytics ? (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="stat-card">
<div className="stat-label">Total Actions</div>
<div className="stat-value">{analytics.moderation_stats.total_actions}</div>
</div>
<div className="stat-card">
<div className="stat-label">Active Users</div>
<div className="stat-value">{analytics.user_activity.active_users}</div>
</div>
<div className="stat-card">
<div className="stat-label">Total Messages</div>
<div className="stat-value">{analytics.user_activity.total_messages.toLocaleString()}</div>
</div>
<div className="stat-card">
<div className="stat-label">AI Checks</div>
<div className="stat-value">{analytics.ai_performance.total_checks}</div>
</div>
</div>
{/* User Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card">
<h3 className="text-lg font-semibold mb-4">New Joins</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Today</span>
<span className="text-2xl font-bold">{analytics.user_activity.new_joins_today}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">This Week</span>
<span className="text-2xl font-bold">{analytics.user_activity.new_joins_week}</span>
</div>
</div>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-4">AI Performance</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Flagged Content</span>
<span className="text-xl font-semibold">{analytics.ai_performance.flagged_content}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Avg Confidence</span>
<span className="text-xl font-semibold">
{(analytics.ai_performance.avg_confidence * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Avg Response Time</span>
<span className="text-xl font-semibold">
{analytics.ai_performance.avg_response_time_ms.toFixed(0)}ms
</span>
</div>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card">
<h3 className="text-lg font-semibold mb-4">Actions by Type</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={actionTypeData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{actionTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-4">Automatic vs Manual</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={automaticVsManualData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="value" fill="#0ea5e9" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Timeline */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Moderation Activity (Last 7 Days)</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={analytics.moderation_stats.actions_over_time}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis />
<Tooltip
labelFormatter={(value) => new Date(value as string).toLocaleDateString()}
/>
<Bar dataKey="value" fill="#0ea5e9" name="Actions" />
</BarChart>
</ResponsiveContainer>
</div>
</>
) : null}
</div>
);
}

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Moderation Logs</h1>
<p className="text-gray-600 mt-1">
View all moderation actions ({logs?.total || 0} total)
</p>
</div>
<select
value={selectedGuildId || ""}
onChange={(e) => {
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
);
setPage(0);
}}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{/* Table */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : logs && logs.items.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Time
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Target
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Action
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Moderator
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Reason
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Type
</th>
</tr>
</thead>
<tbody>
{logs.items.map((log) => (
<tr
key={log.id}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(log.created_at), "MMM d, yyyy HH:mm")}
</td>
<td className="py-3 px-4 font-medium">
{log.target_name}
</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
log.action === "ban"
? "bg-red-100 text-red-800"
: log.action === "kick"
? "bg-orange-100 text-orange-800"
: log.action === "timeout"
? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800"
}`}
>
{log.action}
</span>
</td>
<td className="py-3 px-4 text-sm">
{log.moderator_name}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{log.reason || "—"}
</td>
<td className="py-3 px-4">
<span
className={`text-xs ${
log.is_automatic ? "text-blue-600" : "text-gray-600"
}`}
>
{log.is_automatic ? "Auto" : "Manual"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-between items-center mt-6 pt-4 border-t border-gray-200">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() =>
setPage((p) => Math.min(totalPages - 1, p + 1))
}
disabled={page >= totalPages - 1}
className="btn-secondary disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 text-gray-600">
No moderation logs found
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Servers</h1>
<p className="text-gray-600 mt-1">All servers that have added GuardDen</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="stat-card">
<div className="stat-label">Total Servers</div>
<div className="stat-value">{total}</div>
</div>
<div className="stat-card">
<div className="stat-label">Premium Servers</div>
<div className="stat-value">{premiumCount}</div>
</div>
<div className="stat-card">
<div className="stat-label">Standard Servers</div>
<div className="stat-value">{standardCount}</div>
</div>
</div>
{/* Table */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : guilds && guilds.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Server</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Server ID</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Owner ID</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Plan</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{guilds.map((guild) => (
<tr key={guild.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4 font-medium">{guild.name}</td>
<td className="py-3 px-4 text-sm text-gray-600">{guild.id}</td>
<td className="py-3 px-4 text-sm text-gray-600">{guild.owner_id}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
guild.premium
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-700'
}`}
>
{guild.premium ? 'Premium' : 'Standard'}
</span>
</td>
<td className="py-3 px-4">
<Link
to={`/settings?guild=${guild.id}`}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Configure
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-600">No servers found</div>
)}
</div>
</div>
);
}

View File

@@ -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<number | undefined>(
() => {
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<GuildSettingsType>({
values: settings,
});
const {
register: registerAutomod,
handleSubmit: handleSubmitAutomod,
formState: { isDirty: isAutomodDirty },
} = useForm<AutomodRuleConfig>({
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600 mt-1">
Configure your guild settings and automod rules
</p>
</div>
<select
value={selectedGuildId || ""}
onChange={(e) =>
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
)
}
className="input max-w-xs"
>
<option value="">Select a Guild</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{!selectedGuildId ? (
<div className="card text-center py-12">
<p className="text-gray-600">
Please select a guild to configure settings
</p>
</div>
) : (
<>
{/* General Settings */}
<div className="card">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">General Settings</h2>
<button
type="button"
onClick={handleExport}
className="btn-secondary text-sm"
>
Export Config
</button>
</div>
<form
onSubmit={handleSubmitSettings(onSubmitSettings)}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Command Prefix</label>
<input
{...registerSettings("prefix")}
type="text"
className="input"
placeholder="!"
/>
</div>
<div>
<label className="label">Log Channel ID</label>
<input
{...registerSettings("log_channel_id")}
type="number"
className="input"
placeholder="123456789"
/>
</div>
<div>
<label className="label">Verification Role ID</label>
<input
{...registerSettings("verification_role_id")}
type="number"
className="input"
placeholder="123456789"
/>
</div>
<div>
<label className="label">AI Sensitivity (0-100)</label>
<input
{...registerSettings("ai_sensitivity")}
type="number"
min="0"
max="100"
className="input"
/>
</div>
</div>
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input
{...registerSettings("automod_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Automod</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerSettings("ai_moderation_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable AI Moderation</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerSettings("verification_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Verification</span>
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary"
disabled={
!isSettingsDirty || updateSettingsMutation.isPending
}
>
{updateSettingsMutation.isPending
? "Saving..."
: "Save Settings"}
</button>
</div>
</form>
</div>
{/* Automod Configuration */}
<div className="card">
<h2 className="text-xl font-semibold mb-6">Automod Rules</h2>
<form
onSubmit={handleSubmitAutomod(onSubmitAutomod)}
className="space-y-4"
>
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input
{...registerAutomod("banned_words_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Banned Words Filter</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod("scam_detection_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Scam Detection</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod("spam_detection_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Spam Detection</span>
</label>
<label className="flex items-center space-x-2">
<input
{...registerAutomod("invite_filter_enabled")}
type="checkbox"
className="rounded"
/>
<span>Enable Invite Filter</span>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Max Mentions</label>
<input
{...registerAutomod("max_mentions")}
type="number"
min="1"
max="20"
className="input"
/>
</div>
<div>
<label className="label">Max Emojis</label>
<input
{...registerAutomod("max_emojis")}
type="number"
min="1"
max="50"
className="input"
/>
</div>
<div>
<label className="label">Spam Threshold</label>
<input
{...registerAutomod("spam_threshold")}
type="number"
min="1"
max="20"
className="input"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary"
disabled={!isAutomodDirty || updateAutomodMutation.isPending}
>
{updateAutomodMutation.isPending
? "Saving..."
: "Save Automod Config"}
</button>
</div>
</form>
</div>
</>
)}
</div>
);
}

View File

@@ -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<number | undefined>();
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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-600 mt-1">
Search and manage users across your servers
</p>
</div>
<select
value={selectedGuildId || ""}
onChange={(e) =>
setSelectedGuildId(
e.target.value ? Number(e.target.value) : undefined,
)
}
className="input max-w-xs"
>
<option value="">All Guilds</option>
{guilds?.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
</div>
{/* Search */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Search Users</label>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Enter username..."
className="input"
/>
</div>
<div>
<label className="label">Minimum Strikes</label>
<input
type="number"
min="0"
value={minStrikes}
onChange={(e) => setMinStrikes(e.target.value)}
placeholder="0"
className="input"
/>
</div>
</div>
</div>
{/* Results */}
<div className="card">
{isLoading ? (
<div className="text-center py-12">Loading...</div>
) : users && users.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
{showGuildColumn && (
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Guild
</th>
)}
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Username
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Strikes
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Warnings
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Kicks
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Bans
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
Timeouts
</th>
<th className="text-left py-3 px-4 font-semibold text-gray-700">
First Seen
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={`${user.guild_id}-${user.user_id}`}
className="border-b border-gray-100 hover:bg-gray-50"
>
{showGuildColumn && (
<td className="py-3 px-4 text-sm text-gray-600">
{user.guild_name}
</td>
)}
<td className="py-3 px-4 font-medium">{user.username}</td>
<td className="py-3 px-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
user.strike_count > 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}
</span>
</td>
<td className="py-3 px-4 text-center">
{user.total_warnings}
</td>
<td className="py-3 px-4 text-center">
{user.total_kicks}
</td>
<td className="py-3 px-4 text-center">{user.total_bans}</td>
<td className="py-3 px-4 text-center">
{user.total_timeouts}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{format(new Date(user.first_seen), "MMM d, yyyy")}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-600">
{searchTerm || minStrikes
? "No users found matching your filters"
: "No user activity found"}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
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<T>;
}
// Auth API
export const authApi = {
getMe: () => fetchJson<Me>("/api/me"),
};
// Guilds API
export const guildsApi = {
list: () => fetchJson<Guild[]>("/api/guilds"),
getSettings: (guildId: number) =>
fetchJson<GuildSettings>(`/api/guilds/${guildId}/settings`),
updateSettings: (guildId: number, settings: GuildSettings) =>
fetchJson<GuildSettings>(`/api/guilds/${guildId}/settings`, {
method: "PUT",
body: JSON.stringify(settings),
}),
getAutomodConfig: (guildId: number) =>
fetchJson<AutomodRuleConfig>(`/api/guilds/${guildId}/automod`),
updateAutomodConfig: (guildId: number, config: AutomodRuleConfig) =>
fetchJson<AutomodRuleConfig>(`/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<PaginatedLogs>(`/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<AnalyticsSummary>(`/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<ModerationStats>(
`/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<UserProfile[]>(`/api/users/search?${params}`);
},
getProfile: (userId: number, guildId: number) =>
fetchJson<UserProfile>(`/api/users/${userId}/profile?guild_id=${guildId}`),
getNotes: (userId: number, guildId: number) =>
fetchJson<UserNote[]>(`/api/users/${userId}/notes?guild_id=${guildId}`),
createNote: (userId: number, guildId: number, note: CreateUserNote) =>
fetchJson<UserNote>(`/api/users/${userId}/notes?guild_id=${guildId}`, {
method: "POST",
body: JSON.stringify(note),
}),
deleteNote: (userId: number, noteId: number, guildId: number) =>
fetchJson<void>(
`/api/users/${userId}/notes/${noteId}?guild_id=${guildId}`,
{
method: "DELETE",
},
),
};

View File

@@ -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<string, Set<EventHandler>> = 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();

View File

@@ -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<string, number>;
actions_over_time: TimeSeriesDataPoint[];
automatic_vs_manual: Record<string, number>;
}
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<string, unknown>;
}

View File

@@ -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: [],
}

View File

@@ -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"]
}

View File

@@ -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,
},
});

View File

@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
# Development overrides for docker-compose.yml # Development overrides for docker-compose.yml
# Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up # Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
@@ -28,32 +28,10 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
command: ["python", "-m", "guardden", "--reload"] command: ["python", "-m", "guardden", "--reload"]
ports: ports:
- "5678:5678" # Debugger port - "5678:5678" # Debugger port
stdin_open: true stdin_open: true
tty: 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: db:
environment: environment:
- POSTGRES_PASSWORD=guardden_dev - POSTGRES_PASSWORD=guardden_dev
@@ -78,8 +56,8 @@ services:
container_name: guardden-mailhog container_name: guardden-mailhog
restart: unless-stopped restart: unless-stopped
ports: ports:
- "1025:1025" # SMTP - "1025:1025" # SMTP
- "8025:8025" # Web UI - "8025:8025" # Web UI
networks: networks:
- guardden - guardden

View File

@@ -31,36 +31,6 @@ services:
retries: 3 retries: 3
start_period: 60s 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: db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: guardden-db container_name: guardden-db
@@ -102,27 +72,6 @@ services:
networks: networks:
- guardden - 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: networks:
guardden: guardden:
driver: bridge driver: bridge
@@ -133,4 +82,3 @@ volumes:
redis_data: redis_data:
guardden_data: guardden_data:
guardden_logs: guardden_logs:
prometheus_data:

View File

@@ -18,16 +18,15 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
"""Add nsfw_only_filtering column to guild_settings table.""" """Add nsfw_only_filtering column to guild_settings table."""
op.add_column( op.add_column(
"guild_settings", "guild_settings", sa.Column("nsfw_only_filtering", sa.Boolean, nullable=False, default=True)
sa.Column("nsfw_only_filtering", sa.Boolean, nullable=False, default=False)
) )
# Set default value for existing records # Set default value for existing records
op.execute( op.execute(
sa.text( sa.text(
""" """
UPDATE guild_settings UPDATE guild_settings
SET nsfw_only_filtering = FALSE SET nsfw_only_filtering = TRUE
WHERE nsfw_only_filtering IS NULL WHERE nsfw_only_filtering IS NULL
""" """
) )
@@ -36,4 +35,4 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Remove nsfw_only_filtering column from guild_settings table.""" """Remove nsfw_only_filtering column from guild_settings table."""
op.drop_column("guild_settings", "nsfw_only_filtering") op.drop_column("guild_settings", "nsfw_only_filtering")

View File

@@ -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")

View File

@@ -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://<bot-host>:8001/metrics`
- dashboard: `http://<dashboard-host>:8000/metrics`
- postgres-exporter: `http://<pg-exporter-host>:9187/metrics`
- redis-exporter: `http://<redis-exporter-host>: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
```

View File

@@ -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

View File

@@ -1,11 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
basicAuth: false
jsonData:
timeInterval: 15s

View File

@@ -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

View File

@@ -28,11 +28,7 @@ dependencies = [
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"alembic>=1.13.0", "alembic>=1.13.0",
"sqlalchemy>=2.0.0", "sqlalchemy>=2.0.0",
"fastapi>=0.110.0",
"uvicorn>=0.27.0",
"authlib>=1.3.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"itsdangerous>=2.1.2",
"pyyaml>=6.0", "pyyaml>=6.0",
"jsonschema>=4.20.0", "jsonschema>=4.20.0",
"watchfiles>=0.21.0", "watchfiles>=0.21.0",
@@ -59,15 +55,6 @@ voice = [
"speechrecognition>=3.10.0", "speechrecognition>=3.10.0",
"pydub>=0.25.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] [project.scripts]
guardden = "guardden.__main__:main" guardden = "guardden.__main__:main"

View File

@@ -106,6 +106,13 @@ class Admin(commands.Cog):
inline=False, 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) await ctx.send(embed=embed)
@config.command(name="prefix") @config.command(name="prefix")
@@ -263,6 +270,47 @@ class Admin(commands.Cog):
else: else:
await ctx.send(f"Banned word #{word_id} not found.") 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.command(name="sync")
@commands.is_owner() @commands.is_owner()
async def sync_commands(self, ctx: commands.Context) -> None: async def sync_commands(self, ctx: commands.Context) -> None:

View File

@@ -11,6 +11,7 @@ from guardden.bot import GuardDen
from guardden.models import ModerationLog from guardden.models import ModerationLog
from guardden.services.ai.base import ContentCategory, ModerationResult from guardden.services.ai.base import ContentCategory, ModerationResult
from guardden.services.automod import URL_PATTERN, is_allowed_domain, normalize_domain 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 from guardden.utils.ratelimit import RateLimitExceeded
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -166,22 +167,27 @@ class AIModeration(commands.Cog):
return return
# Notify user # Notify user
try: embed = discord.Embed(
embed = discord.Embed( title=f"Message Flagged in {message.guild.name}",
title=f"Message Flagged in {message.guild.name}", description=result.explanation,
description=result.explanation, color=discord.Color.red(),
color=discord.Color.red(), timestamp=datetime.now(timezone.utc),
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( async def _log_ai_action(
self, self,
@@ -328,7 +334,7 @@ class AIModeration(commands.Cog):
# Filter based on NSFW-only mode setting # Filter based on NSFW-only mode setting
should_flag_image = False should_flag_image = False
categories = [] categories = []
if config.nsfw_only_filtering: if config.nsfw_only_filtering:
# In NSFW-only mode, only flag sexual content # In NSFW-only mode, only flag sexual content
if image_result.is_nsfw: if image_result.is_nsfw:
@@ -346,7 +352,6 @@ class AIModeration(commands.Cog):
should_flag_image = True should_flag_image = True
if should_flag_image: if should_flag_image:
# Use nsfw_severity if available, otherwise use None for default calculation # Use nsfw_severity if available, otherwise use None for default calculation
severity_override = ( severity_override = (
image_result.nsfw_severity if image_result.nsfw_severity > 0 else None 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 # Filter based on NSFW-only mode setting
should_flag_image = False should_flag_image = False
categories = [] categories = []
if config.nsfw_only_filtering: if config.nsfw_only_filtering:
# In NSFW-only mode, only flag sexual content # In NSFW-only mode, only flag sexual content
if image_result.is_nsfw: if image_result.is_nsfw:
@@ -414,7 +419,6 @@ class AIModeration(commands.Cog):
should_flag_image = True should_flag_image = True
if should_flag_image: if should_flag_image:
# Use nsfw_severity if available, otherwise use None for default calculation # Use nsfw_severity if available, otherwise use None for default calculation
severity_override = ( severity_override = (
image_result.nsfw_severity if image_result.nsfw_severity > 0 else None image_result.nsfw_severity if image_result.nsfw_severity > 0 else None
@@ -578,18 +582,18 @@ class AIModeration(commands.Cog):
@commands.guild_only() @commands.guild_only()
async def ai_nsfw_only(self, ctx: commands.Context, enabled: bool) -> None: async def ai_nsfw_only(self, ctx: commands.Context, enabled: bool) -> None:
"""Enable or disable NSFW-only filtering mode. """Enable or disable NSFW-only filtering mode.
When enabled, only sexual/nude content will be filtered. When enabled, only sexual/nude content will be filtered.
Violence, harassment, and other content types will be allowed. Violence, harassment, and other content types will be allowed.
""" """
await self.bot.guild_config.update_settings(ctx.guild.id, nsfw_only_filtering=enabled) await self.bot.guild_config.update_settings(ctx.guild.id, nsfw_only_filtering=enabled)
status = "enabled" if enabled else "disabled" status = "enabled" if enabled else "disabled"
if enabled: if enabled:
embed = discord.Embed( embed = discord.Embed(
title="NSFW-Only Mode Enabled", title="NSFW-Only Mode Enabled",
description="⚠️ **Important:** Only sexual and nude content will now be filtered.\n" 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(), color=discord.Color.orange(),
) )
embed.add_field( embed.add_field(
@@ -607,10 +611,10 @@ class AIModeration(commands.Cog):
embed = discord.Embed( embed = discord.Embed(
title="NSFW-Only Mode Disabled", title="NSFW-Only Mode Disabled",
description="✅ Normal content filtering restored.\n" 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(), color=discord.Color.green(),
) )
await ctx.send(embed=embed) await ctx.send(embed=embed)
@ai_cmd.command(name="analyze") @ai_cmd.command(name="analyze")

View File

@@ -16,6 +16,7 @@ from guardden.services.automod import (
SpamConfig, SpamConfig,
normalize_domain, normalize_domain,
) )
from guardden.utils.notifications import send_moderation_notification
from guardden.utils.ratelimit import RateLimitExceeded from guardden.utils.ratelimit import RateLimitExceeded
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -187,27 +188,35 @@ class Automod(commands.Cog):
await self._log_automod_action(message, result) await self._log_automod_action(message, result)
# Apply strike escalation if configured # 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) total = await self._add_strike(message.guild, message.author, result.reason)
config = await self.bot.guild_config.get_config(message.guild.id) config = await self.bot.guild_config.get_config(message.guild.id)
await self._apply_strike_actions(message.author, total, config) await self._apply_strike_actions(message.author, total, config)
# Notify the user via DM # Notify the user
try: config = await self.bot.guild_config.get_config(message.guild.id)
embed = discord.Embed( embed = discord.Embed(
title=f"Message Removed in {message.guild.name}", title=f"Message Removed in {message.guild.name}",
description=result.reason, description=result.reason,
color=discord.Color.orange(), color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc), 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( async def _log_automod_action(
self, self,
@@ -472,7 +481,9 @@ class Automod(commands.Cog):
results.append(f"**Banned Words**: {result.reason}") results.append(f"**Banned Words**: {result.reason}")
# Check scam links # 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: if result:
results.append(f"**Scam Detection**: {result.reason}") results.append(f"**Scam Detection**: {result.reason}")

View File

@@ -10,6 +10,7 @@ from sqlalchemy import func, select
from guardden.bot import GuardDen from guardden.bot import GuardDen
from guardden.models import ModerationLog, Strike from guardden.models import ModerationLog, Strike
from guardden.utils import parse_duration from guardden.utils import parse_duration
from guardden.utils.notifications import send_moderation_notification
from guardden.utils.ratelimit import RateLimitExceeded from guardden.utils.ratelimit import RateLimitExceeded
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -140,17 +141,23 @@ class Moderation(commands.Cog):
await ctx.send(embed=embed) await ctx.send(embed=embed)
# Try to DM the user # Notify the user
try: config = await self.bot.guild_config.get_config(ctx.guild.id)
dm_embed = discord.Embed( dm_embed = discord.Embed(
title=f"Warning in {ctx.guild.name}", title=f"Warning in {ctx.guild.name}",
description=f"You have been warned.", description=f"You have been warned.",
color=discord.Color.yellow(), 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.command(name="strike")
@commands.has_permissions(kick_members=True) @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.") await ctx.send("You cannot kick someone with a higher or equal role.")
return return
# Try to DM the user before kicking # Notify the user before kicking
try: config = await self.bot.guild_config.get_config(ctx.guild.id)
dm_embed = discord.Embed( dm_embed = discord.Embed(
title=f"Kicked from {ctx.guild.name}", title=f"Kicked from {ctx.guild.name}",
description=f"You have been kicked from the server.", description=f"You have been kicked from the server.",
color=discord.Color.red(), 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: try:
await member.kick(reason=f"{ctx.author}: {reason}") await member.kick(reason=f"{ctx.author}: {reason}")
@@ -348,7 +361,7 @@ class Moderation(commands.Cog):
except discord.HTTPException as e: except discord.HTTPException as e:
await ctx.send(f"❌ Failed to kick member: {e}") await ctx.send(f"❌ Failed to kick member: {e}")
return return
await self._log_action(ctx.guild, member, ctx.author, "kick", reason) await self._log_action(ctx.guild, member, ctx.author, "kick", reason)
embed = discord.Embed( embed = discord.Embed(
@@ -381,17 +394,23 @@ class Moderation(commands.Cog):
await ctx.send("You cannot ban someone with a higher or equal role.") await ctx.send("You cannot ban someone with a higher or equal role.")
return return
# Try to DM the user before banning # Notify the user before banning
try: config = await self.bot.guild_config.get_config(ctx.guild.id)
dm_embed = discord.Embed( dm_embed = discord.Embed(
title=f"Banned from {ctx.guild.name}", title=f"Banned from {ctx.guild.name}",
description=f"You have been banned from the server.", description=f"You have been banned from the server.",
color=discord.Color.dark_red(), 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: try:
await ctx.guild.ban(member, reason=f"{ctx.author}: {reason}", delete_message_days=0) 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: except discord.HTTPException as e:
await ctx.send(f"❌ Failed to ban member: {e}") await ctx.send(f"❌ Failed to ban member: {e}")
return return
await self._log_action(ctx.guild, member, ctx.author, "ban", reason) await self._log_action(ctx.guild, member, ctx.author, "ban", reason)
embed = discord.Embed( embed = discord.Embed(

View File

@@ -1 +0,0 @@
"""Dashboard application package."""

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -97,7 +97,10 @@ class GuildSettings(Base, TimestampMixin):
ai_confidence_threshold: Mapped[float] = mapped_column(Float, default=0.7, nullable=False) ai_confidence_threshold: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
ai_log_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) ai_log_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
nsfw_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=True, 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 settings
verification_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) verification_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -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

View File

@@ -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