Enhance AutoMod configuration by introducing event-specific settings and thresholds. Refactor Automod schemas to include event configurations, thresholds, and limits for ignored roles and channels. Update database interactions to support new structure and improve handling of AutoMod rules in cogs, ensuring dynamic thresholds for various events. Update dashboard components to accommodate new configurations and improve user experience in managing AutoMod settings.
This commit is contained in:
@@ -17,7 +17,7 @@ from api.dependencies import get_bot, limiter
|
|||||||
from api.discord_auth import get_manageable_guild_ids, get_discord_headers
|
from api.discord_auth import get_manageable_guild_ids, get_discord_headers
|
||||||
from api.db_manager import db_manager
|
from api.db_manager import db_manager
|
||||||
from api.schemas import (
|
from api.schemas import (
|
||||||
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig,
|
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig, AutomodEventConfig,
|
||||||
TicketConfig, LevelingConfig, LoggingConfig, TicketEmbed,
|
TicketConfig, LevelingConfig, LoggingConfig, TicketEmbed,
|
||||||
TicketCategory, LevelingEmbedStyle, PrefixUpdate,
|
TicketCategory, LevelingEmbedStyle, PrefixUpdate,
|
||||||
AutomodUpdate, LevelingUpdate, LoggingUpdate, TicketUpdate,
|
AutomodUpdate, LevelingUpdate, LoggingUpdate, TicketUpdate,
|
||||||
@@ -30,11 +30,23 @@ from api.schemas import (
|
|||||||
RRConfig, RRUpdate, ReactionRoleEntry,
|
RRConfig, RRUpdate, ReactionRoleEntry,
|
||||||
InviteStat, InvitesLeaderboard
|
InviteStat, InvitesLeaderboard
|
||||||
)
|
)
|
||||||
|
from utils.automod_settings import (
|
||||||
|
EVENT_ID_TO_DB,
|
||||||
|
THRESHOLD_META,
|
||||||
|
MAX_IGNORED,
|
||||||
|
NSFW_KEYWORDS,
|
||||||
|
normalize_event_id,
|
||||||
|
normalize_punishment,
|
||||||
|
ensure_thresholds_table,
|
||||||
|
get_all_thresholds,
|
||||||
|
set_thresholds,
|
||||||
|
)
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
import math
|
import math
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import discord
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from core.axiom import Axiom
|
from core.axiom import Axiom
|
||||||
@@ -121,78 +133,264 @@ async def get_guild_automod(guild_id: int):
|
|||||||
Retrieves the AutoMod configuration for a specific guild.
|
Retrieves the AutoMod configuration for a specific guild.
|
||||||
"""
|
"""
|
||||||
db = await db_manager.get_connection(db_path('automod.db'))
|
db = await db_manager.get_connection(db_path('automod.db'))
|
||||||
# Check enabled status
|
await ensure_thresholds_table(db)
|
||||||
|
|
||||||
cursor = await db.execute("SELECT enabled FROM automod WHERE guild_id = ?", (guild_id,))
|
cursor = await db.execute("SELECT enabled FROM automod WHERE guild_id = ?", (guild_id,))
|
||||||
enabled_row = await cursor.fetchone()
|
enabled_row = await cursor.fetchone()
|
||||||
enabled = bool(enabled_row[0]) if enabled_row else False
|
enabled = bool(enabled_row[0]) if enabled_row else False
|
||||||
|
|
||||||
# Get punishments
|
cursor = await db.execute(
|
||||||
cursor = await db.execute("SELECT event, punishment FROM automod_punishments WHERE guild_id = ?", (guild_id,))
|
"SELECT event, punishment FROM automod_punishments WHERE guild_id = ?",
|
||||||
punishments = {row[0]: row[1] for row in await cursor.fetchall()}
|
(guild_id,),
|
||||||
|
)
|
||||||
|
raw_punishments = {row[0]: row[1] for row in await cursor.fetchall()}
|
||||||
|
|
||||||
# Get ignored items
|
events: dict[str, AutomodEventConfig] = {}
|
||||||
cursor = await db.execute("SELECT type, id FROM automod_ignored WHERE guild_id = ?", (guild_id,))
|
punishments_out: dict[str, str] = {}
|
||||||
|
for event_id, db_name in EVENT_ID_TO_DB.items():
|
||||||
|
present = db_name in raw_punishments
|
||||||
|
pun = raw_punishments.get(db_name)
|
||||||
|
if event_id == "anti_nsfw_link":
|
||||||
|
events[event_id] = AutomodEventConfig(
|
||||||
|
enabled=present,
|
||||||
|
punishment="block_message",
|
||||||
|
readonly_punishment=True,
|
||||||
|
)
|
||||||
|
if present:
|
||||||
|
punishments_out[event_id] = "block_message"
|
||||||
|
else:
|
||||||
|
norm = normalize_punishment(pun) if present else None
|
||||||
|
events[event_id] = AutomodEventConfig(
|
||||||
|
enabled=present,
|
||||||
|
punishment=norm or ("Mute" if present else None),
|
||||||
|
readonly_punishment=False,
|
||||||
|
)
|
||||||
|
if present and norm:
|
||||||
|
punishments_out[event_id] = norm
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT type, id FROM automod_ignored WHERE guild_id = ?", (guild_id,)
|
||||||
|
)
|
||||||
ignored_items = await cursor.fetchall()
|
ignored_items = await cursor.fetchall()
|
||||||
ignored_roles = [row[1] for row in ignored_items if row[0] == 'role']
|
ignored_roles = [row[1] for row in ignored_items if row[0] == "role"]
|
||||||
ignored_channels = [row[1] for row in ignored_items if row[0] == 'channel']
|
ignored_channels = [row[1] for row in ignored_items if row[0] == "channel"]
|
||||||
|
|
||||||
# Get logging channel
|
cursor = await db.execute(
|
||||||
cursor = await db.execute("SELECT log_channel FROM automod_logging WHERE guild_id = ?", (guild_id,))
|
"SELECT log_channel FROM automod_logging WHERE guild_id = ?", (guild_id,)
|
||||||
|
)
|
||||||
logging_row = await cursor.fetchone()
|
logging_row = await cursor.fetchone()
|
||||||
logging_channel = logging_row[0] if logging_row else None
|
logging_channel = logging_row[0] if logging_row else None
|
||||||
|
|
||||||
|
thresholds = await get_all_thresholds(guild_id)
|
||||||
|
|
||||||
return AutomodConfig(
|
return AutomodConfig(
|
||||||
guild_id=guild_id,
|
guild_id=guild_id,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
punishments=punishments,
|
punishments=punishments_out,
|
||||||
|
events=events,
|
||||||
|
thresholds=thresholds,
|
||||||
|
threshold_meta=THRESHOLD_META,
|
||||||
ignored_roles=ignored_roles,
|
ignored_roles=ignored_roles,
|
||||||
ignored_channels=ignored_channels,
|
ignored_channels=ignored_channels,
|
||||||
logging_channel=logging_channel
|
logging_channel=logging_channel,
|
||||||
|
limits={"max_ignored_roles": MAX_IGNORED, "max_ignored_channels": MAX_IGNORED},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_nsfw_rule(
|
||||||
|
bot: "Axiom",
|
||||||
|
guild_id: int,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
ignored_roles: list[int],
|
||||||
|
ignored_channels: list[int],
|
||||||
|
) -> None:
|
||||||
|
guild = bot.get_guild(guild_id)
|
||||||
|
if not guild:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
rules = await guild.fetch_automod_rules()
|
||||||
|
existing = next((r for r in rules if r.name == "Anti NSFW Links"), None)
|
||||||
|
except (discord.Forbidden, discord.HTTPException):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
await existing.delete(reason="Automod NSFW disabled via dashboard")
|
||||||
|
except (discord.Forbidden, discord.HTTPException):
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
exempt_roles = [discord.Object(i) for i in ignored_roles[:MAX_IGNORED]]
|
||||||
|
exempt_channels = [discord.Object(i) for i in ignored_channels[:MAX_IGNORED]]
|
||||||
|
try:
|
||||||
|
if existing:
|
||||||
|
await existing.edit(
|
||||||
|
exempt_roles=exempt_roles,
|
||||||
|
exempt_channels=exempt_channels,
|
||||||
|
enabled=True,
|
||||||
|
reason="Automod NSFW updated via dashboard",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await guild.create_automod_rule(
|
||||||
|
name="Anti NSFW Links",
|
||||||
|
event_type=discord.AutoModRuleEventType.message_send,
|
||||||
|
trigger=discord.AutoModTrigger(
|
||||||
|
type=discord.AutoModRuleTriggerType.keyword,
|
||||||
|
keyword_filter=NSFW_KEYWORDS,
|
||||||
|
),
|
||||||
|
actions=[
|
||||||
|
discord.AutoModRuleAction(type=discord.AutoModRuleActionType.block_message),
|
||||||
|
],
|
||||||
|
enabled=True,
|
||||||
|
exempt_roles=exempt_roles,
|
||||||
|
exempt_channels=exempt_channels,
|
||||||
|
reason="Automod NSFW enabled via dashboard",
|
||||||
|
)
|
||||||
|
except (discord.Forbidden, discord.HTTPException) as e:
|
||||||
|
print(f"Automod NSFW sync error for guild {guild_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{guild_id}/automod", summary="Update AutoMod config", description="Partially updates the AutoMod configuration components.")
|
@router.patch("/{guild_id}/automod", summary="Update AutoMod config", description="Partially updates the AutoMod configuration components.")
|
||||||
async def patch_guild_automod(guild_id: int, data: AutomodUpdate):
|
async def patch_guild_automod(
|
||||||
|
guild_id: int,
|
||||||
|
data: AutomodUpdate,
|
||||||
|
bot: "Axiom" = Depends(get_bot),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Updates parts of the AutoMod configuration for a specific guild.
|
Updates parts of the AutoMod configuration for a specific guild.
|
||||||
"""
|
"""
|
||||||
db = await db_manager.get_connection(db_path('automod.db'))
|
db = await db_manager.get_connection(db_path('automod.db'))
|
||||||
|
await ensure_thresholds_table(db)
|
||||||
|
|
||||||
if data.enabled is not None:
|
if data.enabled is not None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR REPLACE INTO automod (guild_id, enabled) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO automod (guild_id, enabled) VALUES (?, ?)",
|
||||||
(guild_id, 1 if data.enabled else 0)
|
(guild_id, 1 if data.enabled else 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.punishments is not None:
|
# Build canonical enabled events from events{} and/or legacy punishments{}
|
||||||
for event, punishment in data.punishments.items():
|
events_payload = data.events
|
||||||
await db.execute(
|
if events_payload is None and data.punishments is not None:
|
||||||
"INSERT OR REPLACE INTO automod_punishments (guild_id, event, punishment) VALUES (?, ?, ?)",
|
events_payload = {}
|
||||||
(guild_id, event, punishment)
|
for raw_key, raw_pun in data.punishments.items():
|
||||||
)
|
eid = normalize_event_id(raw_key)
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
if eid == "anti_nsfw_link":
|
||||||
|
events_payload[eid] = AutomodEventConfig(
|
||||||
|
enabled=True, punishment="block_message", readonly_punishment=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pun = normalize_punishment(raw_pun) or "Mute"
|
||||||
|
events_payload[eid] = AutomodEventConfig(enabled=True, punishment=pun)
|
||||||
|
|
||||||
if data.ignored_roles is not None:
|
nsfw_enabled: bool | None = None
|
||||||
await db.execute("DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'role'", (guild_id,))
|
if events_payload is not None:
|
||||||
for role_id in data.ignored_roles:
|
await db.execute(
|
||||||
|
"DELETE FROM automod_punishments WHERE guild_id = ?", (guild_id,)
|
||||||
|
)
|
||||||
|
for raw_id, cfg in events_payload.items():
|
||||||
|
eid = normalize_event_id(raw_id)
|
||||||
|
if not eid or not cfg.enabled:
|
||||||
|
if eid == "anti_nsfw_link":
|
||||||
|
nsfw_enabled = False
|
||||||
|
continue
|
||||||
|
db_name = EVENT_ID_TO_DB[eid]
|
||||||
|
if eid == "anti_nsfw_link":
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO automod_punishments (guild_id, event, punishment) VALUES (?, ?, ?)",
|
||||||
|
(guild_id, db_name, "Block message"),
|
||||||
|
)
|
||||||
|
nsfw_enabled = True
|
||||||
|
else:
|
||||||
|
pun = normalize_punishment(cfg.punishment) or "Mute"
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO automod_punishments (guild_id, event, punishment) VALUES (?, ?, ?)",
|
||||||
|
(guild_id, db_name, pun),
|
||||||
|
)
|
||||||
|
# Full-replace: omitted/disabled NSFW must drop the Discord rule
|
||||||
|
if nsfw_enabled is None:
|
||||||
|
nsfw_enabled = False
|
||||||
|
|
||||||
|
if data.thresholds is not None:
|
||||||
|
await set_thresholds(db, guild_id, data.thresholds)
|
||||||
|
|
||||||
|
ignored_roles = data.ignored_roles
|
||||||
|
ignored_channels = data.ignored_channels
|
||||||
|
|
||||||
|
if ignored_roles is not None:
|
||||||
|
ignored_roles = list(dict.fromkeys(ignored_roles))[:MAX_IGNORED]
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'role'",
|
||||||
|
(guild_id,),
|
||||||
|
)
|
||||||
|
for role_id in ignored_roles:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR REPLACE INTO automod_ignored (guild_id, type, id) VALUES (?, 'role', ?)",
|
"INSERT OR REPLACE INTO automod_ignored (guild_id, type, id) VALUES (?, 'role', ?)",
|
||||||
(guild_id, role_id)
|
(guild_id, role_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.ignored_channels is not None:
|
if ignored_channels is not None:
|
||||||
await db.execute("DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'channel'", (guild_id,))
|
ignored_channels = list(dict.fromkeys(ignored_channels))[:MAX_IGNORED]
|
||||||
for channel_id in data.ignored_channels:
|
await db.execute(
|
||||||
|
"DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'channel'",
|
||||||
|
(guild_id,),
|
||||||
|
)
|
||||||
|
for channel_id in ignored_channels:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR REPLACE INTO automod_ignored (guild_id, type, id) VALUES (?, 'channel', ?)",
|
"INSERT OR REPLACE INTO automod_ignored (guild_id, type, id) VALUES (?, 'channel', ?)",
|
||||||
(guild_id, channel_id)
|
(guild_id, channel_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.logging_channel is not None:
|
if data.clear_logging:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT OR REPLACE INTO automod_logging (guild_id, log_channel) VALUES (?, ?)",
|
"DELETE FROM automod_logging WHERE guild_id = ?", (guild_id,)
|
||||||
(guild_id, data.logging_channel)
|
|
||||||
)
|
)
|
||||||
|
elif data.logging_channel is not None:
|
||||||
|
if data.logging_channel == 0:
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM automod_logging WHERE guild_id = ?", (guild_id,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO automod_logging (guild_id, log_channel) VALUES (?, ?)",
|
||||||
|
(guild_id, data.logging_channel),
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Resolve current ignore lists for NSFW sync if not in this payload
|
||||||
|
if ignored_roles is None or ignored_channels is None:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT type, id FROM automod_ignored WHERE guild_id = ?", (guild_id,)
|
||||||
|
)
|
||||||
|
items = await cursor.fetchall()
|
||||||
|
if ignored_roles is None:
|
||||||
|
ignored_roles = [r[1] for r in items if r[0] == "role"]
|
||||||
|
if ignored_channels is None:
|
||||||
|
ignored_channels = [r[1] for r in items if r[0] == "channel"]
|
||||||
|
|
||||||
|
if nsfw_enabled is None and (data.ignored_roles is not None or data.ignored_channels is not None):
|
||||||
|
# Keep Discord rule exempts in sync when ignores change
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT 1 FROM automod_punishments WHERE guild_id = ? AND event = ?",
|
||||||
|
(guild_id, EVENT_ID_TO_DB["anti_nsfw_link"]),
|
||||||
|
)
|
||||||
|
nsfw_enabled = bool(await cursor.fetchone())
|
||||||
|
|
||||||
|
if nsfw_enabled is not None:
|
||||||
|
await _sync_nsfw_rule(
|
||||||
|
bot,
|
||||||
|
guild_id,
|
||||||
|
enabled=nsfw_enabled,
|
||||||
|
ignored_roles=ignored_roles or [],
|
||||||
|
ignored_channels=ignored_channels or [],
|
||||||
|
)
|
||||||
|
|
||||||
return {"status": "success", "guild_id": guild_id}
|
return {"status": "success", "guild_id": guild_id}
|
||||||
|
|
||||||
@router.get("/{guild_id}/tickets", response_model=TicketConfig, summary="Get Ticket config", description="Retrieves the support ticket system setup, categories, and staff roles.")
|
@router.get("/{guild_id}/tickets", response_model=TicketConfig, summary="Get Ticket config", description="Retrieves the support ticket system setup, categories, and staff roles.")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
from pydantic import BaseModel, HttpUrl
|
from pydantic import BaseModel, HttpUrl
|
||||||
from typing import Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
# --- Bot Schemas ---
|
# --- Bot Schemas ---
|
||||||
|
|
||||||
@@ -67,13 +67,24 @@ class PrefixConfig(BaseModel):
|
|||||||
guild_id: int
|
guild_id: int
|
||||||
prefix: str
|
prefix: str
|
||||||
|
|
||||||
|
class AutomodEventConfig(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
punishment: Optional[str] = None # Mute | Kick | Ban | block_message (NSFW)
|
||||||
|
readonly_punishment: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AutomodConfig(BaseModel):
|
class AutomodConfig(BaseModel):
|
||||||
guild_id: int
|
guild_id: int
|
||||||
enabled: bool
|
enabled: bool
|
||||||
punishments: Dict[str, str]
|
# Legacy flat map (API event ids or bot names) — kept for older clients
|
||||||
ignored_roles: List[int]
|
punishments: Dict[str, str] = {}
|
||||||
ignored_channels: List[int]
|
events: Dict[str, AutomodEventConfig] = {}
|
||||||
logging_channel: Optional[int]
|
thresholds: Dict[str, Dict[str, float]] = {}
|
||||||
|
threshold_meta: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
ignored_roles: List[int] = []
|
||||||
|
ignored_channels: List[int] = []
|
||||||
|
logging_channel: Optional[int] = None
|
||||||
|
limits: Dict[str, int] = {"max_ignored_roles": 10, "max_ignored_channels": 10}
|
||||||
|
|
||||||
class TicketCategory(BaseModel):
|
class TicketCategory(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -299,10 +310,14 @@ class TicketUpdate(BaseModel):
|
|||||||
|
|
||||||
class AutomodUpdate(BaseModel):
|
class AutomodUpdate(BaseModel):
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
punishments: Optional[Dict[str, str]] = None
|
punishments: Optional[Dict[str, str]] = None # legacy
|
||||||
|
events: Optional[Dict[str, AutomodEventConfig]] = None
|
||||||
|
thresholds: Optional[Dict[str, Dict[str, float]]] = None
|
||||||
ignored_roles: Optional[List[int]] = None
|
ignored_roles: Optional[List[int]] = None
|
||||||
ignored_channels: Optional[List[int]] = None
|
ignored_channels: Optional[List[int]] = None
|
||||||
|
# Use a sentinel-friendly optional: omit = no change; null = clear logging
|
||||||
logging_channel: Optional[int] = None
|
logging_channel: Optional[int] = None
|
||||||
|
clear_logging: Optional[bool] = None
|
||||||
|
|
||||||
class LevelingUpdate(BaseModel):
|
class LevelingUpdate(BaseModel):
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -23,7 +24,6 @@ import asyncio
|
|||||||
class AntiEmojiSpam(commands.Cog):
|
class AntiEmojiSpam(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.emoji_threshold = 5
|
|
||||||
|
|
||||||
async def is_automod_enabled(self, guild_id):
|
async def is_automod_enabled(self, guild_id):
|
||||||
async with aiosqlite.connect(db_path("automod.db")) as db:
|
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||||
@@ -118,17 +118,20 @@ class AntiEmojiSpam(commands.Cog):
|
|||||||
|
|
||||||
|
|
||||||
emoji_count = len(emoji_pattern.findall(message.content))
|
emoji_count = len(emoji_pattern.findall(message.content))
|
||||||
|
th = await get_thresholds(guild_id, "anti_emoji_spam")
|
||||||
|
emoji_threshold = int(th.get("max_emojis", 5))
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 1))
|
||||||
|
|
||||||
if emoji_count > self.emoji_threshold:
|
if emoji_count > emoji_threshold:
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = f"Emoji Spam ({emoji_count} emojis)"
|
reason = f"Emoji Spam ({emoji_count} emojis)"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=1)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
||||||
action_taken = "Muted for 1 minute"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason=reason)
|
await user.kick(reason=reason)
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -105,15 +106,17 @@ class AntiInvite(commands.Cog):
|
|||||||
if any(invite.code == invite_code for invite in invite):
|
if any(invite.code == invite_code for invite in invite):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
th = await get_thresholds(guild_id, "anti_invites")
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 12))
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = "Posted an invite link"
|
reason = "Posted an invite link"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=12)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason="Posted an invite link")
|
await user.edit(timed_out_until=timeout_duration, reason="Posted an invite link")
|
||||||
action_taken = "Muted for 12 minutes"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason="Posted an invite link")
|
await user.kick(reason="Posted an invite link")
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -22,7 +23,6 @@ import asyncio
|
|||||||
class AntiMassMention(commands.Cog):
|
class AntiMassMention(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.mass_mention_threshold = 5
|
|
||||||
|
|
||||||
async def is_automod_enabled(self, guild_id):
|
async def is_automod_enabled(self, guild_id):
|
||||||
async with aiosqlite.connect(db_path("automod.db")) as db:
|
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||||
@@ -97,17 +97,21 @@ class AntiMassMention(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
th = await get_thresholds(guild_id, "anti_mass_mention")
|
||||||
|
mention_threshold = int(th.get("max_mentions", 5))
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 3))
|
||||||
|
|
||||||
mention_count = message.content.count("<@")
|
mention_count = message.content.count("<@")
|
||||||
if mention_count >= self.mass_mention_threshold:
|
if mention_count >= mention_threshold:
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = f"Mass Mention ({mention_count} mentions)"
|
reason = f"Mass Mention ({mention_count} mentions)"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=3)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
||||||
action_taken = "Muted for 3 minutes"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason=reason)
|
await user.kick(reason=reason)
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -22,8 +23,6 @@ from datetime import timedelta
|
|||||||
class AntiCaps(commands.Cog):
|
class AntiCaps(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.caps_threshold = 70
|
|
||||||
self.mute_duration = 2 * 60
|
|
||||||
|
|
||||||
async def is_automod_enabled(self, guild_id):
|
async def is_automod_enabled(self, guild_id):
|
||||||
async with aiosqlite.connect(db_path("automod.db")) as db:
|
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||||
@@ -74,9 +73,6 @@ class AntiCaps(commands.Cog):
|
|||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_message(self, message):
|
async def on_message(self, message):
|
||||||
if len(message.content) < 45:
|
|
||||||
return
|
|
||||||
|
|
||||||
if message.author.bot:
|
if message.author.bot:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -85,6 +81,14 @@ class AntiCaps(commands.Cog):
|
|||||||
channel = message.channel
|
channel = message.channel
|
||||||
guild_id = guild.id
|
guild_id = guild.id
|
||||||
|
|
||||||
|
th = await get_thresholds(guild_id, "anti_caps")
|
||||||
|
min_length = int(th.get("min_length", 45))
|
||||||
|
caps_threshold = float(th.get("percent", 70))
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 1))
|
||||||
|
|
||||||
|
if len(message.content) < min_length:
|
||||||
|
return
|
||||||
|
|
||||||
if not await self.is_automod_enabled(guild_id) or not await self.is_anti_caps_enabled(guild_id):
|
if not await self.is_automod_enabled(guild_id) or not await self.is_anti_caps_enabled(guild_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -103,16 +107,16 @@ class AntiCaps(commands.Cog):
|
|||||||
caps_count = sum(1 for c in message.content if c.isupper())
|
caps_count = sum(1 for c in message.content if c.isupper())
|
||||||
caps_percentage = (caps_count / len(message.content)) * 100
|
caps_percentage = (caps_count / len(message.content)) * 100
|
||||||
|
|
||||||
if caps_percentage > self.caps_threshold:
|
if caps_percentage > caps_threshold:
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = "Excessive Caps"
|
reason = "Excessive Caps"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=1)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason="Excessive Caps")
|
await user.edit(timed_out_until=timeout_duration, reason="Excessive Caps")
|
||||||
action_taken = "Muted for 1 minutes"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason="Excessive Caps")
|
await user.kick(reason="Excessive Caps")
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -107,15 +108,17 @@ class AntiLink(commands.Cog):
|
|||||||
if self.spotify_pattern.search(message.content):
|
if self.spotify_pattern.search(message.content):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
th = await get_thresholds(guild_id, "anti_link")
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 7))
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = "Posted a link"
|
reason = "Posted a link"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=7)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
await user.edit(timed_out_until=timeout_duration, reason=reason)
|
||||||
action_taken = "Muted for 7 minutes"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason=reason)
|
await user.kick(reason=reason)
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# ║ ║
|
# ║ ║
|
||||||
# ╚══════════════════════════════════════════════════════════════════╝
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
from utils.db_paths import db_path
|
from utils.db_paths import db_path
|
||||||
|
from utils.automod_settings import get_thresholds
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from utils.emoji import TICK
|
from utils.emoji import TICK
|
||||||
@@ -22,8 +23,6 @@ from datetime import timedelta
|
|||||||
class AntiSpam(commands.Cog):
|
class AntiSpam(commands.Cog):
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.spam_threshold = 5
|
|
||||||
self.mute_duration = 12 * 60
|
|
||||||
self.recent_messages = {}
|
self.recent_messages = {}
|
||||||
|
|
||||||
async def is_automod_enabled(self, guild_id):
|
async def is_automod_enabled(self, guild_id):
|
||||||
@@ -99,21 +98,26 @@ class AntiSpam(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
current_time = message.created_at.timestamp()
|
current_time = message.created_at.timestamp()
|
||||||
|
th = await get_thresholds(guild_id, "anti_spam")
|
||||||
|
window = float(th.get("window_seconds", 10))
|
||||||
|
spam_threshold = int(th.get("max_messages", 5))
|
||||||
|
mute_minutes = float(th.get("mute_minutes", 12))
|
||||||
|
|
||||||
user_messages = self.recent_messages.get(user.id, [])
|
user_messages = self.recent_messages.get(user.id, [])
|
||||||
user_messages = [msg for msg in user_messages if current_time - msg < 10]
|
user_messages = [msg for msg in user_messages if current_time - msg < window]
|
||||||
user_messages.append(current_time)
|
user_messages.append(current_time)
|
||||||
self.recent_messages[user.id] = user_messages
|
self.recent_messages[user.id] = user_messages
|
||||||
|
|
||||||
if len(user_messages) > self.spam_threshold:
|
if len(user_messages) > spam_threshold:
|
||||||
punishment = await self.get_punishment(guild_id)
|
punishment = await self.get_punishment(guild_id)
|
||||||
action_taken = None
|
action_taken = None
|
||||||
reason = "Spamming"
|
reason = "Spamming"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if punishment == "Mute":
|
if punishment == "Mute":
|
||||||
timeout_duration = discord.utils.utcnow() + timedelta(minutes=12)
|
timeout_duration = discord.utils.utcnow() + timedelta(minutes=mute_minutes)
|
||||||
await user.edit(timed_out_until=timeout_duration, reason="Spamming")
|
await user.edit(timed_out_until=timeout_duration, reason="Spamming")
|
||||||
action_taken = "Muted for 12 minutes"
|
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||||
elif punishment == "Kick":
|
elif punishment == "Kick":
|
||||||
await user.kick(reason="Spamming")
|
await user.kick(reason="Spamming")
|
||||||
action_taken = "Kicked"
|
action_taken = "Kicked"
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ class Automod(commands.Cog):
|
|||||||
PRIMARY KEY (guild_id)
|
PRIMARY KEY (guild_id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS automod_thresholds (
|
||||||
|
guild_id INTEGER,
|
||||||
|
event TEXT,
|
||||||
|
key TEXT,
|
||||||
|
value REAL,
|
||||||
|
PRIMARY KEY (guild_id, event, key)
|
||||||
|
)
|
||||||
|
""")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@commands.hybrid_group(
|
@commands.hybrid_group(
|
||||||
|
|||||||
Binary file not shown.
187
bot/utils/automod_settings.py
Normal file
187
bot/utils/automod_settings.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# ╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
# ║ ║
|
||||||
|
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||||
|
# ║ |H|e|x|a|H|o|s|t| ║
|
||||||
|
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||||
|
# ║ ║
|
||||||
|
# ║ © 2026 HexaHost — All Rights Reserved ║
|
||||||
|
# ║ ║
|
||||||
|
# ║ discord ── https://discord.gg/hexahost ║
|
||||||
|
# ║ github ── https://github.com/theoneandonlymace ║
|
||||||
|
# ║ ║
|
||||||
|
# ╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
"""Shared AutoMod settings: event IDs, punishments, thresholds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
from utils.db_paths import db_path
|
||||||
|
|
||||||
|
# API id -> bot DB event name
|
||||||
|
EVENT_ID_TO_DB: dict[str, str] = {
|
||||||
|
"anti_spam": "Anti spam",
|
||||||
|
"anti_caps": "Anti caps",
|
||||||
|
"anti_link": "Anti link",
|
||||||
|
"anti_invites": "Anti invites",
|
||||||
|
"anti_mass_mention": "Anti mass mention",
|
||||||
|
"anti_emoji_spam": "Anti emoji spam",
|
||||||
|
"anti_nsfw_link": "Anti NSFW link",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Also accept legacy UI ids
|
||||||
|
EVENT_ID_ALIASES: dict[str, str] = {
|
||||||
|
"anti_links": "anti_link",
|
||||||
|
"anti_mentions": "anti_mass_mention",
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_EVENT_TO_ID: dict[str, str] = {v: k for k, v in EVENT_ID_TO_DB.items()}
|
||||||
|
|
||||||
|
VALID_PUNISHMENTS = frozenset({"Mute", "Kick", "Ban"})
|
||||||
|
PUNISHMENT_ALIASES: dict[str, str] = {
|
||||||
|
"mute": "Mute",
|
||||||
|
"kick": "Kick",
|
||||||
|
"ban": "Ban",
|
||||||
|
"Mute": "Mute",
|
||||||
|
"Kick": "Kick",
|
||||||
|
"Ban": "Ban",
|
||||||
|
"delete": "Mute", # legacy dashboard — map to Mute
|
||||||
|
"warn": "Mute",
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_IGNORED = 10
|
||||||
|
|
||||||
|
NSFW_KEYWORDS = [
|
||||||
|
"porn", "xxx", "adult", "sex", "nsfw", "xnxx", "onlyfans", "brazzers",
|
||||||
|
"xhamster", "xvideos", "pornhub", "redtube", "livejasmin", "youporn",
|
||||||
|
"tube8", "pornhat", "swxvid", "ixxx",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_THRESHOLDS: dict[str, dict[str, float]] = {
|
||||||
|
"anti_spam": {"max_messages": 5, "window_seconds": 10, "mute_minutes": 12},
|
||||||
|
"anti_caps": {"percent": 70, "min_length": 45, "mute_minutes": 1},
|
||||||
|
"anti_link": {"mute_minutes": 7},
|
||||||
|
"anti_invites": {"mute_minutes": 12},
|
||||||
|
"anti_mass_mention": {"max_mentions": 5, "mute_minutes": 3},
|
||||||
|
"anti_emoji_spam": {"max_emojis": 5, "mute_minutes": 1},
|
||||||
|
"anti_nsfw_link": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
THRESHOLD_META: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"anti_spam": [
|
||||||
|
{"key": "max_messages", "label": "Max messages", "min": 2, "max": 20, "step": 1},
|
||||||
|
{"key": "window_seconds", "label": "Window (seconds)", "min": 3, "max": 60, "step": 1},
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
"anti_caps": [
|
||||||
|
{"key": "percent", "label": "Caps percent", "min": 40, "max": 100, "step": 1},
|
||||||
|
{"key": "min_length", "label": "Min message length", "min": 10, "max": 200, "step": 1},
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
"anti_link": [
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
"anti_invites": [
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
"anti_mass_mention": [
|
||||||
|
{"key": "max_mentions", "label": "Max mentions", "min": 2, "max": 20, "step": 1},
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
"anti_emoji_spam": [
|
||||||
|
{"key": "max_emojis", "label": "Max emojis", "min": 2, "max": 30, "step": 1},
|
||||||
|
{"key": "mute_minutes", "label": "Mute minutes", "min": 1, "max": 1440, "step": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_event_id(raw: str) -> str | None:
|
||||||
|
if raw in EVENT_ID_TO_DB:
|
||||||
|
return raw
|
||||||
|
if raw in EVENT_ID_ALIASES:
|
||||||
|
return EVENT_ID_ALIASES[raw]
|
||||||
|
if raw in DB_EVENT_TO_ID:
|
||||||
|
return DB_EVENT_TO_ID[raw]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_punishment(raw: str | None) -> str | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return PUNISHMENT_ALIASES.get(raw) or PUNISHMENT_ALIASES.get(raw.strip())
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_thresholds_table(db: aiosqlite.Connection) -> None:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS automod_thresholds (
|
||||||
|
guild_id INTEGER,
|
||||||
|
event TEXT,
|
||||||
|
key TEXT,
|
||||||
|
value REAL,
|
||||||
|
PRIMARY KEY (guild_id, event, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_thresholds(guild_id: int, event_id: str) -> dict[str, float]:
|
||||||
|
"""Return merged defaults + DB overrides for an API event id."""
|
||||||
|
defaults = dict(DEFAULT_THRESHOLDS.get(event_id, {}))
|
||||||
|
db_event = EVENT_ID_TO_DB.get(event_id)
|
||||||
|
if not db_event:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||||
|
await ensure_thresholds_table(db)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT key, value FROM automod_thresholds WHERE guild_id = ? AND event = ?",
|
||||||
|
(guild_id, db_event),
|
||||||
|
)
|
||||||
|
for key, value in await cursor.fetchall():
|
||||||
|
defaults[str(key)] = float(value)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_thresholds(guild_id: int) -> dict[str, dict[str, float]]:
|
||||||
|
out: dict[str, dict[str, float]] = {
|
||||||
|
eid: dict(vals) for eid, vals in DEFAULT_THRESHOLDS.items()
|
||||||
|
}
|
||||||
|
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||||
|
await ensure_thresholds_table(db)
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT event, key, value FROM automod_thresholds WHERE guild_id = ?",
|
||||||
|
(guild_id,),
|
||||||
|
)
|
||||||
|
for event, key, value in await cursor.fetchall():
|
||||||
|
eid = DB_EVENT_TO_ID.get(event)
|
||||||
|
if not eid:
|
||||||
|
continue
|
||||||
|
out.setdefault(eid, {})[str(key)] = float(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def set_thresholds(
|
||||||
|
db: aiosqlite.Connection,
|
||||||
|
guild_id: int,
|
||||||
|
thresholds: dict[str, dict[str, float]],
|
||||||
|
) -> None:
|
||||||
|
await ensure_thresholds_table(db)
|
||||||
|
for event_id, pairs in thresholds.items():
|
||||||
|
eid = normalize_event_id(event_id)
|
||||||
|
if not eid or eid == "anti_nsfw_link":
|
||||||
|
continue
|
||||||
|
db_event = EVENT_ID_TO_DB[eid]
|
||||||
|
allowed = DEFAULT_THRESHOLDS.get(eid, {})
|
||||||
|
for key, value in pairs.items():
|
||||||
|
if key not in allowed:
|
||||||
|
continue
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO automod_thresholds (guild_id, event, key, value)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(guild_id, db_event, key, float(value)),
|
||||||
|
)
|
||||||
@@ -22,7 +22,11 @@ const AutomodForm = dynamic(() => import("@/components/dashboard/automod-form").
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default async function AutomodPage({ params }: { params: { guildId: string } }) {
|
export default async function AutomodPage({ params }: { params: { guildId: string } }) {
|
||||||
const config = await api.getAutomod(params.guildId);
|
const [config, channels, roles] = await Promise.all([
|
||||||
|
api.getAutomod(params.guildId),
|
||||||
|
api.getChannels(params.guildId),
|
||||||
|
api.getRoles(params.guildId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
@@ -38,7 +42,12 @@ export default async function AutomodPage({ params }: { params: { guildId: strin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutomodForm initialConfig={config} guildId={params.guildId} />
|
<AutomodForm
|
||||||
|
initialConfig={config}
|
||||||
|
guildId={params.guildId}
|
||||||
|
channels={channels || []}
|
||||||
|
roles={roles || []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,20 @@
|
|||||||
*/
|
*/
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Zap,
|
Zap,
|
||||||
Type,
|
Type,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
ShieldAlert,
|
Smile,
|
||||||
|
EyeOff,
|
||||||
Gavel,
|
Gavel,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Save
|
Save,
|
||||||
|
Info,
|
||||||
|
Hash,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -32,63 +35,162 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AutomodConfig } from "@/types/api";
|
import {
|
||||||
|
AutomodConfig,
|
||||||
|
AutomodEventConfig,
|
||||||
|
DiscordChannel,
|
||||||
|
DiscordRole,
|
||||||
|
} from "@/types/api";
|
||||||
|
|
||||||
const PUNISHMENT_OPTIONS = [
|
const PUNISHMENT_OPTIONS = [
|
||||||
{ value: "delete", label: "Delete Message" },
|
{ value: "Mute", label: "Mute" },
|
||||||
{ value: "warn", label: "Warn User" },
|
{ value: "Kick", label: "Kick" },
|
||||||
{ value: "mute", label: "Mute User" },
|
{ value: "Ban", label: "Ban" },
|
||||||
{ value: "kick", label: "Kick User" },
|
|
||||||
{ value: "ban", label: "Ban User" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULES = [
|
const RULES = [
|
||||||
{ id: 'anti_spam', name: 'Anti Spam', desc: 'Detects and removes repetitive messages or rapid firing.', icon: Zap },
|
{ id: "anti_spam", name: "Anti Spam", desc: "Repetitive messages or rapid firing.", icon: Zap },
|
||||||
{ id: 'anti_caps', name: 'Anti Caps', desc: 'Prevents excessive use of uppercase letters.', icon: Type },
|
{ id: "anti_caps", name: "Anti Caps", desc: "Excessive uppercase letters.", icon: Type },
|
||||||
{ id: 'anti_links', name: 'Anti Links', desc: 'Blocks unauthorized external links in channels.', icon: LinkIcon },
|
{ id: "anti_link", name: "Anti Links", desc: "Unauthorized external links.", icon: LinkIcon },
|
||||||
{ id: 'anti_invites', name: 'Anti Invites', desc: 'Automatically removes Discord server invite links.', icon: MessageSquare },
|
{ id: "anti_invites", name: "Anti Invites", desc: "Discord invite links.", icon: MessageSquare },
|
||||||
{ id: 'anti_mentions', name: 'Anti Mass Mention', desc: 'Protects against mentioned spam (@everyone, @here).', icon: UserMinus },
|
{ id: "anti_mass_mention", name: "Anti Mass Mention", desc: "Too many user mentions.", icon: UserMinus },
|
||||||
|
{ id: "anti_emoji_spam", name: "Anti Emoji Spam", desc: "Too many emojis in one message.", icon: Smile },
|
||||||
|
{ id: "anti_nsfw_link", name: "Anti NSFW Links", desc: "Discord AutoMod keyword filter (blocks message).", icon: EyeOff },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function buildInitialEvents(config: AutomodConfig): Record<string, AutomodEventConfig> {
|
||||||
|
const events: Record<string, AutomodEventConfig> = { ...(config.events || {}) };
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (!events[rule.id]) {
|
||||||
|
const legacy = config.punishments?.[rule.id];
|
||||||
|
events[rule.id] = {
|
||||||
|
enabled: legacy !== undefined,
|
||||||
|
punishment:
|
||||||
|
rule.id === "anti_nsfw_link"
|
||||||
|
? "block_message"
|
||||||
|
: legacy === "mute" || legacy === "Mute"
|
||||||
|
? "Mute"
|
||||||
|
: legacy === "kick" || legacy === "Kick"
|
||||||
|
? "Kick"
|
||||||
|
: legacy === "ban" || legacy === "Ban"
|
||||||
|
? "Ban"
|
||||||
|
: "Mute",
|
||||||
|
readonly_punishment: rule.id === "anti_nsfw_link",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
interface AutomodFormProps {
|
interface AutomodFormProps {
|
||||||
initialConfig: AutomodConfig;
|
initialConfig: AutomodConfig;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
|
channels: DiscordChannel[];
|
||||||
|
roles: DiscordRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
export function AutomodForm({ initialConfig, guildId, channels, roles }: AutomodFormProps) {
|
||||||
const [config, setConfig] = useState<AutomodConfig>(initialConfig);
|
const [enabled, setEnabled] = useState(initialConfig.enabled);
|
||||||
|
const [events, setEvents] = useState(() => buildInitialEvents(initialConfig));
|
||||||
|
const [thresholds, setThresholds] = useState<Record<string, Record<string, number>>>(
|
||||||
|
() => ({ ...(initialConfig.thresholds || {}) })
|
||||||
|
);
|
||||||
|
const [ignoredRoles, setIgnoredRoles] = useState<number[]>(initialConfig.ignored_roles || []);
|
||||||
|
const [ignoredChannels, setIgnoredChannels] = useState<number[]>(
|
||||||
|
initialConfig.ignored_channels || []
|
||||||
|
);
|
||||||
|
const [loggingChannel, setLoggingChannel] = useState<string>(
|
||||||
|
initialConfig.logging_channel ? String(initialConfig.logging_channel) : ""
|
||||||
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const handleToggle = (key: string) => {
|
const maxIgnored = initialConfig.limits?.max_ignored_roles ?? 10;
|
||||||
setConfig({
|
const thresholdMeta = initialConfig.threshold_meta || {};
|
||||||
...config,
|
|
||||||
enabled: key === 'master' ? !config.enabled : config.enabled,
|
const textChannels = useMemo(
|
||||||
});
|
() =>
|
||||||
|
channels.filter(
|
||||||
|
(c) => c.type === "0" || c.type === "text" || !c.type
|
||||||
|
),
|
||||||
|
[channels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: "", label: "No log channel" },
|
||||||
|
...textChannels.map((c) => ({ value: String(c.id), label: `#${c.name}` })),
|
||||||
|
],
|
||||||
|
[textChannels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleIgnore = (
|
||||||
|
list: number[],
|
||||||
|
setList: (v: number[]) => void,
|
||||||
|
id: number,
|
||||||
|
cap: number
|
||||||
|
) => {
|
||||||
|
if (list.includes(id)) {
|
||||||
|
setList(list.filter((x) => x !== id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (list.length >= cap) {
|
||||||
|
toast.error(`Maximum ${cap} entries allowed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setList([...list, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePunishmentChange = (ruleId: string, value: string) => {
|
const setEventEnabled = (ruleId: string, on: boolean) => {
|
||||||
const newPunishments = { ...config.punishments };
|
setEvents((prev) => ({
|
||||||
newPunishments[ruleId] = value;
|
...prev,
|
||||||
setConfig({ ...config, punishments: newPunishments });
|
[ruleId]: {
|
||||||
|
...prev[ruleId],
|
||||||
|
enabled: on,
|
||||||
|
punishment:
|
||||||
|
ruleId === "anti_nsfw_link"
|
||||||
|
? "block_message"
|
||||||
|
: prev[ruleId]?.punishment || "Mute",
|
||||||
|
readonly_punishment: ruleId === "anti_nsfw_link",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPunishment = (ruleId: string, value: string) => {
|
||||||
|
setEvents((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[ruleId]: { ...prev[ruleId], enabled: true, punishment: value },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setThresholdValue = (ruleId: string, key: string, value: number) => {
|
||||||
|
setThresholds((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[ruleId]: { ...(prev[ruleId] || {}), [key]: value },
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
const payload = {
|
||||||
|
enabled,
|
||||||
|
events,
|
||||||
|
thresholds,
|
||||||
|
ignored_roles: ignoredRoles,
|
||||||
|
ignored_channels: ignoredChannels,
|
||||||
|
logging_channel: loggingChannel ? Number(loggingChannel) : 0,
|
||||||
|
clear_logging: !loggingChannel,
|
||||||
|
};
|
||||||
|
|
||||||
const promise = api.updateAutomod(guildId, {
|
const promise = api.updateAutomod(guildId, payload);
|
||||||
enabled: config.enabled,
|
|
||||||
punishments: config.punishments,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: 'Saving configuration...',
|
loading: "Saving configuration...",
|
||||||
success: 'Configuration saved successfully!',
|
success: "Configuration saved successfully!",
|
||||||
error: (err) => err.message || 'Failed to update settings',
|
error: (err) => err.message || "Failed to update settings",
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await promise;
|
await promise;
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -100,78 +202,127 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
|||||||
<div className="lg:col-span-3 space-y-6">
|
<div className="lg:col-span-3 space-y-6">
|
||||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
|
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
|
||||||
<div className="p-8 space-y-6">
|
<div className="p-8 space-y-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">Master Control</h3>
|
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">
|
||||||
<Switch
|
Master Control
|
||||||
checked={config.enabled}
|
</h3>
|
||||||
onCheckedChange={() => handleToggle('master')}
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pb-4 border-b border-slate-800">
|
||||||
|
<label className="text-xs font-bold uppercase text-slate-500 tracking-wider flex items-center gap-2">
|
||||||
|
<Hash className="h-3.5 w-3.5" /> Logging Channel
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={loggingChannel}
|
||||||
|
onValueChange={setLoggingChannel}
|
||||||
|
options={channelOptions}
|
||||||
|
placeholder="Select channel"
|
||||||
|
className="max-w-md"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{RULES.map((rule) => {
|
{RULES.map((rule) => {
|
||||||
const isEnabled = config.enabled && config.punishments?.[rule.id] !== undefined;
|
const cfg = events[rule.id] || { enabled: false, punishment: "Mute" };
|
||||||
|
const isOn = enabled && cfg.enabled;
|
||||||
|
const meta = thresholdMeta[rule.id] || [];
|
||||||
|
const isNsfw = rule.id === "anti_nsfw_link";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-6 rounded-2xl border transition-all duration-300",
|
"p-6 rounded-2xl border transition-all duration-300",
|
||||||
config.enabled ? "bg-slate-900/40 border-slate-800" : "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
|
enabled
|
||||||
|
? "bg-slate-900/40 border-slate-800"
|
||||||
|
: "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={cn(
|
<div
|
||||||
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
|
className={cn(
|
||||||
isEnabled ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
|
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
|
||||||
)}>
|
isOn ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<rule.icon className="h-6 w-6" />
|
<rule.icon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-white">{rule.name}</h3>
|
<h3 className="font-bold text-white">{rule.name}</h3>
|
||||||
<p className="text-xs text-slate-500 max-w-xs">{rule.desc}</p>
|
<p className="text-xs text-slate-500 max-w-sm">{rule.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{isEnabled && (
|
{isOn && !isNsfw && (
|
||||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
|
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
|
||||||
<Gavel className="h-4 w-4 text-primary" />
|
<Gavel className="h-4 w-4 text-primary" />
|
||||||
<Select
|
<Select
|
||||||
value={config.punishments[rule.id] || "delete"}
|
value={cfg.punishment || "Mute"}
|
||||||
onValueChange={(val) => handlePunishmentChange(rule.id, val)}
|
onValueChange={(val) => setPunishment(rule.id, val)}
|
||||||
options={PUNISHMENT_OPTIONS}
|
options={PUNISHMENT_OPTIONS}
|
||||||
className="w-40"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
{isOn && isNsfw && (
|
||||||
<Switch
|
<span className="text-xs text-slate-400 font-mono">block_message</span>
|
||||||
disabled={!config.enabled}
|
)}
|
||||||
checked={config.punishments?.[rule.id] !== undefined}
|
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
||||||
onCheckedChange={() => {
|
<Switch
|
||||||
const newPunishments = { ...config.punishments };
|
disabled={!enabled}
|
||||||
if (newPunishments[rule.id]) {
|
checked={!!cfg.enabled}
|
||||||
delete newPunishments[rule.id];
|
onCheckedChange={(on) => setEventEnabled(rule.id, on)}
|
||||||
} else {
|
|
||||||
newPunishments[rule.id] = "delete";
|
|
||||||
}
|
|
||||||
setConfig({...config, punishments: newPunishments});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOn && meta.length > 0 && (
|
||||||
|
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-4 border-t border-slate-800/80">
|
||||||
|
{meta.map((field) => {
|
||||||
|
const val =
|
||||||
|
thresholds[rule.id]?.[field.key] ??
|
||||||
|
field.min;
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-400">{field.label}</span>
|
||||||
|
<span className="text-primary font-mono">{val}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
value={val}
|
||||||
|
onChange={(e) =>
|
||||||
|
setThresholdValue(rule.id, field.key, Number(e.target.value))
|
||||||
|
}
|
||||||
|
className="w-full accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full h-14 text-base font-bold gap-2"
|
className="w-full h-14 text-base font-bold gap-2"
|
||||||
>
|
>
|
||||||
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
|
{saving ? (
|
||||||
|
<RefreshCcw className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-5 w-5" />
|
||||||
|
)}
|
||||||
Save Moderation Rules
|
Save Moderation Rules
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,28 +330,87 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4">Logging Level</h3>
|
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-2">
|
||||||
<div className="space-y-3">
|
Ignored Roles
|
||||||
<div className="p-3 bg-slate-900/50 rounded-xl border border-white/5 flex items-center justify-between">
|
</h3>
|
||||||
<span className="text-sm text-slate-400">Log Channel</span>
|
<p className="text-[11px] text-slate-500 mb-3">
|
||||||
<span className="text-xs font-mono text-primary">#{config.logging_channel || 'None'}</span>
|
Up to {maxIgnored} roles bypass Automod filters.
|
||||||
</div>
|
</p>
|
||||||
<p className="text-[10px] text-slate-500 italic text-center">Mod logs are automatically sent to the configured channel.</p>
|
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
||||||
</div>
|
{roles
|
||||||
</div>
|
.filter((r) => r.name !== "@everyone")
|
||||||
|
.map((role) => {
|
||||||
|
const id = Number(role.id);
|
||||||
|
const checked = ignoredRoles.includes(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={role.id}
|
||||||
|
type="button"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() =>
|
||||||
|
toggleIgnore(ignoredRoles, setIgnoredRoles, id, maxIgnored)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-primary/40 bg-primary/10 text-white"
|
||||||
|
: "border-transparent bg-slate-900/40 text-slate-400 hover:border-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
|
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||||
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
|
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-2">
|
||||||
<ShieldAlert className="h-32 w-32 text-white" />
|
Ignored Channels
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 mb-3">
|
||||||
|
Up to {maxIgnored} channels are skipped by filters.
|
||||||
|
</p>
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
||||||
|
{textChannels.map((ch) => {
|
||||||
|
const id = Number(ch.id);
|
||||||
|
const checked = ignoredChannels.includes(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ch.id}
|
||||||
|
type="button"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() =>
|
||||||
|
toggleIgnore(ignoredChannels, setIgnoredChannels, id, maxIgnored)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-primary/40 bg-primary/10 text-white"
|
||||||
|
: "border-transparent bg-slate-900/40 text-slate-400 hover:border-slate-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
#{ch.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white mb-1">How it works</h3>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">
|
||||||
|
Enable rules, pick Mute / Kick / Ban, and tune thresholds. NSFW uses Discord's
|
||||||
|
native AutoMod and always blocks the message. Prefix commands (
|
||||||
|
<code className="text-primary">automod</code>) stay in sync with these settings.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-white mb-2">Automod AI</h3>
|
</div>
|
||||||
<p className="text-xs text-slate-400 leading-relaxed mb-4">Our neural network analyzes message context to prevent false positives.</p>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
|
||||||
<span className="text-[10px] font-black uppercase text-primary">V2 Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getAutomod: (guildId: string) => request<AutomodConfig>(`/guilds/${guildId}/automod`),
|
getAutomod: (guildId: string) => request<AutomodConfig>(`/guilds/${guildId}/automod`),
|
||||||
updateAutomod: (guildId: string, data: Partial<AutomodConfig>) =>
|
updateAutomod: (guildId: string, data: AutomodUpdate) =>
|
||||||
request<{ status: string }>(`/guilds/${guildId}/automod`, {
|
request<{ status: string }>(`/guilds/${guildId}/automod`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -53,13 +53,34 @@ export interface PrefixConfig {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutomodEventConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
punishment?: string | null;
|
||||||
|
readonly_punishment?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodThresholdMeta {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutomodConfig {
|
export interface AutomodConfig {
|
||||||
guild_id: number;
|
guild_id: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
punishments: Record<string, string>;
|
punishments: Record<string, string>;
|
||||||
|
events: Record<string, AutomodEventConfig>;
|
||||||
|
thresholds: Record<string, Record<string, number>>;
|
||||||
|
threshold_meta: Record<string, AutomodThresholdMeta[]>;
|
||||||
ignored_roles: number[];
|
ignored_roles: number[];
|
||||||
ignored_channels: number[];
|
ignored_channels: number[];
|
||||||
logging_channel: number | null;
|
logging_channel: number | null;
|
||||||
|
limits?: {
|
||||||
|
max_ignored_roles: number;
|
||||||
|
max_ignored_channels: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketCategory {
|
export interface TicketCategory {
|
||||||
@@ -124,9 +145,12 @@ export interface PrefixUpdate {
|
|||||||
export interface AutomodUpdate {
|
export interface AutomodUpdate {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
punishments?: Record<string, string>;
|
punishments?: Record<string, string>;
|
||||||
|
events?: Record<string, AutomodEventConfig>;
|
||||||
|
thresholds?: Record<string, Record<string, number>>;
|
||||||
ignored_roles?: number[];
|
ignored_roles?: number[];
|
||||||
ignored_channels?: number[];
|
ignored_channels?: number[];
|
||||||
logging_channel?: number;
|
logging_channel?: number | null;
|
||||||
|
clear_logging?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LevelingUpdate {
|
export interface LevelingUpdate {
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ export interface AutomodConfig {
|
|||||||
guild_id: number;
|
guild_id: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
punishments: Record<string, string>;
|
punishments: Record<string, string>;
|
||||||
|
events?: Record<string, { enabled: boolean; punishment?: string | null; readonly_punishment?: boolean }>;
|
||||||
|
thresholds?: Record<string, Record<string, number>>;
|
||||||
|
threshold_meta?: Record<string, { key: string; label: string; min: number; max: number; step: number }[]>;
|
||||||
ignored_roles: number[];
|
ignored_roles: number[];
|
||||||
ignored_channels: number[];
|
ignored_channels: number[];
|
||||||
logging_channel: number | null;
|
logging_channel: number | null;
|
||||||
|
limits?: { max_ignored_roles: number; max_ignored_channels: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketConfig {
|
export interface TicketConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user