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

View File

@@ -106,6 +106,13 @@ class Admin(commands.Cog):
inline=False,
)
# Notification settings
embed.add_field(
name="In-Channel Warnings",
value="✅ Enabled" if config.send_in_channel_warnings else "❌ Disabled",
inline=True,
)
await ctx.send(embed=embed)
@config.command(name="prefix")
@@ -263,6 +270,47 @@ class Admin(commands.Cog):
else:
await ctx.send(f"Banned word #{word_id} not found.")
@commands.command(name="channelwarnings")
@commands.guild_only()
async def channel_warnings(self, ctx: commands.Context, enabled: bool) -> None:
"""Enable or disable PUBLIC in-channel warnings when DMs fail.
WARNING: In-channel messages are PUBLIC and visible to all users in the channel.
They are NOT private due to Discord API limitations.
When enabled, if a user has DMs disabled, moderation warnings will be sent
as temporary PUBLIC messages in the channel (auto-deleted after 10 seconds).
Args:
enabled: True to enable PUBLIC warnings, False to disable (default: False)
"""
await self.bot.guild_config.update_settings(ctx.guild.id, send_in_channel_warnings=enabled)
status = "enabled" if enabled else "disabled"
embed = discord.Embed(
title="In-Channel Warnings Updated",
description=f"In-channel warnings are now **{status}**.",
color=discord.Color.green() if enabled else discord.Color.orange(),
)
if enabled:
embed.add_field(
name="⚠️ Privacy Warning",
value="**Messages are PUBLIC and visible to ALL users in the channel.**\n"
"When a user has DMs disabled, moderation warnings will be sent "
"as temporary PUBLIC messages in the channel (auto-deleted after 10 seconds).",
inline=False,
)
else:
embed.add_field(
name="✅ Privacy Protected",
value="When users have DMs disabled, they will not receive any notification. "
"This protects user privacy and prevents public embarrassment.",
inline=False,
)
await ctx.send(embed=embed)
@commands.command(name="sync")
@commands.is_owner()
async def sync_commands(self, ctx: commands.Context) -> None:

View File

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

View File

