update
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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_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)
|
||||
|
||||
@@ -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