update
This commit is contained in:
16
.env.example
16
.env.example
@@ -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=
|
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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/`
|
||||||
|
|||||||
371
MIGRATION.md
371
MIGRATION.md
@@ -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
124
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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: [],
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ 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
|
||||||
@@ -27,7 +26,7 @@ def upgrade() -> None:
|
|||||||
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
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|||||||
39
migrations/versions/20260125_add_in_channel_warnings.py
Normal file
39
migrations/versions/20260125_add_in_channel_warnings.py
Normal 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")
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: 1
|
|
||||||
|
|
||||||
datasources:
|
|
||||||
- name: Prometheus
|
|
||||||
type: prometheus
|
|
||||||
access: proxy
|
|
||||||
url: http://prometheus:9090
|
|
||||||
isDefault: true
|
|
||||||
basicAuth: false
|
|
||||||
jsonData:
|
|
||||||
timeInterval: 15s
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -589,7 +593,7 @@ class AIModeration(commands.Cog):
|
|||||||
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,7 +611,7 @@ 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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Dashboard application package."""
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
79
src/guardden/utils/notifications.py
Normal file
79
src/guardden/utils/notifications.py
Normal 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
|
||||||
Reference in New Issue
Block a user