@@ -16,6 +16,7 @@ from guardden.services.automod import (
SpamConfig,
normalize_domain,
)
from guardden.utils.notifications import send_moderation_notification
from guardden.utils.ratelimit import RateLimitExceeded
logger = logging.getLogger(__name__)
@@ -187,27 +188,35 @@ class Automod(commands.Cog):
await self._log_automod_action(message, result)
# Apply strike escalation if configured
if (result.should_warn or result.should_strike) and isinstance(message.author, discord.Member):
if (result.should_warn or result.should_strike) and isinstance(
message.author, discord.Member
):
total = await self._add_strike(message.guild, message.author, result.reason)
config = await self.bot.guild_config.get_config(message.guild.id)
await self._apply_strike_actions(message.author, total, config)
# Notify the user via DM
try:
embed = discord.Embed(
title=f"Message Removed in {message.guild.name}",
description=result.reason,
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
# Notify the user
config = await self.bot.guild_config.get_config(message.guild.id)
embed = discord.Embed(
title=f"Message Removed in {message.guild.name}",
description=result.reason,
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
if result.should_timeout:
embed.add_field(
name="Timeout",
value=f"You have been timed out for {result.timeout_duration} seconds.",
)
# Use notification utility to send DM with in-channel fallback
if isinstance(message.channel, discord.TextChannel):
await send_moderation_notification(
user=message.author,
channel=message.channel,
embed=embed,
send_in_channel=config.send_in_channel_warnings if config else False,
)
if result.should_timeout:
embed.add_field(
name="Timeout",
value=f"You have been timed out for {result.timeout_duration} seconds.",
)
await message.author.send(embed=embed)
except discord.Forbidden:
pass # User has DMs disabled
async def _log_automod_action(
self,
@@ -472,7 +481,9 @@ class Automod(commands.Cog):
results.append(f"**Banned Words**: {result.reason}")
# Check scam links
result = self.automod.check_scam_links(text, allowlist=config.scam_allowlist if config else [])
result = self.automod.check_scam_links(
text, allowlist=config.scam_allowlist if config else []
)
if result:
results.append(f"**Scam Detection**: {result.reason}")

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
"""Dashboard entrypoint for `python -m guardden.dashboard`."""
import os
import uvicorn
def main() -> None:
host = os.getenv("GUARDDEN_DASHBOARD_HOST", "0.0.0.0")
port = int(os.getenv("GUARDDEN_DASHBOARD_PORT", "8000"))
log_level = os.getenv("GUARDDEN_LOG_LEVEL", "info").lower()
uvicorn.run("guardden.dashboard.main:app", host=host, port=port, log_level=log_level)
if __name__ == "__main__":
main()

View File

@@ -1,267 +0,0 @@
"""Analytics API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import (
AIPerformanceStats,
AnalyticsSummary,
ModerationStats,
TimeSeriesDataPoint,
UserActivityStats,
)
from guardden.models import AICheck, MessageActivity, ModerationLog, UserActivity
def create_analytics_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the analytics API router."""
router = APIRouter(prefix="/api/analytics")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/summary",
response_model=AnalyticsSummary,
dependencies=[Depends(require_owner_dep)],
)
async def analytics_summary(
guild_id: int | None = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> AnalyticsSummary:
"""Get analytics summary for the specified time period."""
start_date = datetime.now() - timedelta(days=days)
# Moderation stats
mod_query = select(ModerationLog).where(ModerationLog.created_at >= start_date)
if guild_id:
mod_query = mod_query.where(ModerationLog.guild_id == guild_id)
mod_result = await session.execute(mod_query)
mod_logs = mod_result.scalars().all()
total_actions = len(mod_logs)
actions_by_type: dict[str, int] = {}
automatic_count = 0
manual_count = 0
for log in mod_logs:
actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1
if log.is_automatic:
automatic_count += 1
else:
manual_count += 1
# Time series data (group by day)
time_series: dict[str, int] = {}
for log in mod_logs:
day_key = log.created_at.strftime("%Y-%m-%d")
time_series[day_key] = time_series.get(day_key, 0) + 1
actions_over_time = [
TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count)
for day, count in sorted(time_series.items())
]
moderation_stats = ModerationStats(
total_actions=total_actions,
actions_by_type=actions_by_type,
actions_over_time=actions_over_time,
automatic_vs_manual={"automatic": automatic_count, "manual": manual_count},
)
# User activity stats
activity_query = select(MessageActivity).where(MessageActivity.date >= start_date)
if guild_id:
activity_query = activity_query.where(MessageActivity.guild_id == guild_id)
activity_result = await session.execute(activity_query)
activities = activity_result.scalars().all()
total_messages = sum(a.total_messages for a in activities)
active_users = max((a.active_users for a in activities), default=0)
# New joins
today = datetime.now().date()
week_ago = today - timedelta(days=7)
new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today)
new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago)
user_activity = UserActivityStats(
active_users=active_users,
total_messages=total_messages,
new_joins_today=new_joins_today,
new_joins_week=new_joins_week,
)
# AI performance stats
ai_query = select(AICheck).where(AICheck.created_at >= start_date)
if guild_id:
ai_query = ai_query.where(AICheck.guild_id == guild_id)
ai_result = await session.execute(ai_query)
ai_checks = ai_result.scalars().all()
total_checks = len(ai_checks)
flagged_content = sum(1 for c in ai_checks if c.flagged)
avg_confidence = (
sum(c.confidence for c in ai_checks) / total_checks if total_checks > 0 else 0.0
)
false_positives = sum(1 for c in ai_checks if c.is_false_positive)
avg_response_time = (
sum(c.response_time_ms for c in ai_checks) / total_checks if total_checks > 0 else 0.0
)
ai_performance = AIPerformanceStats(
total_checks=total_checks,
flagged_content=flagged_content,
avg_confidence=avg_confidence,
false_positives=false_positives,
avg_response_time_ms=avg_response_time,
)
return AnalyticsSummary(
moderation_stats=moderation_stats,
user_activity=user_activity,
ai_performance=ai_performance,
)
@router.get(
"/moderation-stats",
response_model=ModerationStats,
dependencies=[Depends(require_owner_dep)],
)
async def moderation_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=30, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> ModerationStats:
"""Get detailed moderation statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(ModerationLog).where(ModerationLog.created_at >= start_date)
if guild_id:
query = query.where(ModerationLog.guild_id == guild_id)
result = await session.execute(query)
logs = result.scalars().all()
total_actions = len(logs)
actions_by_type: dict[str, int] = {}
automatic_count = 0
manual_count = 0
for log in logs:
actions_by_type[log.action] = actions_by_type.get(log.action, 0) + 1
if log.is_automatic:
automatic_count += 1
else:
manual_count += 1
# Time series data
time_series: dict[str, int] = {}
for log in logs:
day_key = log.created_at.strftime("%Y-%m-%d")
time_series[day_key] = time_series.get(day_key, 0) + 1
actions_over_time = [
TimeSeriesDataPoint(timestamp=datetime.strptime(day, "%Y-%m-%d"), value=count)
for day, count in sorted(time_series.items())
]
return ModerationStats(
total_actions=total_actions,
actions_by_type=actions_by_type,
actions_over_time=actions_over_time,
automatic_vs_manual={"automatic": automatic_count, "manual": manual_count},
)
@router.get(
"/user-activity",
response_model=UserActivityStats,
dependencies=[Depends(require_owner_dep)],
)
async def user_activity_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=7, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> UserActivityStats:
"""Get user activity statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(MessageActivity).where(MessageActivity.date >= start_date)
if guild_id:
query = query.where(MessageActivity.guild_id == guild_id)
result = await session.execute(query)
activities = result.scalars().all()
total_messages = sum(a.total_messages for a in activities)
active_users = max((a.active_users for a in activities), default=0)
today = datetime.now().date()
week_ago = today - timedelta(days=7)
new_joins_today = sum(a.new_joins for a in activities if a.date.date() == today)
new_joins_week = sum(a.new_joins for a in activities if a.date.date() >= week_ago)
return UserActivityStats(
active_users=active_users,
total_messages=total_messages,
new_joins_today=new_joins_today,
new_joins_week=new_joins_week,
)
@router.get(
"/ai-performance",
response_model=AIPerformanceStats,
dependencies=[Depends(require_owner_dep)],
)
async def ai_performance_stats(
guild_id: int | None = Query(default=None),
days: int = Query(default=30, ge=1, le=90),
session: AsyncSession = Depends(get_session),
) -> AIPerformanceStats:
"""Get AI moderation performance statistics."""
start_date = datetime.now() - timedelta(days=days)
query = select(AICheck).where(AICheck.created_at >= start_date)
if guild_id:
query = query.where(AICheck.guild_id == guild_id)
result = await session.execute(query)
checks = result.scalars().all()
total_checks = len(checks)
flagged_content = sum(1 for c in checks if c.flagged)
avg_confidence = (
sum(c.confidence for c in checks) / total_checks if total_checks > 0 else 0.0
)
false_positives = sum(1 for c in checks if c.is_false_positive)
avg_response_time = (
sum(c.response_time_ms for c in checks) / total_checks if total_checks > 0 else 0.0
)
return AIPerformanceStats(
total_checks=total_checks,
flagged_content=flagged_content,
avg_confidence=avg_confidence,
false_positives=false_positives,
avg_response_time_ms=avg_response_time,
)
return router

View File

@@ -1,78 +0,0 @@
"""Authentication helpers for the dashboard."""
from typing import Any
from urllib.parse import urlencode
import httpx
from authlib.integrations.starlette_client import OAuth
from fastapi import HTTPException, Request, status
from guardden.dashboard.config import DashboardSettings
def build_oauth(settings: DashboardSettings) -> OAuth:
"""Build OAuth client registrations."""
oauth = OAuth()
oauth.register(
name="entra",
client_id=settings.entra_client_id,
client_secret=settings.entra_client_secret.get_secret_value(),
server_metadata_url=(
"https://login.microsoftonline.com/"
f"{settings.entra_tenant_id}/v2.0/.well-known/openid-configuration"
),
client_kwargs={"scope": "openid profile email"},
)
return oauth
def discord_authorize_url(settings: DashboardSettings, state: str) -> str:
"""Generate the Discord OAuth authorization URL."""
query = urlencode(
{
"client_id": settings.discord_client_id,
"redirect_uri": settings.callback_url("discord"),
"response_type": "code",
"scope": "identify",
"state": state,
}
)
return f"https://discord.com/oauth2/authorize?{query}"
async def exchange_discord_code(settings: DashboardSettings, code: str) -> dict[str, Any]:
"""Exchange a Discord OAuth code for a user profile."""
async with httpx.AsyncClient(timeout=10.0) as client:
token_response = await client.post(
"https://discord.com/api/oauth2/token",
data={
"client_id": settings.discord_client_id,
"client_secret": settings.discord_client_secret.get_secret_value(),
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.callback_url("discord"),
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
user_response = await client.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {token_data['access_token']}"},
)
user_response.raise_for_status()
return user_response.json()
def require_owner(settings: DashboardSettings, request: Request) -> None:
"""Ensure the current session is the configured owner."""
session = request.session
entra_oid = session.get("entra_oid")
discord_id = session.get("discord_id")
if not entra_oid or not discord_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
if str(entra_oid) != settings.owner_entra_object_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
if int(discord_id) != settings.owner_discord_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")

View File

@@ -1,68 +0,0 @@
"""Configuration for the GuardDen dashboard."""
from pathlib import Path
from typing import Any
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class DashboardSettings(BaseSettings):
"""Dashboard settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
env_prefix="GUARDDEN_DASHBOARD_",
)
database_url: SecretStr = Field(
validation_alias="GUARDDEN_DATABASE_URL",
description="Database connection URL",
)
base_url: str = Field(
default="http://localhost:8080",
description="Base URL for OAuth callbacks",
)
secret_key: SecretStr = Field(
default=SecretStr("change-me"),
description="Session secret key",
)
entra_tenant_id: str = Field(description="Entra ID tenant ID")
entra_client_id: str = Field(description="Entra ID application client ID")
entra_client_secret: SecretStr = Field(description="Entra ID application client secret")
discord_client_id: str = Field(description="Discord OAuth client ID")
discord_client_secret: SecretStr = Field(description="Discord OAuth client secret")
owner_discord_id: int = Field(description="Discord user ID allowed to access dashboard")
owner_entra_object_id: str = Field(description="Entra ID object ID allowed to access")
cors_origins: list[str] = Field(default_factory=list, description="Allowed CORS origins")
static_dir: Path = Field(
default=Path("dashboard/frontend/dist"),
description="Directory containing built frontend assets",
)
@field_validator("cors_origins", mode="before")
@classmethod
def _parse_origins(cls, value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
if not text:
return []
return [item.strip() for item in text.split(",") if item.strip()]
def callback_url(self, provider: str) -> str:
return f"{self.base_url}/auth/{provider}/callback"
def get_dashboard_settings() -> DashboardSettings:
"""Load dashboard settings from environment."""
return DashboardSettings()

View File

@@ -1,298 +0,0 @@
"""Configuration management API routes for the GuardDen dashboard."""
import json
from collections.abc import AsyncIterator
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import AutomodRuleConfig, ConfigExport, GuildSettings
from guardden.models import Guild
from guardden.models import GuildSettings as GuildSettingsModel
def create_config_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the configuration management API router."""
router = APIRouter(prefix="/api/guilds")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/{guild_id}/settings",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def get_guild_settings(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Get guild settings."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3, # Default value, could be derived from strike_actions
)
@router.put(
"/{guild_id}/settings",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def update_guild_settings(
guild_id: int = Path(...),
settings_data: GuildSettings = ...,
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Update guild settings."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Update settings
if settings_data.prefix is not None:
guild_settings.prefix = settings_data.prefix
if settings_data.log_channel_id is not None:
guild_settings.log_channel_id = settings_data.log_channel_id
guild_settings.automod_enabled = settings_data.automod_enabled
guild_settings.ai_moderation_enabled = settings_data.ai_moderation_enabled
guild_settings.ai_sensitivity = settings_data.ai_sensitivity
guild_settings.verification_enabled = settings_data.verification_enabled
if settings_data.verification_role_id is not None:
guild_settings.verified_role_id = settings_data.verification_role_id
await session.commit()
await session.refresh(guild_settings)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
)
@router.get(
"/{guild_id}/automod",
response_model=AutomodRuleConfig,
dependencies=[Depends(require_owner_dep)],
)
async def get_automod_config(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> AutomodRuleConfig:
"""Get automod rule configuration."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
return AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=True, # Derived from automod_enabled
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10, # Default value
spam_threshold=guild_settings.message_rate_limit,
)
@router.put(
"/{guild_id}/automod",
response_model=AutomodRuleConfig,
dependencies=[Depends(require_owner_dep)],
)
async def update_automod_config(
guild_id: int = Path(...),
automod_data: AutomodRuleConfig = ...,
session: AsyncSession = Depends(get_session),
) -> AutomodRuleConfig:
"""Update automod rule configuration."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Update automod settings
guild_settings.automod_enabled = automod_data.scam_detection_enabled
guild_settings.anti_spam_enabled = automod_data.spam_detection_enabled
guild_settings.link_filter_enabled = automod_data.invite_filter_enabled
guild_settings.mention_limit = automod_data.max_mentions
guild_settings.message_rate_limit = automod_data.spam_threshold
await session.commit()
await session.refresh(guild_settings)
return AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=automod_data.banned_words_enabled,
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10,
spam_threshold=guild_settings.message_rate_limit,
)
@router.get(
"/{guild_id}/export",
dependencies=[Depends(require_owner_dep)],
)
async def export_config(
guild_id: int = Path(...),
session: AsyncSession = Depends(get_session),
) -> StreamingResponse:
"""Export guild configuration as JSON."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Build export data
export_data = ConfigExport(
version="1.0",
guild_settings=GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
),
automod_rules=AutomodRuleConfig(
guild_id=guild_settings.guild_id,
banned_words_enabled=True,
scam_detection_enabled=guild_settings.automod_enabled,
spam_detection_enabled=guild_settings.anti_spam_enabled,
invite_filter_enabled=guild_settings.link_filter_enabled,
max_mentions=guild_settings.mention_limit,
max_emojis=10,
spam_threshold=guild_settings.message_rate_limit,
),
exported_at=datetime.now(),
)
# Convert to JSON
json_data = export_data.model_dump_json(indent=2)
return StreamingResponse(
iter([json_data]),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename=guild_{guild_id}_config.json"},
)
@router.post(
"/{guild_id}/import",
response_model=GuildSettings,
dependencies=[Depends(require_owner_dep)],
)
async def import_config(
guild_id: int = Path(...),
config_data: ConfigExport = ...,
session: AsyncSession = Depends(get_session),
) -> GuildSettings:
"""Import guild configuration from JSON."""
query = select(GuildSettingsModel).where(GuildSettingsModel.guild_id == guild_id)
result = await session.execute(query)
guild_settings = result.scalar_one_or_none()
if not guild_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Guild settings not found",
)
# Import settings
settings = config_data.guild_settings
if settings.prefix is not None:
guild_settings.prefix = settings.prefix
if settings.log_channel_id is not None:
guild_settings.log_channel_id = settings.log_channel_id
guild_settings.automod_enabled = settings.automod_enabled
guild_settings.ai_moderation_enabled = settings.ai_moderation_enabled
guild_settings.ai_sensitivity = settings.ai_sensitivity
guild_settings.verification_enabled = settings.verification_enabled
if settings.verification_role_id is not None:
guild_settings.verified_role_id = settings.verification_role_id
# Import automod rules
automod = config_data.automod_rules
guild_settings.anti_spam_enabled = automod.spam_detection_enabled
guild_settings.link_filter_enabled = automod.invite_filter_enabled
guild_settings.mention_limit = automod.max_mentions
guild_settings.message_rate_limit = automod.spam_threshold
await session.commit()
await session.refresh(guild_settings)
return GuildSettings(
guild_id=guild_settings.guild_id,
prefix=guild_settings.prefix,
log_channel_id=guild_settings.log_channel_id,
automod_enabled=guild_settings.automod_enabled,
ai_moderation_enabled=guild_settings.ai_moderation_enabled,
ai_sensitivity=guild_settings.ai_sensitivity,
verification_enabled=guild_settings.verification_enabled,
verification_role_id=guild_settings.verified_role_id,
max_warns_before_action=3,
)
return router

View File

@@ -1,24 +0,0 @@
"""Database helpers for the dashboard."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from guardden.dashboard.config import DashboardSettings
class DashboardDatabase:
"""Async database session factory for the dashboard."""
def __init__(self, settings: DashboardSettings) -> None:
db_url = settings.database_url.get_secret_value()
if db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
self._engine = create_async_engine(db_url, pool_pre_ping=True)
self._sessionmaker = async_sessionmaker(self._engine, expire_on_commit=False)
async def session(self) -> AsyncIterator[AsyncSession]:
"""Yield a database session."""
async with self._sessionmaker() as session:
yield session

View File

@@ -1,121 +0,0 @@
"""FastAPI app for the GuardDen dashboard."""
import logging
import secrets
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from starlette.staticfiles import StaticFiles
from guardden.dashboard.analytics import create_analytics_router
from guardden.dashboard.auth import (
build_oauth,
discord_authorize_url,
exchange_discord_code,
require_owner,
)
from guardden.dashboard.config import DashboardSettings, get_dashboard_settings
from guardden.dashboard.config_management import create_config_router
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.routes import create_api_router
from guardden.dashboard.users import create_users_router
from guardden.dashboard.websocket import create_websocket_router
logger = logging.getLogger(__name__)
def create_app() -> FastAPI:
settings = get_dashboard_settings()
database = DashboardDatabase(settings)
oauth = build_oauth(settings)
app = FastAPI(title="GuardDen Dashboard")
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key.get_secret_value())
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/me")
async def me(request: Request) -> dict[str, bool | str | None]:
entra_oid = request.session.get("entra_oid")
discord_id = request.session.get("discord_id")
owner = str(entra_oid) == settings.owner_entra_object_id and str(discord_id) == str(
settings.owner_discord_id
)
return {
"entra": bool(entra_oid),
"discord": bool(discord_id),
"owner": owner,
"entra_oid": entra_oid,
"discord_id": discord_id,
}
@app.get("/auth/entra/login")
async def entra_login(request: Request) -> RedirectResponse:
redirect_uri = settings.callback_url("entra")
return await oauth.entra.authorize_redirect(request, redirect_uri)
@app.get("/auth/entra/callback")
async def entra_callback(request: Request) -> RedirectResponse:
token = await oauth.entra.authorize_access_token(request)
user = await oauth.entra.parse_id_token(request, token)
request.session["entra_oid"] = user.get("oid")
return RedirectResponse(url="/")
@app.get("/auth/discord/login")
async def discord_login(request: Request) -> RedirectResponse:
state = secrets.token_urlsafe(16)
request.session["discord_state"] = state
return RedirectResponse(url=discord_authorize_url(settings, state))
@app.get("/auth/discord/callback")
async def discord_callback(request: Request) -> RedirectResponse:
params = dict(request.query_params)
code = params.get("code")
state = params.get("state")
if not code or not state:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code")
if state != request.session.get("discord_state"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid state")
profile = await exchange_discord_code(settings, code)
request.session["discord_id"] = profile.get("id")
return RedirectResponse(url="/")
@app.get("/auth/logout")
async def logout(request: Request) -> RedirectResponse:
request.session.clear()
return RedirectResponse(url="/")
# Include all API routers
app.include_router(create_api_router(settings, database))
app.include_router(create_analytics_router(settings, database))
app.include_router(create_users_router(settings, database))
app.include_router(create_config_router(settings, database))
app.include_router(create_websocket_router(settings))
static_dir = Path(settings.static_dir)
if static_dir.exists():
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
else:
logger.warning("Static directory not found: %s", static_dir)
return app
app = create_app()

View File

@@ -1,111 +0,0 @@
"""API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import GuildSummary, ModerationLogEntry, PaginatedLogs
from guardden.models import Guild, ModerationLog
def create_api_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the dashboard API router."""
router = APIRouter(prefix="/api")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/guilds", response_model=list[GuildSummary], dependencies=[Depends(require_owner_dep)]
)
async def list_guilds(
session: AsyncSession = Depends(get_session),
) -> list[GuildSummary]:
result = await session.execute(select(Guild).order_by(Guild.name.asc()))
guilds = result.scalars().all()
return [
GuildSummary(id=g.id, name=g.name, owner_id=g.owner_id, premium=g.premium)
for g in guilds
]
@router.get(
"/moderation/logs",
response_model=PaginatedLogs,
dependencies=[Depends(require_owner_dep)],
)
async def list_moderation_logs(
guild_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
action: str | None = Query(default=None),
message_only: bool = Query(default=False),
search: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
) -> PaginatedLogs:
query = select(ModerationLog)
count_query = select(func.count(ModerationLog.id))
if guild_id:
query = query.where(ModerationLog.guild_id == guild_id)
count_query = count_query.where(ModerationLog.guild_id == guild_id)
if action:
query = query.where(ModerationLog.action == action)
count_query = count_query.where(ModerationLog.action == action)
if message_only:
query = query.where(ModerationLog.message_content.is_not(None))
count_query = count_query.where(ModerationLog.message_content.is_not(None))
if search:
like = f"%{search}%"
search_filter = or_(
ModerationLog.target_name.ilike(like),
ModerationLog.moderator_name.ilike(like),
ModerationLog.reason.ilike(like),
ModerationLog.message_content.ilike(like),
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
query = query.order_by(ModerationLog.created_at.desc()).offset(offset).limit(limit)
total_result = await session.execute(count_query)
total = int(total_result.scalar() or 0)
result = await session.execute(query)
logs = result.scalars().all()
items = [
ModerationLogEntry(
id=log.id,
guild_id=log.guild_id,
target_id=log.target_id,
target_name=log.target_name,
moderator_id=log.moderator_id,
moderator_name=log.moderator_name,
action=log.action,
reason=log.reason,
duration=log.duration,
expires_at=log.expires_at,
channel_id=log.channel_id,
message_id=log.message_id,
message_content=log.message_content,
is_automatic=log.is_automatic,
created_at=log.created_at,
)
for log in logs
]
return PaginatedLogs(total=total, items=items)
return router

View File

@@ -1,165 +0,0 @@
"""Pydantic schemas for dashboard APIs."""
from datetime import datetime
from pydantic import BaseModel, Field
class GuildSummary(BaseModel):
id: int
name: str
owner_id: int
premium: bool
class ModerationLogEntry(BaseModel):
id: int
guild_id: int
target_id: int
target_name: str
moderator_id: int
moderator_name: str
action: str
reason: str | None
duration: int | None
expires_at: datetime | None
channel_id: int | None
message_id: int | None
message_content: str | None
is_automatic: bool
created_at: datetime
class PaginatedLogs(BaseModel):
total: int
items: list[ModerationLogEntry]
# Analytics Schemas
class TimeSeriesDataPoint(BaseModel):
timestamp: datetime
value: int
class ModerationStats(BaseModel):
total_actions: int
actions_by_type: dict[str, int]
actions_over_time: list[TimeSeriesDataPoint]
automatic_vs_manual: dict[str, int]
class UserActivityStats(BaseModel):
active_users: int
total_messages: int
new_joins_today: int
new_joins_week: int
class AIPerformanceStats(BaseModel):
total_checks: int
flagged_content: int
avg_confidence: float
false_positives: int = 0
avg_response_time_ms: float = 0.0
class AnalyticsSummary(BaseModel):
moderation_stats: ModerationStats
user_activity: UserActivityStats
ai_performance: AIPerformanceStats
# User Management Schemas
class UserProfile(BaseModel):
guild_id: int
guild_name: str
user_id: int
username: str
strike_count: int
total_warnings: int
total_kicks: int
total_bans: int
total_timeouts: int
first_seen: datetime
last_action: datetime | None
class UserNote(BaseModel):
id: int
user_id: int
guild_id: int
moderator_id: int
moderator_name: str
content: str
created_at: datetime
class CreateUserNote(BaseModel):
content: str = Field(min_length=1, max_length=2000)
class BulkModerationAction(BaseModel):
action: str = Field(pattern="^(ban|kick|timeout|warn)$")
user_ids: list[int] = Field(min_length=1, max_length=100)
reason: str | None = None
duration: int | None = None
class BulkActionResult(BaseModel):
success_count: int
failed_count: int
errors: dict[int, str]
# Configuration Schemas
class GuildSettings(BaseModel):
guild_id: int
prefix: str | None = None
log_channel_id: int | None = None
automod_enabled: bool = True
ai_moderation_enabled: bool = False
ai_sensitivity: int = Field(ge=0, le=100, default=50)
verification_enabled: bool = False
verification_role_id: int | None = None
max_warns_before_action: int = Field(ge=1, le=10, default=3)
class AutomodRuleConfig(BaseModel):
guild_id: int
banned_words_enabled: bool = True
scam_detection_enabled: bool = True
spam_detection_enabled: bool = True
invite_filter_enabled: bool = False
max_mentions: int = Field(ge=1, le=20, default=5)
max_emojis: int = Field(ge=1, le=50, default=10)
spam_threshold: int = Field(ge=1, le=20, default=5)
class ConfigExport(BaseModel):
version: str = "1.0"
guild_settings: GuildSettings
automod_rules: AutomodRuleConfig
exported_at: datetime
# WebSocket Event Schemas
class WebSocketEvent(BaseModel):
type: str
guild_id: int
timestamp: datetime
data: dict[str, object]
class ModerationEvent(WebSocketEvent):
type: str = "moderation_action"
data: dict[str, object]
class UserJoinEvent(WebSocketEvent):
type: str = "user_join"
data: dict[str, object]
class AIAlertEvent(WebSocketEvent):
type: str = "ai_alert"
data: dict[str, object]

View File

@@ -1,254 +0,0 @@
"""User management API routes for the GuardDen dashboard."""
from collections.abc import AsyncIterator
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from guardden.dashboard.auth import require_owner
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.db import DashboardDatabase
from guardden.dashboard.schemas import CreateUserNote, UserNote, UserProfile
from guardden.models import Guild, ModerationLog, UserActivity
from guardden.models import UserNote as UserNoteModel
def create_users_router(
settings: DashboardSettings,
database: DashboardDatabase,
) -> APIRouter:
"""Create the user management API router."""
router = APIRouter(prefix="/api/users")
async def get_session() -> AsyncIterator[AsyncSession]:
async for session in database.session():
yield session
def require_owner_dep(request: Request) -> None:
require_owner(settings, request)
@router.get(
"/search",
response_model=list[UserProfile],
dependencies=[Depends(require_owner_dep)],
)
async def search_users(
guild_id: int | None = Query(default=None),
username: str | None = Query(default=None),
min_strikes: int | None = Query(default=None, ge=0),
limit: int = Query(default=50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
) -> list[UserProfile]:
"""Search for users with optional guild and filter parameters."""
query = select(UserActivity, Guild.name).join(Guild, Guild.id == UserActivity.guild_id)
if guild_id:
query = query.where(UserActivity.guild_id == guild_id)
if username:
query = query.where(UserActivity.username.ilike(f"%{username}%"))
if min_strikes is not None:
query = query.where(UserActivity.strike_count >= min_strikes)
query = query.order_by(UserActivity.last_seen.desc()).limit(limit)
result = await session.execute(query)
users = result.all()
# Get last moderation action for each user
profiles = []
for user, guild_name in users:
last_action_query = (
select(ModerationLog.created_at)
.where(ModerationLog.guild_id == user.guild_id)
.where(ModerationLog.target_id == user.user_id)
.order_by(ModerationLog.created_at.desc())
.limit(1)
)
last_action_result = await session.execute(last_action_query)
last_action = last_action_result.scalar()
profiles.append(
UserProfile(
guild_id=user.guild_id,
guild_name=guild_name,
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,
total_warnings=user.warning_count,
total_kicks=user.kick_count,
total_bans=user.ban_count,
total_timeouts=user.timeout_count,
first_seen=user.first_seen,
last_action=last_action,
)
)
return profiles
@router.get(
"/{user_id}/profile",
response_model=UserProfile,
dependencies=[Depends(require_owner_dep)],
)
async def get_user_profile(
user_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> UserProfile:
"""Get detailed profile for a specific user."""
query = (
select(UserActivity, Guild.name)
.join(Guild, Guild.id == UserActivity.guild_id)
.where(UserActivity.guild_id == guild_id)
.where(UserActivity.user_id == user_id)
)
result = await session.execute(query)
row = result.one_or_none()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found in this guild",
)
user, guild_name = row
# Get last moderation action
last_action_query = (
select(ModerationLog.created_at)
.where(ModerationLog.guild_id == guild_id)
.where(ModerationLog.target_id == user_id)
.order_by(ModerationLog.created_at.desc())
.limit(1)
)
last_action_result = await session.execute(last_action_query)
last_action = last_action_result.scalar()
return UserProfile(
guild_id=user.guild_id,
guild_name=guild_name,
user_id=user.user_id,
username=user.username,
strike_count=user.strike_count,
total_warnings=user.warning_count,
total_kicks=user.kick_count,
total_bans=user.ban_count,
total_timeouts=user.timeout_count,
first_seen=user.first_seen,
last_action=last_action,
)
@router.get(
"/{user_id}/notes",
response_model=list[UserNote],
dependencies=[Depends(require_owner_dep)],
)
async def get_user_notes(
user_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> list[UserNote]:
"""Get all notes for a specific user."""
query = (
select(UserNoteModel)
.where(UserNoteModel.guild_id == guild_id)
.where(UserNoteModel.user_id == user_id)
.order_by(UserNoteModel.created_at.desc())
)
result = await session.execute(query)
notes = result.scalars().all()
return [
UserNote(
id=note.id,
user_id=note.user_id,
guild_id=note.guild_id,
moderator_id=note.moderator_id,
moderator_name=note.moderator_name,
content=note.content,
created_at=note.created_at,
)
for note in notes
]
@router.post(
"/{user_id}/notes",
response_model=UserNote,
dependencies=[Depends(require_owner_dep)],
)
async def create_user_note(
user_id: int = Path(...),
guild_id: int = Query(...),
note_data: CreateUserNote = ...,
request: Request = ...,
session: AsyncSession = Depends(get_session),
) -> UserNote:
"""Create a new note for a user."""
# Get moderator info from session
moderator_id = request.session.get("discord_id")
if not moderator_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Discord authentication required",
)
# Create the note
new_note = UserNoteModel(
user_id=user_id,
guild_id=guild_id,
moderator_id=int(moderator_id),
moderator_name="Dashboard User", # TODO: Fetch actual username
content=note_data.content,
created_at=datetime.now(),
)
session.add(new_note)
await session.commit()
await session.refresh(new_note)
return UserNote(
id=new_note.id,
user_id=new_note.user_id,
guild_id=new_note.guild_id,
moderator_id=new_note.moderator_id,
moderator_name=new_note.moderator_name,
content=new_note.content,
created_at=new_note.created_at,
)
@router.delete(
"/{user_id}/notes/{note_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_owner_dep)],
)
async def delete_user_note(
user_id: int = Path(...),
note_id: int = Path(...),
guild_id: int = Query(...),
session: AsyncSession = Depends(get_session),
) -> None:
"""Delete a user note."""
query = (
select(UserNoteModel)
.where(UserNoteModel.id == note_id)
.where(UserNoteModel.guild_id == guild_id)
.where(UserNoteModel.user_id == user_id)
)
result = await session.execute(query)
note = result.scalar_one_or_none()
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found",
)
await session.delete(note)
await session.commit()
return router

View File

@@ -1,221 +0,0 @@
"""WebSocket support for real-time dashboard updates."""
import asyncio
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from guardden.dashboard.config import DashboardSettings
from guardden.dashboard.schemas import WebSocketEvent
logger = logging.getLogger(__name__)
class ConnectionManager:
"""Manage WebSocket connections for real-time updates."""
def __init__(self) -> None:
self.active_connections: dict[int, list[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, guild_id: int) -> None:
"""Accept a new WebSocket connection."""
await websocket.accept()
async with self._lock:
if guild_id not in self.active_connections:
self.active_connections[guild_id] = []
self.active_connections[guild_id].append(websocket)
logger.info("New WebSocket connection for guild %s", guild_id)
async def disconnect(self, websocket: WebSocket, guild_id: int) -> None:
"""Remove a WebSocket connection."""
async with self._lock:
if guild_id in self.active_connections:
if websocket in self.active_connections[guild_id]:
self.active_connections[guild_id].remove(websocket)
if not self.active_connections[guild_id]:
del self.active_connections[guild_id]
logger.info("WebSocket disconnected for guild %s", guild_id)
async def broadcast_to_guild(self, guild_id: int, event: WebSocketEvent) -> None:
"""Broadcast an event to all connections for a specific guild."""
async with self._lock:
connections = self.active_connections.get(guild_id, []).copy()
if not connections:
return
# Convert event to JSON
message = event.model_dump_json()
# Send to all connections
dead_connections = []
for connection in connections:
try:
await connection.send_text(message)
except Exception as e:
logger.warning("Failed to send message to WebSocket: %s", e)
dead_connections.append(connection)
# Clean up dead connections
if dead_connections:
async with self._lock:
if guild_id in self.active_connections:
for conn in dead_connections:
if conn in self.active_connections[guild_id]:
self.active_connections[guild_id].remove(conn)
if not self.active_connections[guild_id]:
del self.active_connections[guild_id]
async def broadcast_to_all(self, event: WebSocketEvent) -> None:
"""Broadcast an event to all connections."""
async with self._lock:
all_guilds = list(self.active_connections.keys())
for guild_id in all_guilds:
await self.broadcast_to_guild(guild_id, event)
def get_connection_count(self, guild_id: int | None = None) -> int:
"""Get the number of active connections."""
if guild_id is not None:
return len(self.active_connections.get(guild_id, []))
return sum(len(conns) for conns in self.active_connections.values())
# Global connection manager
connection_manager = ConnectionManager()
def create_websocket_router(settings: DashboardSettings) -> APIRouter:
"""Create the WebSocket API router."""
router = APIRouter()
@router.websocket("/ws/events")
async def websocket_events(websocket: WebSocket, guild_id: int) -> None:
"""WebSocket endpoint for real-time events."""
await connection_manager.connect(websocket, guild_id)
try:
# Send initial connection confirmation
await websocket.send_json(
{
"type": "connected",
"guild_id": guild_id,
"timestamp": datetime.now().isoformat(),
"data": {"message": "Connected to real-time events"},
}
)
# Keep connection alive and handle incoming messages
while True:
try:
# Wait for messages from client (ping/pong, etc.)
data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
# Echo back as heartbeat
if data == "ping":
await websocket.send_text("pong")
except asyncio.TimeoutError:
# Send periodic ping to keep connection alive
await websocket.send_json(
{
"type": "ping",
"guild_id": guild_id,
"timestamp": datetime.now().isoformat(),
"data": {},
}
)
except WebSocketDisconnect:
logger.info("Client disconnected from WebSocket for guild %s", guild_id)
except Exception as e:
logger.error("WebSocket error for guild %s: %s", guild_id, e)
finally:
await connection_manager.disconnect(websocket, guild_id)
return router
# Helper functions to broadcast events from other parts of the application
async def broadcast_moderation_action(
guild_id: int,
action: str,
target_id: int,
target_name: str,
moderator_name: str,
reason: str | None = None,
) -> None:
"""Broadcast a moderation action event."""
event = WebSocketEvent(
type="moderation_action",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"action": action,
"target_id": target_id,
"target_name": target_name,
"moderator_name": moderator_name,
"reason": reason,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_user_join(
guild_id: int,
user_id: int,
username: str,
) -> None:
"""Broadcast a user join event."""
event = WebSocketEvent(
type="user_join",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"user_id": user_id,
"username": username,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_ai_alert(
guild_id: int,
user_id: int,
severity: str,
category: str,
confidence: float,
) -> None:
"""Broadcast an AI moderation alert."""
event = WebSocketEvent(
type="ai_alert",
guild_id=guild_id,
timestamp=datetime.now(),
data={
"user_id": user_id,
"severity": severity,
"category": category,
"confidence": confidence,
},
)
await connection_manager.broadcast_to_guild(guild_id, event)
async def broadcast_system_event(
event_type: str,
data: dict[str, Any],
guild_id: int | None = None,
) -> None:
"""Broadcast a generic system event."""
event = WebSocketEvent(
type=event_type,
guild_id=guild_id or 0,
timestamp=datetime.now(),
data=data,
)
if guild_id:
await connection_manager.broadcast_to_guild(guild_id, event)
else:
await connection_manager.broadcast_to_all(event)

View File

@@ -97,7 +97,10 @@ class GuildSettings(Base, TimestampMixin):
ai_confidence_threshold: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
ai_log_only: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
nsfw_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
nsfw_only_filtering: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
nsfw_only_filtering: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Notification settings
send_in_channel_warnings: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Verification settings
verification_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -1,328 +0,0 @@
"""Prometheus metrics utilities for GuardDen."""
import time
from functools import wraps
from typing import Dict, Optional, Any
try:
from prometheus_client import Counter, Histogram, Gauge, Info, start_http_server, CollectorRegistry, REGISTRY
PROMETHEUS_AVAILABLE = True
except ImportError:
PROMETHEUS_AVAILABLE = False
# Mock objects when Prometheus client is not available
class MockMetric:
def inc(self, *args, **kwargs): pass
def observe(self, *args, **kwargs): pass
def set(self, *args, **kwargs): pass
def info(self, *args, **kwargs): pass
Counter = Histogram = Gauge = Info = MockMetric
CollectorRegistry = REGISTRY = None
class GuardDenMetrics:
"""Centralized metrics collection for GuardDen."""
def __init__(self, registry: Optional[CollectorRegistry] = None):
self.registry = registry or REGISTRY
self.enabled = PROMETHEUS_AVAILABLE
if not self.enabled:
return
# Bot metrics
self.bot_commands_total = Counter(
'guardden_commands_total',
'Total number of commands executed',
['command', 'guild', 'status'],
registry=self.registry
)
self.bot_command_duration = Histogram(
'guardden_command_duration_seconds',
'Command execution duration in seconds',
['command', 'guild'],
registry=self.registry
)
self.bot_guilds_total = Gauge(
'guardden_guilds_total',
'Total number of guilds the bot is in',
registry=self.registry
)
self.bot_users_total = Gauge(
'guardden_users_total',
'Total number of users across all guilds',
registry=self.registry
)
# Moderation metrics
self.moderation_actions_total = Counter(
'guardden_moderation_actions_total',
'Total number of moderation actions',
['action', 'guild', 'automated'],
registry=self.registry
)
self.automod_triggers_total = Counter(
'guardden_automod_triggers_total',
'Total number of automod triggers',
['filter_type', 'guild', 'action'],
registry=self.registry
)
# AI metrics
self.ai_requests_total = Counter(
'guardden_ai_requests_total',
'Total number of AI provider requests',
['provider', 'operation', 'status'],
registry=self.registry
)
self.ai_request_duration = Histogram(
'guardden_ai_request_duration_seconds',
'AI request duration in seconds',
['provider', 'operation'],
registry=self.registry
)
self.ai_confidence_score = Histogram(
'guardden_ai_confidence_score',
'AI confidence scores',
['provider', 'operation'],
buckets=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
registry=self.registry
)
# Database metrics
self.database_connections_active = Gauge(
'guardden_database_connections_active',
'Number of active database connections',
registry=self.registry
)
self.database_query_duration = Histogram(
'guardden_database_query_duration_seconds',
'Database query duration in seconds',
['operation'],
registry=self.registry
)
# System metrics
self.bot_info = Info(
'guardden_bot_info',
'Bot information',
registry=self.registry
)
self.last_heartbeat = Gauge(
'guardden_last_heartbeat_timestamp',
'Timestamp of last successful heartbeat',
registry=self.registry
)
def record_command(self, command: str, guild_id: Optional[int], status: str, duration: float):
"""Record command execution metrics."""
if not self.enabled:
return
guild_str = str(guild_id) if guild_id else 'dm'
self.bot_commands_total.labels(command=command, guild=guild_str, status=status).inc()
self.bot_command_duration.labels(command=command, guild=guild_str).observe(duration)
def record_moderation_action(self, action: str, guild_id: int, automated: bool):
"""Record moderation action metrics."""
if not self.enabled:
return
self.moderation_actions_total.labels(
action=action,
guild=str(guild_id),
automated=str(automated).lower()
).inc()
def record_automod_trigger(self, filter_type: str, guild_id: int, action: str):
"""Record automod trigger metrics."""
if not self.enabled:
return
self.automod_triggers_total.labels(
filter_type=filter_type,
guild=str(guild_id),
action=action
).inc()
def record_ai_request(self, provider: str, operation: str, status: str, duration: float, confidence: Optional[float] = None):
"""Record AI request metrics."""
if not self.enabled:
return
self.ai_requests_total.labels(
provider=provider,
operation=operation,
status=status
).inc()
self.ai_request_duration.labels(
provider=provider,
operation=operation
).observe(duration)
if confidence is not None:
self.ai_confidence_score.labels(
provider=provider,
operation=operation
).observe(confidence)
def update_guild_count(self, count: int):
"""Update total guild count."""
if not self.enabled:
return
self.bot_guilds_total.set(count)
def update_user_count(self, count: int):
"""Update total user count."""
if not self.enabled:
return
self.bot_users_total.set(count)
def update_database_connections(self, active: int):
"""Update active database connections."""
if not self.enabled:
return
self.database_connections_active.set(active)
def record_database_query(self, operation: str, duration: float):
"""Record database query metrics."""
if not self.enabled:
return
self.database_query_duration.labels(operation=operation).observe(duration)
def update_bot_info(self, info: Dict[str, str]):
"""Update bot information."""
if not self.enabled:
return
self.bot_info.info(info)
def heartbeat(self):
"""Record heartbeat timestamp."""
if not self.enabled:
return
self.last_heartbeat.set(time.time())
# Global metrics instance
_metrics: Optional[GuardDenMetrics] = None
def get_metrics() -> GuardDenMetrics:
"""Get the global metrics instance."""
global _metrics
if _metrics is None:
_metrics = GuardDenMetrics()
return _metrics
def start_metrics_server(port: int = 8001) -> None:
"""Start Prometheus metrics HTTP server."""
if PROMETHEUS_AVAILABLE:
start_http_server(port)
def metrics_middleware(func):
"""Decorator to automatically record command metrics."""
@wraps(func)
async def wrapper(*args, **kwargs):
if not PROMETHEUS_AVAILABLE:
return await func(*args, **kwargs)
start_time = time.time()
status = "success"
try:
# Try to extract context information
ctx = None
if args and hasattr(args[0], 'qualified_name'):
# This is likely a command
command_name = args[0].qualified_name
if len(args) > 1 and hasattr(args[1], 'guild'):
ctx = args[1]
else:
command_name = func.__name__
result = await func(*args, **kwargs)
return result
except Exception as e:
status = "error"
raise
finally:
duration = time.time() - start_time
guild_id = ctx.guild.id if ctx and ctx.guild else None
metrics = get_metrics()
metrics.record_command(
command=command_name,
guild_id=guild_id,
status=status,
duration=duration
)
return wrapper
class MetricsCollector:
"""Periodic metrics collector for system stats."""
def __init__(self, bot):
self.bot = bot
self.metrics = get_metrics()
async def collect_bot_metrics(self):
"""Collect basic bot metrics."""
if not PROMETHEUS_AVAILABLE:
return
# Guild count
guild_count = len(self.bot.guilds)
self.metrics.update_guild_count(guild_count)
# Total user count across all guilds
total_users = sum(guild.member_count or 0 for guild in self.bot.guilds)
self.metrics.update_user_count(total_users)
# Database connections if available
if hasattr(self.bot, 'database') and self.bot.database._engine:
try:
pool = self.bot.database._engine.pool
if hasattr(pool, 'checkedout'):
active_connections = pool.checkedout()
self.metrics.update_database_connections(active_connections)
except Exception:
pass # Ignore database connection metrics errors
# Bot info
self.metrics.update_bot_info({
'version': getattr(self.bot, 'version', 'unknown'),
'python_version': f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
'discord_py_version': str(discord.__version__) if 'discord' in globals() else 'unknown',
})
# Heartbeat
self.metrics.heartbeat()
def setup_metrics(bot, port: int = 8001) -> Optional[MetricsCollector]:
"""Set up metrics collection for the bot."""
if not PROMETHEUS_AVAILABLE:
return None
try:
start_metrics_server(port)
collector = MetricsCollector(bot)
return collector
except Exception as e:
# Log error but don't fail startup
logger = __import__('logging').getLogger(__name__)
logger.error(f"Failed to start metrics server: {e}")
return None

View File

@@ -0,0 +1,79 @@
"""Utility functions for sending moderation notifications."""
import logging
import discord
logger = logging.getLogger(__name__)
async def send_moderation_notification(
user: discord.User | discord.Member,
channel: discord.TextChannel,
embed: discord.Embed,
send_in_channel: bool = False,
) -> bool:
"""
Send moderation notification to user.
Attempts to DM the user first. If DM fails and send_in_channel is True,
sends a temporary PUBLIC message in the channel that auto-deletes after 10 seconds.
WARNING: In-channel messages are PUBLIC and visible to all users in the channel.
They are NOT private or ephemeral due to Discord API limitations.
Args:
user: The user to notify
channel: The channel to send fallback message in
embed: The embed to send
send_in_channel: Whether to send PUBLIC in-channel message if DM fails (default: False)
Returns:
True if notification was delivered (via DM or channel), False otherwise
"""
# Try to DM the user first
try:
await user.send(embed=embed)
logger.debug(f"Sent moderation notification DM to {user}")
return True
except discord.Forbidden:
logger.debug(f"User {user} has DMs disabled, attempting in-channel notification")
pass
except discord.HTTPException as e:
logger.warning(f"Failed to DM user {user}: {e}")
pass
# DM failed, try in-channel notification if enabled
if not send_in_channel:
logger.debug(f"In-channel warnings disabled, notification to {user} not sent")
return False
try:
# Create a simplified message for in-channel notification
# Mention the user so they see it, but keep it brief
in_channel_embed = discord.Embed(
title="⚠️ Moderation Notice",
description=f"{user.mention}, your message was flagged by moderation.\n\n"
f"**Reason:** {embed.description or 'Violation detected'}\n\n"
f"_This message will be deleted in 10 seconds._",
color=embed.color or discord.Color.orange(),
)
# Add timeout info if present
for field in embed.fields:
if field.name in ("Timeout", "Action"):
in_channel_embed.add_field(
name=field.name,
value=field.value,
inline=False,
)
await channel.send(embed=in_channel_embed, delete_after=10)
logger.info(f"Sent in-channel moderation notification to {user} in {channel}")
return True
except discord.Forbidden:
logger.warning(f"Cannot send in-channel notification in {channel}: missing permissions")
return False
except discord.HTTPException as e:
logger.warning(f"Failed to send in-channel notification in {channel}: {e}")
return False