Compare commits
9 Commits
f15c869993
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f57cccea5 | ||
|
|
36b32de34c | ||
|
|
d24b810164 | ||
|
|
a52d84467d | ||
|
|
1da2085087 | ||
|
|
20193404e8 | ||
|
|
299351463c | ||
|
|
6b0091f7d5 | ||
|
|
fc77f0a3c2 |
@@ -7,6 +7,12 @@ brand_name=Axiom
|
||||
OWNER_IDS=YOUR_DISCORD_USER_ID_HERE
|
||||
DASHBOARD_ADMIN_IDS=YOUR_DISCORD_USER_ID_HERE
|
||||
|
||||
# Sharding — leave unset for auto (recommended). Multi-process: same TOKEN, shared bot-db volume.
|
||||
# SHARD_COUNT=
|
||||
# SHARD_IDS=0,1
|
||||
# SHARD_PRIMARY=
|
||||
# Enable API only on the primary shard process.
|
||||
|
||||
# Shared secret — must match between bot API and dashboard
|
||||
DASHBOARD_API_KEY=generate_a_long_random_secret_here
|
||||
|
||||
@@ -19,6 +25,11 @@ LAVALINK_PORT=13592
|
||||
EMOJI_SYNC=false
|
||||
JISHAKU_ENABLED=false
|
||||
|
||||
# Slash (/) command sync — use ONE mode to avoid duplicate commands in Discord
|
||||
SLASH_SYNC_MODE=global
|
||||
SLASH_CLEAR_GUILD_COMMANDS=true
|
||||
# SLASH_GUILD_IDS=
|
||||
|
||||
# Traefik (optional override — default in compose: letsencrypt)
|
||||
# TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@ NEXT_PUBLIC_BRAND_NAME='Axiom'
|
||||
# BOT_INSTANCE_ID=prod-a
|
||||
# API_PORT=8001
|
||||
|
||||
# ── Discord sharding (optional) ───────────────────────────────────────────────
|
||||
# Default (all unset): AutoShardedBot asks Discord for recommended shard count (one process).
|
||||
# Multi-process cluster: same TOKEN + shared DB volume; set SHARD_COUNT + SHARD_IDS per process.
|
||||
# API / slash sync / emoji sync only on the primary (shard 0 in SHARD_IDS, or SHARD_PRIMARY=true).
|
||||
# SHARD_COUNT=
|
||||
# SHARD_IDS=0,1
|
||||
# SHARD_PRIMARY=
|
||||
# Only enable API on the primary process:
|
||||
# API_ENABLED=true
|
||||
|
||||
# ── Owner / Staff IDs (REQUIRED — comma-separated Discord user IDs) ───────────
|
||||
# Set your own Discord user ID(s). No foreign IDs are used as fallback.
|
||||
OWNER_IDS=YOUR_DISCORD_USER_ID_HERE
|
||||
@@ -27,8 +37,12 @@ LAVALINK_PASSWORD="youshallnotpass"
|
||||
LAVALINK_SECURE="false"
|
||||
LAVALINK_PORT="13592"
|
||||
|
||||
# ── Emoji Sync ────────────────────────────────────────────────────────────────
|
||||
EMOJI_SYNC="false"
|
||||
SLASH_SYNC_MODE=global
|
||||
# global = one set worldwide (default, no duplicates)
|
||||
# guild = instant only in connected servers (set SLASH_GUILD_IDS or rely on all guilds)
|
||||
# SLASH_GUILD_IDS=
|
||||
# Clears old per-server copies that caused duplicate /commands in Discord
|
||||
SLASH_CLEAR_GUILD_COMMANDS=true
|
||||
|
||||
# ── API / Dashboard Backend ───────────────────────────────────────────────────
|
||||
API_ENABLED="false"
|
||||
|
||||
62
bot/Axiom.py
62
bot/Axiom.py
@@ -33,6 +33,7 @@ from utils.Tools import *
|
||||
from utils.config import *
|
||||
from utils.emoji import SUCCESS, ERROR, TICK, CROSS, REACTION_TEST_EMOJIS
|
||||
from utils.sync_emojis import run_sync
|
||||
from utils.slash_sync import sync_slash_commands
|
||||
|
||||
import cogs
|
||||
|
||||
@@ -95,25 +96,66 @@ async def on_ready():
|
||||
print(f"Logged in as: {client.user}")
|
||||
print(f"Connected to: {len(client.guilds)} guilds")
|
||||
print(f"Connected to: {len(client.users)} users")
|
||||
shard_count = client.shard_count or 1
|
||||
shard_ids = list(client.shards.keys()) if client.shards else [0]
|
||||
print(f"Shards: {shard_count} (this process: {shard_ids})")
|
||||
print(f"Shard primary: {IS_SHARD_PRIMARY}")
|
||||
if client.latencies:
|
||||
for sid, lat in client.latencies:
|
||||
print(f" Shard {sid}: {round(lat * 1000)}ms")
|
||||
|
||||
if not IS_SHARD_PRIMARY:
|
||||
print("\033[33m◈ Non-primary shard process — skipping API-side sync tasks\033[0m")
|
||||
return
|
||||
|
||||
# Sync application emojis on startup
|
||||
await run_sync(TOKEN)
|
||||
|
||||
async def sync_commands():
|
||||
# Register / slash commands (guild sync = instant; global can take up to ~1h)
|
||||
if not getattr(client, "_slash_synced", False):
|
||||
client._slash_synced = True
|
||||
try:
|
||||
synced = await client.tree.sync()
|
||||
all_commands = list(client.commands)
|
||||
print(f"Synced Total {len(all_commands)} Client Commands and {len(synced)} Slash Commands")
|
||||
await sync_slash_commands(client)
|
||||
except Exception as e:
|
||||
print(f"Error syncing command tree: {e}")
|
||||
print(f"\033[31mError syncing command tree: {e}\033[0m")
|
||||
|
||||
client.loop.create_task(sync_commands())
|
||||
if SERVER_COUNT_CHANNEL_ID or USER_COUNT_CHANNEL_ID:
|
||||
client.loop.create_task(update_stats())
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_shard_connect(shard_id: int):
|
||||
print(f"\033[32m◈ Shard {shard_id}: connected\033[0m")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_shard_ready(shard_id: int):
|
||||
print(f"\033[32m◈ Shard {shard_id}: ready\033[0m")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_shard_disconnect(shard_id: int):
|
||||
print(f"\033[33m◈ Shard {shard_id}: disconnected\033[0m")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_shard_resumed(shard_id: int):
|
||||
print(f"\033[32m◈ Shard {shard_id}: resumed\033[0m")
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_guild_join(guild: discord.Guild):
|
||||
# Only guild-mode needs per-server registration; global mode must not
|
||||
# copy commands into the guild or Discord shows duplicates (/afk twice).
|
||||
if IS_SHARD_PRIMARY:
|
||||
try:
|
||||
from utils.slash_sync import _sync_mode, sync_guild
|
||||
if _sync_mode() == "guild":
|
||||
synced = await sync_guild(client, guild, copy_global=True)
|
||||
print(f"◈ Slash sync for new guild {guild.id}: {len(synced)} command(s)")
|
||||
except Exception as e:
|
||||
print(f"Slash sync on guild join failed: {e}")
|
||||
|
||||
if not LOG_CHANNEL_ID:
|
||||
return
|
||||
log_channel = client.get_channel(LOG_CHANNEL_ID)
|
||||
@@ -318,6 +360,9 @@ def run_api():
|
||||
uvicorn.run(fastapi_app, host=API_HOST, port=API_PORT, log_level="warning")
|
||||
|
||||
def keep_alive():
|
||||
if not IS_SHARD_PRIMARY:
|
||||
print(f"\033[33m◈ API Server: Skipped (non-primary shard process)\033[0m")
|
||||
return
|
||||
if not API_ENABLED:
|
||||
print(f"\033[33m◈ API Server: Disabled via API_ENABLED=false\033[0m")
|
||||
return
|
||||
@@ -331,7 +376,10 @@ keep_alive()
|
||||
|
||||
# --- Cloudflare Tunnel (HTTPS for API) — only when explicitly enabled ---
|
||||
from utils.tunnel import start_tunnel
|
||||
start_tunnel()
|
||||
if IS_SHARD_PRIMARY:
|
||||
start_tunnel()
|
||||
else:
|
||||
print("\033[33m◈ Cloudflare Tunnel: Skipped (non-primary shard process)\033[0m")
|
||||
|
||||
# --- Main Bot Execution ---
|
||||
async def main():
|
||||
|
||||
@@ -87,7 +87,16 @@ async def get_admin_stats(bot: "Axiom" = Depends(get_bot)):
|
||||
AdminNodeStatus(
|
||||
name="Auth Sockets",
|
||||
status="Healthy",
|
||||
load=f"Shard: {bot.shard_count} | Latency: {round(bot.latency * 1000)}ms",
|
||||
load=(
|
||||
f"Shards: {bot.shard_count or 1} | "
|
||||
f"Latency: {round(bot.latency * 1000)}ms"
|
||||
+ (
|
||||
" | "
|
||||
+ ", ".join(f"s{sid}:{round(lat * 1000)}ms" for sid, lat in bot.latencies)
|
||||
if bot.latencies and len(bot.latencies) > 1
|
||||
else ""
|
||||
)
|
||||
),
|
||||
icon="Lock"
|
||||
)
|
||||
]
|
||||
|
||||
@@ -28,13 +28,20 @@ async def get_status(bot: "Axiom" = Depends(get_bot)):
|
||||
"""
|
||||
Returns the live status of the bot.
|
||||
"""
|
||||
shard_ids = list(bot.shards.keys()) if bot.shards else ([0] if bot.shard_count else [])
|
||||
shard_latencies = [
|
||||
{"id": sid, "latency_ms": round(lat * 1000, 2)}
|
||||
for sid, lat in (bot.latencies or [])
|
||||
]
|
||||
return BotStatus(
|
||||
user=str(bot.user),
|
||||
id=str(bot.user.id) if bot.user else None,
|
||||
latency=bot.latency * 1000,
|
||||
guild_count=len(bot.guilds),
|
||||
user_count=sum(g.member_count or 0 for g in bot.guilds),
|
||||
shards=bot.shard_count
|
||||
shards=bot.shard_count,
|
||||
shard_ids=shard_ids,
|
||||
shard_latencies=shard_latencies,
|
||||
)
|
||||
|
||||
@router.get("/info", response_model=BotInfo, summary="Get bot info", description="Returns general information about the bot including command count and user reach.")
|
||||
|
||||
@@ -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.db_manager import db_manager
|
||||
from api.schemas import (
|
||||
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig,
|
||||
GuildSummary, GuildDetails, PrefixConfig, AutomodConfig, AutomodEventConfig,
|
||||
TicketConfig, LevelingConfig, LoggingConfig, TicketEmbed,
|
||||
TicketCategory, LevelingEmbedStyle, PrefixUpdate,
|
||||
AutomodUpdate, LevelingUpdate, LoggingUpdate, TicketUpdate,
|
||||
@@ -30,11 +30,23 @@ from api.schemas import (
|
||||
RRConfig, RRUpdate, ReactionRoleEntry,
|
||||
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
|
||||
import math
|
||||
import aiosqlite
|
||||
import json
|
||||
import os
|
||||
import discord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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.
|
||||
"""
|
||||
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,))
|
||||
enabled_row = await cursor.fetchone()
|
||||
enabled = bool(enabled_row[0]) if enabled_row else False
|
||||
|
||||
# Get punishments
|
||||
cursor = await db.execute("SELECT event, punishment FROM automod_punishments WHERE guild_id = ?", (guild_id,))
|
||||
punishments = {row[0]: row[1] for row in await cursor.fetchall()}
|
||||
cursor = await db.execute(
|
||||
"SELECT event, punishment FROM automod_punishments WHERE guild_id = ?",
|
||||
(guild_id,),
|
||||
)
|
||||
raw_punishments = {row[0]: row[1] for row in await cursor.fetchall()}
|
||||
|
||||
# Get ignored items
|
||||
cursor = await db.execute("SELECT type, id FROM automod_ignored WHERE guild_id = ?", (guild_id,))
|
||||
events: dict[str, AutomodEventConfig] = {}
|
||||
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_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_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"]
|
||||
|
||||
# Get logging channel
|
||||
cursor = await db.execute("SELECT log_channel FROM automod_logging WHERE guild_id = ?", (guild_id,))
|
||||
cursor = await db.execute(
|
||||
"SELECT log_channel FROM automod_logging WHERE guild_id = ?", (guild_id,)
|
||||
)
|
||||
logging_row = await cursor.fetchone()
|
||||
logging_channel = logging_row[0] if logging_row else None
|
||||
|
||||
thresholds = await get_all_thresholds(guild_id)
|
||||
|
||||
return AutomodConfig(
|
||||
guild_id=guild_id,
|
||||
enabled=enabled,
|
||||
punishments=punishments,
|
||||
punishments=punishments_out,
|
||||
events=events,
|
||||
thresholds=thresholds,
|
||||
threshold_meta=THRESHOLD_META,
|
||||
ignored_roles=ignored_roles,
|
||||
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.")
|
||||
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.
|
||||
"""
|
||||
db = await db_manager.get_connection(db_path('automod.db'))
|
||||
await ensure_thresholds_table(db)
|
||||
|
||||
if data.enabled is not None:
|
||||
await db.execute(
|
||||
"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:
|
||||
for event, punishment in data.punishments.items():
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO automod_punishments (guild_id, event, punishment) VALUES (?, ?, ?)",
|
||||
(guild_id, event, punishment)
|
||||
)
|
||||
# Build canonical enabled events from events{} and/or legacy punishments{}
|
||||
events_payload = data.events
|
||||
if events_payload is None and data.punishments is not None:
|
||||
events_payload = {}
|
||||
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:
|
||||
await db.execute("DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'role'", (guild_id,))
|
||||
for role_id in data.ignored_roles:
|
||||
nsfw_enabled: bool | None = None
|
||||
if events_payload is not None:
|
||||
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(
|
||||
"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:
|
||||
await db.execute("DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'channel'", (guild_id,))
|
||||
for channel_id in data.ignored_channels:
|
||||
if ignored_channels is not None:
|
||||
ignored_channels = list(dict.fromkeys(ignored_channels))[:MAX_IGNORED]
|
||||
await db.execute(
|
||||
"DELETE FROM automod_ignored WHERE guild_id = ? AND type = 'channel'",
|
||||
(guild_id,),
|
||||
)
|
||||
for channel_id in ignored_channels:
|
||||
await db.execute(
|
||||
"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(
|
||||
"INSERT OR REPLACE INTO automod_logging (guild_id, log_channel) VALUES (?, ?)",
|
||||
(guild_id, data.logging_channel)
|
||||
"DELETE FROM automod_logging WHERE guild_id = ?", (guild_id,)
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
# 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}
|
||||
|
||||
@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 typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# --- Bot Schemas ---
|
||||
|
||||
@@ -31,6 +31,8 @@ class BotStatus(BaseModel):
|
||||
guild_count: int
|
||||
user_count: int
|
||||
shards: Optional[int]
|
||||
shard_ids: Optional[List[int]] = None
|
||||
shard_latencies: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
# --- Guild Schemas ---
|
||||
|
||||
@@ -67,13 +69,24 @@ class PrefixConfig(BaseModel):
|
||||
guild_id: int
|
||||
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):
|
||||
guild_id: int
|
||||
enabled: bool
|
||||
punishments: Dict[str, str]
|
||||
ignored_roles: List[int]
|
||||
ignored_channels: List[int]
|
||||
logging_channel: Optional[int]
|
||||
# Legacy flat map (API event ids or bot names) — kept for older clients
|
||||
punishments: Dict[str, str] = {}
|
||||
events: Dict[str, AutomodEventConfig] = {}
|
||||
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):
|
||||
name: str
|
||||
@@ -299,10 +312,14 @@ class TicketUpdate(BaseModel):
|
||||
|
||||
class AutomodUpdate(BaseModel):
|
||||
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_channels: Optional[List[int]] = None
|
||||
# Use a sentinel-friendly optional: omit = no change; null = clear logging
|
||||
logging_channel: Optional[int] = None
|
||||
clear_logging: Optional[bool] = None
|
||||
|
||||
class LevelingUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
@@ -161,6 +161,7 @@ from .moderation.warn import Warn
|
||||
from .moderation.role import Role
|
||||
from .moderation.message import Message
|
||||
from .moderation.moderation import Moderation
|
||||
from .moderation.mod_hub import ModHub
|
||||
from .moderation.topcheck import TopCheck
|
||||
from .moderation.snipe import Snipe
|
||||
|
||||
@@ -187,7 +188,7 @@ COG_CLASSES: tuple[type, ...] = (
|
||||
AntiWebhookCreate, AntiWebhookDelete,
|
||||
AntiSpam, AntiCaps, AntiInvite, AntiLink, AntiMassMention, AntiEmojiSpam,
|
||||
Ban, Unban, Mute, Unmute, Lock, Unlock, Hide, Unhide, Kick, Warn, Role,
|
||||
Message, Moderation, TopCheck, Snipe,
|
||||
Message, Moderation, ModHub, TopCheck, Snipe,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -23,7 +24,6 @@ import asyncio
|
||||
class AntiEmojiSpam(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.emoji_threshold = 5
|
||||
|
||||
async def is_automod_enabled(self, guild_id):
|
||||
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))
|
||||
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)
|
||||
action_taken = None
|
||||
reason = f"Emoji Spam ({emoji_count} emojis)"
|
||||
|
||||
try:
|
||||
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)
|
||||
action_taken = "Muted for 1 minute"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason=reason)
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -105,15 +106,17 @@ class AntiInvite(commands.Cog):
|
||||
if any(invite.code == invite_code for invite in invite):
|
||||
return
|
||||
|
||||
th = await get_thresholds(guild_id, "anti_invites")
|
||||
mute_minutes = float(th.get("mute_minutes", 12))
|
||||
punishment = await self.get_punishment(guild_id)
|
||||
action_taken = None
|
||||
reason = "Posted an invite link"
|
||||
|
||||
try:
|
||||
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")
|
||||
action_taken = "Muted for 12 minutes"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason="Posted an invite link")
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -22,7 +23,6 @@ import asyncio
|
||||
class AntiMassMention(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.mass_mention_threshold = 5
|
||||
|
||||
async def is_automod_enabled(self, guild_id):
|
||||
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||
@@ -97,17 +97,21 @@ class AntiMassMention(commands.Cog):
|
||||
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("<@")
|
||||
if mention_count >= self.mass_mention_threshold:
|
||||
if mention_count >= mention_threshold:
|
||||
punishment = await self.get_punishment(guild_id)
|
||||
action_taken = None
|
||||
reason = f"Mass Mention ({mention_count} mentions)"
|
||||
|
||||
try:
|
||||
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)
|
||||
action_taken = "Muted for 3 minutes"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason=reason)
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -22,8 +23,6 @@ from datetime import timedelta
|
||||
class AntiCaps(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.caps_threshold = 70
|
||||
self.mute_duration = 2 * 60
|
||||
|
||||
async def is_automod_enabled(self, guild_id):
|
||||
async with aiosqlite.connect(db_path("automod.db")) as db:
|
||||
@@ -74,9 +73,6 @@ class AntiCaps(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message):
|
||||
if len(message.content) < 45:
|
||||
return
|
||||
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
@@ -85,6 +81,14 @@ class AntiCaps(commands.Cog):
|
||||
channel = message.channel
|
||||
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):
|
||||
return
|
||||
|
||||
@@ -103,16 +107,16 @@ class AntiCaps(commands.Cog):
|
||||
caps_count = sum(1 for c in message.content if c.isupper())
|
||||
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)
|
||||
action_taken = None
|
||||
reason = "Excessive Caps"
|
||||
|
||||
try:
|
||||
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")
|
||||
action_taken = "Muted for 1 minutes"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason="Excessive Caps")
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -107,15 +108,17 @@ class AntiLink(commands.Cog):
|
||||
if self.spotify_pattern.search(message.content):
|
||||
return
|
||||
|
||||
th = await get_thresholds(guild_id, "anti_link")
|
||||
mute_minutes = float(th.get("mute_minutes", 7))
|
||||
punishment = await self.get_punishment(guild_id)
|
||||
action_taken = None
|
||||
reason = "Posted a link"
|
||||
|
||||
try:
|
||||
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)
|
||||
action_taken = "Muted for 7 minutes"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason=reason)
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
from utils.db_paths import db_path
|
||||
from utils.automod_settings import get_thresholds
|
||||
|
||||
import discord
|
||||
from utils.emoji import TICK
|
||||
@@ -22,8 +23,6 @@ from datetime import timedelta
|
||||
class AntiSpam(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.spam_threshold = 5
|
||||
self.mute_duration = 12 * 60
|
||||
self.recent_messages = {}
|
||||
|
||||
async def is_automod_enabled(self, guild_id):
|
||||
@@ -99,21 +98,26 @@ class AntiSpam(commands.Cog):
|
||||
return
|
||||
|
||||
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 = [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)
|
||||
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)
|
||||
action_taken = None
|
||||
reason = "Spamming"
|
||||
|
||||
try:
|
||||
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")
|
||||
action_taken = "Muted for 12 minutes"
|
||||
action_taken = f"Muted for {int(mute_minutes)} minutes"
|
||||
elif punishment == "Kick":
|
||||
await user.kick(reason="Spamming")
|
||||
action_taken = "Kicked"
|
||||
|
||||
@@ -30,10 +30,25 @@ class Games(Cog):
|
||||
def __init__(self, client: Axiom):
|
||||
self.client = client
|
||||
|
||||
@commands.hybrid_group(
|
||||
name="game",
|
||||
description="Play mini-games with friends or the bot.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@commands.guild_only()
|
||||
async def game(self, ctx: Context):
|
||||
"""Games root — use `/game <subcommand>` or `>game <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
@commands.hybrid_command(name="chess",
|
||||
@game.command(name="chess",
|
||||
help="Play Chess with a user.",
|
||||
usage="Chess <user>")
|
||||
usage="game chess <user>")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -49,10 +64,10 @@ class Games(Cog):
|
||||
await game.start(ctx)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="rps",
|
||||
@game.command(name="rps",
|
||||
help="Play Rock Paper Scissor with bot/user.",
|
||||
aliases=["rockpaperscissors"],
|
||||
usage="Rockpaperscissors")
|
||||
usage="game rps [user]")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -62,10 +77,10 @@ class Games(Cog):
|
||||
game = btn.BetaRockPaperScissors(player)
|
||||
await game.start(ctx, timeout=120)
|
||||
|
||||
@commands.hybrid_command(name="tic-tac-toe",
|
||||
@game.command(name="tictactoe",
|
||||
help="play tic-tac-toe game with a user.",
|
||||
aliases=["ttt", "tictactoe"],
|
||||
usage="Ticktactoe <member>")
|
||||
aliases=["ttt", "tic-tac-toe"],
|
||||
usage="game tictactoe <member>")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -80,9 +95,9 @@ class Games(Cog):
|
||||
game = btn.BetaTictactoe(cross=ctx.author, circle=player)
|
||||
await game.start(ctx, timeout=30)
|
||||
|
||||
@commands.hybrid_command(name="wordle",
|
||||
@game.command(name="wordle",
|
||||
help="Wordle Game | Play with bot.",
|
||||
usage="Wordle")
|
||||
usage="game wordle")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -92,10 +107,10 @@ class Games(Cog):
|
||||
game = games.Wordle()
|
||||
await game.start(ctx, timeout=120)
|
||||
|
||||
@commands.hybrid_command(name="2048",
|
||||
@game.command(name="2048",
|
||||
help="Play 2048 game with bot.",
|
||||
aliases=["twenty48"],
|
||||
usage="2048")
|
||||
usage="game 2048")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -105,10 +120,10 @@ class Games(Cog):
|
||||
game = btn.BetaTwenty48()
|
||||
await game.start(ctx, win_at=2048)
|
||||
|
||||
@commands.hybrid_command(name="memory-game",
|
||||
@game.command(name="memory",
|
||||
help="How strong is your memory?",
|
||||
aliases=["memory"],
|
||||
usage="memory-game")
|
||||
aliases=["memory-game"],
|
||||
usage="game memory")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -118,10 +133,10 @@ class Games(Cog):
|
||||
game = btn.MemoryGame()
|
||||
await game.start(ctx)
|
||||
|
||||
@commands.hybrid_command(name="number-slider",
|
||||
@game.command(name="slider",
|
||||
help="slide numbers with bot",
|
||||
aliases=["slider"],
|
||||
usage="slider")
|
||||
aliases=["number-slider"],
|
||||
usage="game slider")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -131,10 +146,10 @@ class Games(Cog):
|
||||
game = btn.NumberSlider()
|
||||
await game.start(ctx)
|
||||
|
||||
@commands.hybrid_command(name="battleship",
|
||||
@game.command(name="battleship",
|
||||
help="Play battleship game with your friend.",
|
||||
aliases=["battle-ship"],
|
||||
usage="battleship <user>")
|
||||
usage="game battleship <user>")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -164,10 +179,10 @@ class Games(Cog):
|
||||
async def _end_country_guesser(self, ctx: Context):
|
||||
await self.country_guesser_game.end_game_manually(ctx)"""
|
||||
|
||||
@commands.hybrid_command(name="connectfour",
|
||||
@game.command(name="connectfour",
|
||||
help="Play Connect Four game with user.",
|
||||
aliases=["c4", "connect-four", "connect4"],
|
||||
usage="connectfour <user>")
|
||||
usage="game connectfour <user>")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -184,10 +199,10 @@ class Games(Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="lights-out",
|
||||
@game.command(name="lightsout",
|
||||
help="Play Lights Show game with bot.",
|
||||
aliases=["lightsout"],
|
||||
usage="Lights-out")
|
||||
aliases=["lights-out"],
|
||||
usage="game lightsout")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
|
||||
@@ -47,7 +47,13 @@ class Media(commands.Cog):
|
||||
async def on_ready(self):
|
||||
await self.set_db()
|
||||
|
||||
@commands.hybrid_group(name="media", help="Setup Media channel, Media channel will not allow users to send messages other than media files.", invoke_without_command=True)
|
||||
@commands.hybrid_group(
|
||||
name="media",
|
||||
description="Setup Media channel; only media files are allowed.",
|
||||
help="Setup Media channel, Media channel will not allow users to send messages other than media files.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
|
||||
@@ -57,16 +57,34 @@ class Whitelist(commands.Cog):
|
||||
''')
|
||||
await self.db.commit()
|
||||
|
||||
@commands.hybrid_command(name='whitelist', aliases=['wl'], help="Whitelists a user from antinuke for a specific action.")
|
||||
|
||||
@commands.hybrid_group(
|
||||
name="whitelist",
|
||||
aliases=["wl"],
|
||||
description="Manage antinuke whitelist.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
help="Whitelists a user from antinuke for a specific action.",
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@commands.max_concurrency(1, per=commands.BucketType.default, wait=False)
|
||||
@commands.guild_only()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def whitelist(self, ctx):
|
||||
"""Whitelist root — use `/whitelist <subcommand>` or `>whitelist <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
async def whitelist(self, ctx, member: discord.Member = None):
|
||||
@whitelist.command(name="add", help="Whitelists a user from antinuke for a specific action.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@commands.max_concurrency(1, per=commands.BucketType.default, wait=False)
|
||||
@commands.guild_only()
|
||||
@commands.has_permissions(administrator=True)
|
||||
async def whitelist_add(self, ctx, member: discord.Member = None):
|
||||
if ctx.guild.member_count < 2:
|
||||
view = CV2(f"{CROSS} Error", "Your Server Doesn't Meet My 30 Member Criteria")
|
||||
return await ctx.send(view=view)
|
||||
@@ -101,7 +119,7 @@ class Whitelist(commands.Cog):
|
||||
view = CV2(
|
||||
"__Whitelist Commands__",
|
||||
"**Adding a user to the whitelist means that no actions will be taken against them if they trigger the Anti-Nuke Module.**",
|
||||
f"**Usage**\n{ARROWRED} `{prefix}whitelist @user/id`\n{ARROWRED} `{prefix}wl @user`"
|
||||
f"**Usage**\n{ARROWRED} `{prefix}whitelist add @user/id`\n{ARROWRED} `{prefix}wl add @user`"
|
||||
)
|
||||
return await ctx.send(view=view)
|
||||
|
||||
@@ -252,7 +270,7 @@ class Whitelist(commands.Cog):
|
||||
await msg.edit(view=None)
|
||||
|
||||
|
||||
@commands.hybrid_command(name='whitelisted', aliases=['wlist'], help="Shows the list of whitelisted users.")
|
||||
@whitelist.command(name="list", aliases=["whitelisted", "wlist"], help="Shows the list of whitelisted users.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@@ -308,7 +326,7 @@ class Whitelist(commands.Cog):
|
||||
await ctx.send(view=view)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="whitelistreset", aliases=['wlreset'], help="Resets the whitelisted users.")
|
||||
@whitelist.command(name="reset", aliases=["whitelistreset", "wlreset"], help="Resets the whitelisted users.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 10, commands.BucketType.user)
|
||||
|
||||
@@ -181,13 +181,30 @@ class Automod(commands.Cog):
|
||||
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()
|
||||
|
||||
@commands.hybrid_group(invoke_without_command=True)
|
||||
@commands.hybrid_group(
|
||||
name="automod",
|
||||
aliases=["am"],
|
||||
invoke_without_command=True,
|
||||
with_app_command=True,
|
||||
description="Configure server Automod (enable, disable, punishments, ignores).",
|
||||
fallback="help",
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 4, commands.BucketType.user)
|
||||
async def automod(self, ctx):
|
||||
"""Automod root — use `/automod <subcommand>` or `>automod <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
@@ -500,7 +500,23 @@ class Customrole(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="staff",
|
||||
@commands.hybrid_group(
|
||||
name="customrole",
|
||||
aliases=["cr"],
|
||||
description="Assign or remove custom roles for members.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
async def customrole(self, context: Context):
|
||||
"""Customrole root — use `/customrole <subcommand>` or `>customrole <subcommand>`."""
|
||||
if context.subcommand_passed is None:
|
||||
await context.send_help(context.command)
|
||||
context.command.reset_cooldown(context)
|
||||
|
||||
@customrole.command(name="staff",
|
||||
description="Gives the staff role to the user.",
|
||||
aliases=['official'],
|
||||
help="Gives the staff role to the user.")
|
||||
@@ -511,7 +527,7 @@ class Customrole(commands.Cog):
|
||||
async def _staff(self, context: Context, member: discord.Member) -> None:
|
||||
await self.handle_role_command(context, member, 'staff')
|
||||
|
||||
@commands.hybrid_command(name="girl",
|
||||
@customrole.command(name="girl",
|
||||
description="Gives the girl role to the user.",
|
||||
aliases=['qt'],
|
||||
help="Gives the girl role to the user.")
|
||||
@@ -522,7 +538,7 @@ class Customrole(commands.Cog):
|
||||
async def _girl(self, context: Context, member: discord.Member) -> None:
|
||||
await self.handle_role_command(context, member, 'girl')
|
||||
|
||||
@commands.hybrid_command(name="vip",
|
||||
@customrole.command(name="vip",
|
||||
description="Gives the VIP role to the user.",
|
||||
help="Gives the VIP role to the user.")
|
||||
@blacklist_check()
|
||||
@@ -532,7 +548,7 @@ class Customrole(commands.Cog):
|
||||
async def _vip(self, context: Context, member: discord.Member) -> None:
|
||||
await self.handle_role_command(context, member, 'vip')
|
||||
|
||||
@commands.hybrid_command(name="guest",
|
||||
@customrole.command(name="guest",
|
||||
description="Gives the guest role to the user.",
|
||||
help="Gives the guest role to the user.")
|
||||
@blacklist_check()
|
||||
@@ -542,7 +558,7 @@ class Customrole(commands.Cog):
|
||||
async def _guest(self, context: Context, member: discord.Member) -> None:
|
||||
await self.handle_role_command(context, member, 'guest')
|
||||
|
||||
@commands.hybrid_command(name="friend",
|
||||
@customrole.command(name="friend",
|
||||
description="Gives the friend role to the user.",
|
||||
aliases=['frnd'],
|
||||
help="Gives the friend role to the user.")
|
||||
|
||||
@@ -125,7 +125,12 @@ class Extra(commands.Cog):
|
||||
self.color = 0xFF0000
|
||||
self.start_time = datetime.datetime.now()
|
||||
|
||||
@commands.hybrid_group(name="banner")
|
||||
@commands.hybrid_group(
|
||||
name="banner",
|
||||
description="Show user or server banners.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -900,6 +905,11 @@ class Extra(commands.Cog):
|
||||
f"**API (Roundtrip):** `{api_latency}ms`\n"
|
||||
f"**Database:** `{db_latency}`"
|
||||
)
|
||||
if self.bot.latencies and len(self.bot.latencies) > 1:
|
||||
shard_lines = "\n".join(
|
||||
f"**Shard {sid}:** `{round(lat * 1000)}ms`" for sid, lat in self.bot.latencies
|
||||
)
|
||||
latency_text += f"\n\n**Shards ({self.bot.shard_count or len(self.bot.latencies)}):**\n{shard_lines}"
|
||||
await msg.edit(view=CV2("System Latency Report", latency_text))
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,12 @@ class FilterCog(commands.Cog):
|
||||
self.active_filters[ctx.guild.id] = filter_name
|
||||
await ctx.send(embed=discord.Embed(description=f"Filter set to **{filter_name}**.", color=discord.Color.green()))
|
||||
|
||||
@commands.hybrid_group(invoke_without_command=True)
|
||||
@commands.hybrid_group(
|
||||
name="filter",
|
||||
description="Enable or disable audio filters for music.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
|
||||
@@ -86,7 +86,7 @@ class AvatarView(View):
|
||||
await interaction.response.edit_message(embed=embed)
|
||||
|
||||
|
||||
from utils.config import BotName
|
||||
from utils.config import BotName, DISCORD_CLIENT_ID
|
||||
|
||||
class General(commands.Cog):
|
||||
|
||||
@@ -309,7 +309,7 @@ class General(commands.Cog):
|
||||
invite_text = (
|
||||
"```Empower your server with blazing-fast features and 24/7 support!```\n"
|
||||
f"{AXIOM_LINKS} **Quick Actions**\n"
|
||||
f">>> **[Invite {BotName}](https://discord.com/oauth2/authorize?client_id=1396114795102470196&permissions=8&integration_type=0&scope=bot+applications.commands)**\n"
|
||||
f">>> **[Invite {BotName}](https://discord.com/oauth2/authorize?client_id={DISCORD_CLIENT_ID or (ctx.bot.user.id if ctx.bot.user else '')}&permissions=8&integration_type=0&scope=bot%20applications.commands)**\n"
|
||||
"**[Support Server](https://discord.gg/hexahost)**"
|
||||
)
|
||||
await ctx.send(view=CV2(f"{AXIOM_CONNECTION} {BotName} Integration Hub!", invite_text))
|
||||
@@ -159,7 +159,24 @@ class Giveaway(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(description="Starts a new giveaway.")
|
||||
@commands.hybrid_group(
|
||||
name="giveaway",
|
||||
aliases=["g"],
|
||||
description="Manage server giveaways.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@commands.has_guild_permissions(manage_guild=True)
|
||||
async def giveaway(self, ctx):
|
||||
"""Giveaway root — use `/giveaway <subcommand>` or `>giveaway <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
@giveaway.command(name="start", aliases=["gstart"], description="Starts a new giveaway.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@@ -244,7 +261,7 @@ class Giveaway(commands.Cog):
|
||||
print(f"Giveaway message deleted in {message.guild.name} - {message.guild.id}")
|
||||
await self.connection.commit()
|
||||
|
||||
@commands.hybrid_command(name="gend", description="Ends a giveaway before its ending time.", help="Ends a giveaway before its ending time.")
|
||||
@giveaway.command(name="end", aliases=["gend"], description="Ends a giveaway before its ending time.", help="Ends a giveaway before its ending time.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@@ -329,7 +346,7 @@ class Giveaway(commands.Cog):
|
||||
await ctx.send("Please reply to the giveaway message or provide the giveaway ID.")
|
||||
await self.connection.commit()
|
||||
|
||||
@commands.hybrid_command(description="Rerolls a giveaway on replying the giveaway message.", help="Rerolls a giveaway on replying the giveaway message.")
|
||||
@giveaway.command(name="reroll", aliases=["greroll"], description="Rerolls a giveaway on replying the giveaway message.", help="Rerolls a giveaway on replying the giveaway message.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
@@ -389,7 +406,7 @@ class Giveaway(commands.Cog):
|
||||
return val * time_dict[unit]
|
||||
|
||||
|
||||
@commands.hybrid_command(name="glist", description="Lists all ongoing giveaways.")
|
||||
@giveaway.command(name="list", aliases=["glist"], description="Lists all ongoing giveaways.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 5, commands.BucketType.user)
|
||||
|
||||
@@ -1598,7 +1598,12 @@ class Logging(commands.Cog):
|
||||
embeds.append(config_embed)
|
||||
return embeds
|
||||
|
||||
@commands.hybrid_group(invoke_without_command=True, name="log")
|
||||
@commands.hybrid_group(
|
||||
invoke_without_command=True,
|
||||
name="log",
|
||||
description="Configure server logging channels and events.",
|
||||
fallback="help",
|
||||
)
|
||||
async def log(self, ctx: commands.Context):
|
||||
"""Main logging command group. Works with both slash (/) and prefix (!) commands."""
|
||||
|
||||
|
||||
@@ -585,7 +585,23 @@ class Music(commands.Cog):
|
||||
bar = '█' * filled_length + '░' * (length - filled_length)
|
||||
return bar
|
||||
|
||||
@commands.command(name="play", aliases=['p'], usage="play <query>", help="Plays a song or playlist.")
|
||||
@commands.hybrid_group(
|
||||
name="music",
|
||||
aliases=["m"],
|
||||
description="Music player commands.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
async def music(self, ctx: commands.Context):
|
||||
"""Music root — use `/music <subcommand>` or `>music <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
@music.command(name="play", aliases=['p'], usage="music play <query>", help="Plays a song or playlist.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -594,7 +610,7 @@ class Music(commands.Cog):
|
||||
await self.play_source(ctx, query)
|
||||
|
||||
|
||||
@commands.command(name="search", usage="search <query>", help="Searches music from multiple platforms.")
|
||||
@music.command(name="search", usage="music search <query>", help="Searches music from multiple platforms.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -606,7 +622,7 @@ class Music(commands.Cog):
|
||||
await ctx.send(view=PlatformSelectView(ctx, query))
|
||||
|
||||
|
||||
@commands.command(name="nowplaying", aliases=["nop"], usage="nowplaying", help="Shows the info about current playing song.")
|
||||
@music.command(name="nowplaying", aliases=["nop"], usage="music nowplaying", help="Shows the info about current playing song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -659,7 +675,7 @@ class Music(commands.Cog):
|
||||
|
||||
await ctx.send(view=view)
|
||||
|
||||
@commands.command(name="autoplay", usage="autoplay", help="Toggles autoplay mode.")
|
||||
@music.command(name="autoplay", usage="music autoplay", help="Toggles autoplay mode.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -679,7 +695,7 @@ class Music(commands.Cog):
|
||||
)
|
||||
await ctx.send(view=CV2(f"{TICK} Autoplay {'enabled' if vc.autoplay == wavelink.AutoPlayMode.enabled else 'disabled'} by {ctx.author.mention}."))
|
||||
|
||||
@commands.command(name="loop", usage="loop", help="Toggles loop mode.")
|
||||
@music.command(name="loop", usage="music loop", help="Toggles loop mode.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -700,7 +716,7 @@ class Music(commands.Cog):
|
||||
await ctx.send(view=CV2("I'm not connected to a voice channel."))
|
||||
|
||||
|
||||
@commands.command(name="pause", usage="pause", help="Pauses the current song.")
|
||||
@music.command(name="pause", usage="music pause", help="Pauses the current song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -721,7 +737,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2(f"{WARNING} Nothing is playing or already paused."))
|
||||
|
||||
@commands.command(name="resume", usage="resume", help="Resumes the paused song.")
|
||||
@music.command(name="resume", usage="music resume", help="Resumes the paused song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -742,7 +758,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("Player is not paused."))
|
||||
|
||||
@commands.command(name="skip", usage="skip", help="Skips the current song.")
|
||||
@music.command(name="skip", usage="music skip", help="Skips the current song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -767,7 +783,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2(f"{WARNING} No song is playing or in the queue to skip."))
|
||||
|
||||
@commands.command(name="shuffle", usage="shuffle", help="Shuffles the queue.")
|
||||
@music.command(name="shuffle", usage="music shuffle", help="Shuffles the queue.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -787,7 +803,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("Queue is empty."))
|
||||
|
||||
@commands.command(name="stop", usage="stop", help="Stops the current song and clears the queue.")
|
||||
@music.command(name="stop", usage="music stop", help="Stops the current song and clears the queue.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -810,7 +826,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("Nothing is playing to stop."))
|
||||
|
||||
@commands.command(name="volume", aliases=["vol"], usage="volume <level>", help="Sets the volume of the player.")
|
||||
@music.command(name="volume", aliases=["vol"], usage="music volume <level>", help="Sets the volume of the player.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -834,7 +850,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("Bot is not connected to a voice channel."))
|
||||
|
||||
@commands.command(name="queue", usage="queue", help="Shows the current queue.")
|
||||
@music.command(name="queue", usage="music queue", help="Shows the current queue.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -860,7 +876,7 @@ class Music(commands.Cog):
|
||||
ctx=ctx)
|
||||
await paginator.paginate()
|
||||
|
||||
@commands.command(name="clearqueue", usage="clearqueue", help="Clears the queue.")
|
||||
@music.command(name="clearqueue", usage="music clearqueue", help="Clears the queue.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -881,7 +897,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("No queue to clear."))
|
||||
|
||||
@commands.command(name="replay", usage="replay", help="Replays the current song.")
|
||||
@music.command(name="replay", usage="music replay", help="Replays the current song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -902,7 +918,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("No track is currently playing."))
|
||||
|
||||
@commands.command(name="join", aliases=["connect"], usage="join", help="Joins the voice channel.")
|
||||
@music.command(name="join", aliases=["connect"], usage="music join", help="Joins the voice channel.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -913,7 +929,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("You need to join a voice channel first."))
|
||||
|
||||
@commands.hybrid_command(name="disconnect", aliases=["dc", "leave"], usage="disconnect", help="Disconnects the bot from the voice channel.")
|
||||
@music.command(name="disconnect", aliases=["dc", "leave"], usage="music disconnect", help="Disconnects the bot from the voice channel.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@@ -933,7 +949,7 @@ class Music(commands.Cog):
|
||||
else:
|
||||
await ctx.send(view=CV2("Bot is not connected to any voice channel."))
|
||||
|
||||
@commands.command(name="seek", usage="seek <percentage>", help="Seeks to a specific percentage of the song.")
|
||||
@music.command(name="seek", usage="music seek <percentage>", help="Seeks to a specific percentage of the song.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
|
||||
@@ -239,7 +239,7 @@ class Owner(commands.Cog):
|
||||
|
||||
@commands.command(name="getinvite", aliases=["gi", "guildinvite"])
|
||||
@commands.is_owner()
|
||||
async def getinvite(self, ctx: Context, guild= discord.Guild):
|
||||
async def getinvite(self, ctx: Context, guild: discord.Guild):
|
||||
if not guild:
|
||||
await ctx.send("Invalid server.")
|
||||
return
|
||||
@@ -300,6 +300,34 @@ class Owner(commands.Cog):
|
||||
with open('config.json', 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
@commands.hybrid_command(name="syncslash", help="Re-register all slash (/) commands with Discord.")
|
||||
@commands.is_owner()
|
||||
async def syncslash(self, ctx, scope: str = "all"):
|
||||
"""Force-sync slash commands. scope: all | global | clear"""
|
||||
if ctx.interaction:
|
||||
await ctx.defer(ephemeral=True)
|
||||
from utils.slash_sync import sync_slash_commands
|
||||
|
||||
scope = (scope or "all").lower().strip()
|
||||
if scope == "global":
|
||||
result = await sync_slash_commands(self.client, guilds=list(self.client.guilds), global_sync=True)
|
||||
elif scope in ("guild", "clear"):
|
||||
# Clear guild-scoped copies in this server (or all) to remove duplicates
|
||||
targets = [ctx.guild] if ctx.guild and scope == "clear" else list(self.client.guilds)
|
||||
if not targets:
|
||||
return await ctx.send("No guild to clear.")
|
||||
result = await sync_slash_commands(self.client, guilds=targets, global_sync=(scope != "clear"))
|
||||
else:
|
||||
result = await sync_slash_commands(self.client)
|
||||
|
||||
cleared = len(result.get("cleared_guilds") or [])
|
||||
err_n = len(result.get("errors") or [])
|
||||
await ctx.send(
|
||||
f"{TICK} Slash sync done — mode `{result.get('mode')}`, "
|
||||
f"tree `{result.get('local_tree')}`, global `{result.get('global')}`, "
|
||||
f"cleared guilds `{cleared}`, errors `{err_n}`."
|
||||
)
|
||||
|
||||
|
||||
@commands.command(name="owners")
|
||||
@commands.is_owner()
|
||||
|
||||
@@ -32,7 +32,7 @@ class Global(commands.Cog):
|
||||
await ctx.send_help(ctx.command)
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
|
||||
@commands.command(name="GB",help="Bans the user from all mutual guilds.")
|
||||
@commands.command(name="gb", help="Bans the user from all mutual guilds.")
|
||||
@commands.is_owner()
|
||||
async def global_ban(self, ctx: commands.Context, user: discord.User, reason: str = "Severe violations of Discord's terms of service."):
|
||||
mutual_guilds = [guild for guild in self.client.guilds if guild.get_member(user.id)]
|
||||
|
||||
@@ -51,10 +51,24 @@ TICKET_LIMIT_PER_USER = 3
|
||||
# --- Database Class ---
|
||||
class TicketDatabase:
|
||||
def __init__(self, path):
|
||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||
self.path = path
|
||||
self.conn = None
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
self.conn = sqlite3.connect(self.path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self._create_tables()
|
||||
|
||||
def _ensure_open(self):
|
||||
if self.conn is None:
|
||||
self._connect()
|
||||
return
|
||||
try:
|
||||
self.conn.execute("SELECT 1")
|
||||
except (sqlite3.ProgrammingError, sqlite3.OperationalError):
|
||||
self._connect()
|
||||
|
||||
def _create_tables(self):
|
||||
with self.conn:
|
||||
self.conn.execute("CREATE TABLE IF NOT EXISTS guild_configs (guild_id INTEGER PRIMARY KEY, panel_channel_id INTEGER, logging_channel_id INTEGER, panel_message_id INTEGER, panel_type TEXT, embed_title TEXT, embed_description TEXT, embed_color INTEGER, embed_image_url TEXT, embed_thumbnail_url TEXT, closed_category_id INTEGER)")
|
||||
@@ -63,13 +77,29 @@ class TicketDatabase:
|
||||
self.conn.execute("CREATE TABLE IF NOT EXISTS user_ticket_counts (guild_id INTEGER, user_id INTEGER, ticket_count INTEGER DEFAULT 0, PRIMARY KEY (guild_id, user_id))")
|
||||
|
||||
def execute(self, q, p=()):
|
||||
with self.conn: return self.conn.execute(q, p)
|
||||
self._ensure_open()
|
||||
with self.conn:
|
||||
return self.conn.execute(q, p)
|
||||
|
||||
def fetchone(self, q, p=()):
|
||||
cur = self.conn.cursor(); cur.execute(q, p); return cur.fetchone()
|
||||
self._ensure_open()
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(q, p)
|
||||
return cur.fetchone()
|
||||
|
||||
def fetchall(self, q, p=()):
|
||||
cur = self.conn.cursor(); cur.execute(q, p); return cur.fetchall()
|
||||
self._ensure_open()
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(q, p)
|
||||
return cur.fetchall()
|
||||
|
||||
def close(self):
|
||||
if self.conn: self.conn.close()
|
||||
if self.conn is not None:
|
||||
try:
|
||||
self.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.conn = None
|
||||
|
||||
# --- Utility Functions ---
|
||||
async def get_or_create_log_channel(db, guild):
|
||||
@@ -270,13 +300,30 @@ class CategoryConfigView(discord.ui.View):
|
||||
|
||||
class TicketCog(commands.Cog, name="Ticket System"):
|
||||
def __init__(self, bot):
|
||||
self.bot, self.db = bot, TicketDatabase(DB_PATH)
|
||||
self.bot.loop.create_task(self.load_persistent_views())
|
||||
self.bot = bot
|
||||
self.db = TicketDatabase(DB_PATH)
|
||||
self._views_task: asyncio.Task | None = None
|
||||
|
||||
async def cog_load(self):
|
||||
# Start after inject succeeds — avoids closed-DB race when add_cog fails/unloads
|
||||
self._views_task = asyncio.create_task(self.load_persistent_views())
|
||||
|
||||
async def load_persistent_views(self):
|
||||
await self.bot.wait_until_ready()
|
||||
for config in self.db.fetchall("SELECT guild_id, panel_message_id FROM guild_configs WHERE panel_message_id IS NOT NULL"):
|
||||
if view := self.create_panel_view(config['guild_id']): self.bot.add_view(view, message_id=config['panel_message_id'])
|
||||
try:
|
||||
await self.bot.wait_until_ready()
|
||||
configs = self.db.fetchall(
|
||||
"SELECT guild_id, panel_message_id FROM guild_configs WHERE panel_message_id IS NOT NULL"
|
||||
)
|
||||
for config in configs:
|
||||
if view := self.create_panel_view(config["guild_id"]):
|
||||
self.bot.add_view(view, message_id=config["panel_message_id"])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except sqlite3.ProgrammingError:
|
||||
# Cog unloaded / DB closed before ready — ignore
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Ticket persistent views load failed: {e}")
|
||||
|
||||
def create_panel_view(self, guild_id):
|
||||
config = self.db.fetchone("SELECT panel_type FROM guild_configs WHERE guild_id=?", (guild_id,))
|
||||
@@ -290,7 +337,10 @@ class TicketCog(commands.Cog, name="Ticket System"):
|
||||
for c in categories: view.add_item(discord.ui.Button(label=c['name'], style=discord.ButtonStyle(c['button_style']), emoji=c['emoji'], custom_id=f"create_ticket_{c['category_id']}"))
|
||||
return view
|
||||
|
||||
def cog_unload(self): self.db.close()
|
||||
def cog_unload(self):
|
||||
if self._views_task and not self._views_task.done():
|
||||
self._views_task.cancel()
|
||||
self.db.close()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_interaction(self, inter):
|
||||
@@ -328,7 +378,12 @@ class TicketCog(commands.Cog, name="Ticket System"):
|
||||
await ch.send(content=" ".join(pings), embed=ticket_embed, view=TicketActionsView(self, ch.id, cat_id))
|
||||
await inter.followup.send(f"Your ticket has been successfully created: {ch.mention}", ephemeral=True)
|
||||
|
||||
@commands.hybrid_group(name="ticket", description="Main command group for the ticket system.")
|
||||
@commands.hybrid_group(
|
||||
name="ticket",
|
||||
description="Main command group for the ticket system.",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@commands.guild_only()
|
||||
async def ticket(self, ctx):
|
||||
if ctx.invoked_subcommand is None: await ctx.send_help(ctx.command)
|
||||
|
||||
@@ -78,7 +78,13 @@ class Welcomer(commands.Cog):
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
@commands.hybrid_group(invoke_without_command=True, name="greet", help="Shows all the greet commands.")
|
||||
@commands.hybrid_group(
|
||||
invoke_without_command=True,
|
||||
name="greet",
|
||||
description="Configure welcome/greet messages for new members.",
|
||||
help="Shows all the greet commands.",
|
||||
fallback="help",
|
||||
)
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
async def greet(self, ctx: commands.Context):
|
||||
|
||||
@@ -12,40 +12,107 @@
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
import discord
|
||||
from utils.emoji import ARROWRED, ZMODULE
|
||||
from discord.utils import *
|
||||
from core import Axiom, Cog
|
||||
from utils.Tools import *
|
||||
from utils.config import BotName, serverLink
|
||||
from utils.config import BotName, BRAND_NAME, DISCORD_CLIENT_ID, serverLink
|
||||
from discord.ext import commands
|
||||
from discord.ui import Button, View
|
||||
|
||||
# HexaHost brand accent (matches dashboard primary/accent)
|
||||
EMBED_COLOR = 0xA348FF
|
||||
DASHBOARD_URL = "https://bot.hexahost.de"
|
||||
|
||||
|
||||
class Autorole(Cog):
|
||||
def __init__(self, bot: Axiom):
|
||||
self.bot = bot
|
||||
self.bot = bot
|
||||
|
||||
def _invite_url(self) -> str | None:
|
||||
client_id = DISCORD_CLIENT_ID or (str(self.bot.user.id) if self.bot.user else "")
|
||||
if not client_id:
|
||||
return None
|
||||
return (
|
||||
f"https://discord.com/oauth2/authorize?client_id={client_id}"
|
||||
f"&permissions=8&scope=bot%20applications.commands"
|
||||
)
|
||||
|
||||
def _welcome_embed(self, guild: discord.Guild, adder: discord.abc.User) -> discord.Embed:
|
||||
bot_avatar = guild.me.display_avatar.url if guild.me else (
|
||||
self.bot.user.display_avatar.url if self.bot.user else None
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Thanks for adding {BotName}",
|
||||
description=(
|
||||
f"Hey {adder.mention}, **{BotName}** is now in **{guild.name}**.\n\n"
|
||||
"Here's everything you need to get started:"
|
||||
),
|
||||
color=EMBED_COLOR,
|
||||
)
|
||||
|
||||
if bot_avatar:
|
||||
embed.set_author(name=BotName, icon_url=bot_avatar)
|
||||
embed.set_thumbnail(url=bot_avatar)
|
||||
|
||||
embed.add_field(
|
||||
name="Prefix",
|
||||
value="Default prefix is `>`\nExample: `>help`",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Slash commands",
|
||||
value="Type `/` in your server to browse available commands.",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Dashboard",
|
||||
value=f"Configure modules online at **[bot.hexahost.de]({DASHBOARD_URL})**",
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Need help?",
|
||||
value=f"Join our **[Support Server]({serverLink})** for guides, FAQ and assistance.",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
embed.set_footer(text=f"Powered by HexaHost · {BRAND_NAME}")
|
||||
return embed
|
||||
|
||||
def _welcome_view(self) -> View:
|
||||
view = View()
|
||||
view.add_item(
|
||||
Button(label="Open Dashboard", style=discord.ButtonStyle.link, url=DASHBOARD_URL)
|
||||
)
|
||||
view.add_item(
|
||||
Button(label="Support Server", style=discord.ButtonStyle.link, url=serverLink)
|
||||
)
|
||||
invite = self._invite_url()
|
||||
if invite:
|
||||
view.add_item(
|
||||
Button(label="Add to another server", style=discord.ButtonStyle.link, url=invite)
|
||||
)
|
||||
return view
|
||||
|
||||
@commands.Cog.listener(name="on_guild_join")
|
||||
async def send_msg_to_adder(self, guild: discord.Guild):
|
||||
async for entry in guild.audit_logs(limit=3):
|
||||
if entry.action == discord.AuditLogAction.bot_add:
|
||||
embed = discord.Embed(
|
||||
description=f"{ZMODULE} **Thanks for adding me.**\n\n{ARROWRED} My default prefix is `>`\n{ARROWRED}> Use the `>help` command to see a list of commands\n{ARROWRED} For detailed guides, FAQ and information, visit our **[Support Server](https://discord.gg/hexahost)**",
|
||||
color=0xFF0000
|
||||
)
|
||||
embed.set_thumbnail(url=entry.user.avatar.url if entry.user.avatar else entry.user.default_avatar.url)
|
||||
embed.set_author(name=f"{guild.name}", icon_url=guild.me.display_avatar.url)
|
||||
|
||||
website_button = Button(label='Website', style=discord.ButtonStyle.link, url='https://.vercel.app')
|
||||
support_button = Button(label='Support', style=discord.ButtonStyle.link, url='https://discord.gg/hexahost')
|
||||
vote_button = Button(label='Vote for Me', style=discord.ButtonStyle.link, url=f'https://top.gg/bot/{self.bot.user.id}/vote')
|
||||
view = View()
|
||||
view.add_item(support_button)
|
||||
#view.add_item(website_button)
|
||||
#view.add_item(vote_button)
|
||||
if guild.icon:
|
||||
embed.set_author(name=guild.name, icon_url=guild.icon.url)
|
||||
try:
|
||||
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.bot_add):
|
||||
if entry.target is None or entry.user is None:
|
||||
continue
|
||||
if entry.target.id != self.bot.user.id:
|
||||
continue
|
||||
|
||||
embed = self._welcome_embed(guild, entry.user)
|
||||
view = self._welcome_view()
|
||||
try:
|
||||
await entry.user.send(embed=embed, view=view)
|
||||
except discord.Forbidden:
|
||||
# User has DMs closed — ignore silently
|
||||
pass
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(f"Welcome DM failed: {e}")
|
||||
break
|
||||
except discord.Forbidden:
|
||||
# Missing View Audit Log permission
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Welcome DM (audit log) failed: {e}")
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
from utils.db_paths import db_path
|
||||
|
||||
from utils import getConfig
|
||||
from utils.config import BotName
|
||||
from utils.config import BotName, DISCORD_CLIENT_ID
|
||||
import discord
|
||||
from utils.emoji import ARROWRED, CODEBASE, HEART3, INDEX, AXIOM_LINKS
|
||||
from discord.ui import LayoutView, TextDisplay, Separator, Container, ActionRow, Select
|
||||
@@ -87,7 +87,7 @@ class MentionSelectView(LayoutView):
|
||||
)
|
||||
elif selected == "Links":
|
||||
content = (
|
||||
f"**[Invite {BotName}](https://discord.com/oauth2/authorize?client_id=1396114795102470196)**\n"
|
||||
f"**[Invite {BotName}](https://discord.com/oauth2/authorize?client_id={DISCORD_CLIENT_ID or (self.bot.user.id if self.bot.user else '')}&permissions=8&scope=bot%20applications.commands)**\n"
|
||||
"**[Join Support Server](https://discord.gg/hexahost)**"
|
||||
)
|
||||
|
||||
|
||||
@@ -99,25 +99,37 @@ Threads : {len(guild.threads)}
|
||||
await guild.chunk()
|
||||
|
||||
embed = discord.Embed(
|
||||
description=f"{ARROWRED} Prefix For This Server is `>`\n{ARROWRED} Get Started with `>help`\n{ARROWRED} For detailed guides, FAQ & information, visit our **[Support Server](https://discord.gg/hexahost)**",
|
||||
color=0xFF0000,
|
||||
title=f"{BRAND_NAME} joined the server",
|
||||
description=(
|
||||
f"**{BRAND_NAME}** is ready to use.\n\n"
|
||||
f"▸ Default prefix: `>`\n"
|
||||
f"▸ Get started with `>help` or type `/`\n"
|
||||
f"▸ Dashboard: **[bot.hexahost.de](https://bot.hexahost.de)**\n"
|
||||
f"▸ Support: **[discord.gg/hexahost](https://discord.gg/hexahost)**"
|
||||
),
|
||||
color=0xA348FF,
|
||||
)
|
||||
embed.set_author(
|
||||
name="Thanks for adding me!", icon_url=guild.me.display_avatar.url
|
||||
name=BRAND_NAME,
|
||||
icon_url=guild.me.display_avatar.url if guild.me else None,
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Powered by {BRAND_NAME}™",
|
||||
)
|
||||
if guild.icon:
|
||||
embed.set_thumbnail(url=guild.icon.url)
|
||||
embed.set_footer(text=f"Powered by HexaHost · {BRAND_NAME}")
|
||||
if guild.me:
|
||||
embed.set_thumbnail(url=guild.me.display_avatar.url)
|
||||
|
||||
support = Button(
|
||||
label="Support",
|
||||
label="Support Server",
|
||||
style=discord.ButtonStyle.link,
|
||||
url=f"https://discord.gg/hexahost",
|
||||
url="https://discord.gg/hexahost",
|
||||
)
|
||||
dashboard = Button(
|
||||
label="Open Dashboard",
|
||||
style=discord.ButtonStyle.link,
|
||||
url="https://bot.hexahost.de",
|
||||
)
|
||||
|
||||
view = View()
|
||||
view.add_item(dashboard)
|
||||
view.add_item(support)
|
||||
channel = discord.utils.get(guild.text_channels, name="general")
|
||||
if not channel:
|
||||
|
||||
@@ -25,7 +25,7 @@ class Ban(commands.Cog):
|
||||
def get_user_avatar(self, user):
|
||||
return user.avatar.url if user.avatar else user.default_avatar.url
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="ban",
|
||||
help="Bans a user from the Server",
|
||||
usage="ban <member>",
|
||||
|
||||
@@ -20,7 +20,7 @@ class Hide(commands.Cog):
|
||||
self.bot = bot
|
||||
self.color = discord.Color.from_rgb(255, 0, 0) # Red color for embeds
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="hide",
|
||||
help="Hides a channel from the default role (@everyone).",
|
||||
usage="hide [channel]",
|
||||
|
||||
@@ -22,7 +22,7 @@ class Kick(commands.Cog):
|
||||
# Color is set to Red (255, 0, 0) as requested
|
||||
self.color = discord.Color.from_rgb(255, 0, 0)
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="kick",
|
||||
help="Kicks a member from the server.",
|
||||
usage="kick <member> [reason]",
|
||||
|
||||
@@ -20,7 +20,7 @@ class Lock(commands.Cog):
|
||||
self.bot = bot
|
||||
self.color = discord.Color.red()
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="lock",
|
||||
description="Locks a channel to prevent sending messages.",
|
||||
aliases=["lockchannel"]
|
||||
|
||||
224
bot/cogs/moderation/mod_hub.py
Normal file
224
bot/cogs/moderation/mod_hub.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ |H|e|x|a|H|o|s|t| ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 HexaHost — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/hexahost ║
|
||||
# ║ github ── https://github.com/theoneandonlymace ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class ModHub(commands.Cog):
|
||||
"""Slash/prefix hub that exposes moderation actions under `/mod <subcommand>`."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def _get_cog(self, ctx: commands.Context, name: str):
|
||||
cog = self.bot.get_cog(name)
|
||||
if not cog:
|
||||
await ctx.send(f"{name} module unavailable.")
|
||||
return None
|
||||
return cog
|
||||
|
||||
@commands.hybrid_group(
|
||||
name="mod",
|
||||
description="Moderation commands",
|
||||
fallback="help",
|
||||
invoke_without_command=True,
|
||||
)
|
||||
@commands.guild_only()
|
||||
async def mod(self, ctx: commands.Context):
|
||||
"""Moderation root — use `/mod <subcommand>` or `>mod <subcommand>`."""
|
||||
if ctx.subcommand_passed is None:
|
||||
await ctx.send_help(ctx.command)
|
||||
|
||||
@mod.command(name="ban", description="Ban a user")
|
||||
@commands.has_permissions(ban_members=True)
|
||||
@commands.bot_has_permissions(ban_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_ban(self, ctx: commands.Context, user: discord.User, *, reason: str = None):
|
||||
cog = await self._get_cog(ctx, "Ban")
|
||||
if not cog:
|
||||
return
|
||||
await cog.ban(ctx, user, reason=reason)
|
||||
|
||||
@mod.command(name="unban", description="Unban a user")
|
||||
@commands.has_permissions(ban_members=True)
|
||||
@commands.bot_has_permissions(ban_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_unban(self, ctx: commands.Context, user: discord.User, *, reason: str = None):
|
||||
cog = await self._get_cog(ctx, "Unban")
|
||||
if not cog:
|
||||
return
|
||||
await cog.unban(ctx, user, reason=reason)
|
||||
|
||||
@mod.command(name="kick", description="Kick a member")
|
||||
@commands.has_permissions(kick_members=True)
|
||||
@commands.bot_has_permissions(kick_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_kick(self, ctx: commands.Context, member: discord.Member, *, reason: str = None):
|
||||
cog = await self._get_cog(ctx, "Kick")
|
||||
if not cog:
|
||||
return
|
||||
await cog.kick_command(ctx, member, reason=reason)
|
||||
|
||||
@mod.command(name="mute", description="Mute / timeout a member")
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
@commands.bot_has_permissions(moderate_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_mute(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.Member,
|
||||
time: str = None,
|
||||
*,
|
||||
reason: str = None,
|
||||
):
|
||||
cog = await self._get_cog(ctx, "Mute")
|
||||
if not cog:
|
||||
return
|
||||
await cog.mute(ctx, user, time, reason=reason)
|
||||
|
||||
@mod.command(name="unmute", description="Remove a timeout from a member")
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
@commands.bot_has_permissions(moderate_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_unmute(self, ctx: commands.Context, user: discord.Member):
|
||||
cog = await self._get_cog(ctx, "Unmute")
|
||||
if not cog:
|
||||
return
|
||||
await cog.unmute(ctx, user)
|
||||
|
||||
@mod.command(name="warn", description="Warn a member")
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_warn(self, ctx: commands.Context, user: discord.Member, *, reason: str = None):
|
||||
cog = await self._get_cog(ctx, "Warn")
|
||||
if not cog:
|
||||
return
|
||||
await cog.warn(ctx, user, reason=reason)
|
||||
|
||||
@mod.command(name="clearwarns", description="Clear warnings for a member")
|
||||
@commands.has_permissions(moderate_members=True)
|
||||
@commands.guild_only()
|
||||
async def mod_clearwarns(self, ctx: commands.Context, user: discord.Member):
|
||||
cog = await self._get_cog(ctx, "Warn")
|
||||
if not cog:
|
||||
return
|
||||
await cog.clearwarns(ctx, user)
|
||||
|
||||
@mod.command(name="lock", description="Lock a channel")
|
||||
@commands.has_permissions(manage_channels=True)
|
||||
@commands.bot_has_permissions(manage_channels=True)
|
||||
@commands.guild_only()
|
||||
async def mod_lock(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||
cog = await self._get_cog(ctx, "Lock")
|
||||
if not cog:
|
||||
return
|
||||
await cog.lock_command(ctx, channel)
|
||||
|
||||
@mod.command(name="unlock", description="Unlock a channel")
|
||||
@commands.has_permissions(manage_roles=True)
|
||||
@commands.bot_has_permissions(manage_roles=True)
|
||||
@commands.guild_only()
|
||||
async def mod_unlock(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||
cog = await self._get_cog(ctx, "Unlock")
|
||||
if not cog:
|
||||
return
|
||||
await cog.unlock_command(ctx, channel)
|
||||
|
||||
@mod.command(name="hide", description="Hide a channel")
|
||||
@commands.has_permissions(manage_channels=True)
|
||||
@commands.bot_has_permissions(manage_channels=True)
|
||||
@commands.guild_only()
|
||||
async def mod_hide(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||
cog = await self._get_cog(ctx, "Hide")
|
||||
if not cog:
|
||||
return
|
||||
await cog.hide_command(ctx, channel)
|
||||
|
||||
@mod.command(name="unhide", description="Unhide a channel")
|
||||
@commands.has_permissions(manage_channels=True)
|
||||
@commands.bot_has_permissions(manage_channels=True)
|
||||
@commands.guild_only()
|
||||
async def mod_unhide(self, ctx: commands.Context, channel: discord.TextChannel = None):
|
||||
cog = await self._get_cog(ctx, "Unhide")
|
||||
if not cog:
|
||||
return
|
||||
await cog.unhide_command(ctx, channel)
|
||||
|
||||
@mod.command(name="lockall", description="Lock all channels")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def mod_lockall(self, ctx: commands.Context):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog.lockall(ctx)
|
||||
|
||||
@mod.command(name="unlockall", description="Unlock all channels")
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.guild_only()
|
||||
async def mod_unlockall(self, ctx: commands.Context):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog.unlockall(ctx)
|
||||
|
||||
@mod.command(name="nuke", description="Nuke the current channel")
|
||||
@commands.has_permissions(manage_channels=True)
|
||||
@commands.guild_only()
|
||||
async def mod_nuke(self, ctx: commands.Context):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog._nuke(ctx)
|
||||
|
||||
@mod.command(name="slowmode", description="Set channel slowmode")
|
||||
@commands.has_permissions(manage_messages=True)
|
||||
@commands.guild_only()
|
||||
async def mod_slowmode(self, ctx: commands.Context, seconds: int = 0):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog._slowmode(ctx, seconds)
|
||||
|
||||
@mod.command(name="nick", description="Change a member's nickname")
|
||||
@commands.has_permissions(manage_nicknames=True)
|
||||
@commands.bot_has_permissions(manage_nicknames=True)
|
||||
@commands.guild_only()
|
||||
async def mod_nick(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
member: discord.Member,
|
||||
*,
|
||||
name: str = None,
|
||||
):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog.changenickname(ctx, member, name=name)
|
||||
|
||||
@mod.command(name="audit", description="View recent audit log entries")
|
||||
@commands.has_permissions(view_audit_log=True)
|
||||
@commands.bot_has_permissions(view_audit_log=True)
|
||||
@commands.guild_only()
|
||||
async def mod_audit(self, ctx: commands.Context, limit: int):
|
||||
cog = await self._get_cog(ctx, "Moderation")
|
||||
if not cog:
|
||||
return
|
||||
await cog.auditlog(ctx, limit)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(ModHub(bot))
|
||||
@@ -119,14 +119,14 @@ class Moderation(commands.Cog):
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
async def enlarge(self, ctx, emoji: Union[discord.Emoji, discord.PartialEmoji, str]):
|
||||
async def enlarge(self, ctx, emoji: str):
|
||||
url = emoji.url
|
||||
await ctx.send(url)
|
||||
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="unlockall",
|
||||
@commands.command(name="unlockall",
|
||||
help="Unlocks all channels in the Guild.",
|
||||
usage="unlockall")
|
||||
@blacklist_check()
|
||||
@@ -211,7 +211,7 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="lockall",
|
||||
@commands.command(name="lockall",
|
||||
help="locks all the channels in Guild.",
|
||||
usage="lockall")
|
||||
@blacklist_check()
|
||||
@@ -294,7 +294,7 @@ class Moderation(commands.Cog):
|
||||
await ctx.send(embed=denied, mention_author=False)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="give",
|
||||
@commands.command(name="give",
|
||||
help="Gives the mentioned user a role.",
|
||||
usage="give <user> <role>",
|
||||
aliases=["addrole"])
|
||||
@@ -364,7 +364,7 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="hideall", help="Hides all the channels .",
|
||||
@commands.command(name="hideall", help="Hides all the channels .",
|
||||
usage="hideall")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@@ -442,7 +442,7 @@ class Moderation(commands.Cog):
|
||||
icon_url=ctx.author.avatar.url if ctx.author.avatar else ctx.author.default_avatar.url)
|
||||
await ctx.send(embed=denied, mention_author=False)
|
||||
|
||||
@commands.hybrid_command(name="unhideall", help="Unhides all the channels in the server.",
|
||||
@commands.command(name="unhideall", help="Unhides all the channels in the server.",
|
||||
usage="unhideall")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@@ -522,7 +522,7 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="prefix",
|
||||
aliases=["setprefix", "prefixset"],
|
||||
help="Allows you to change the prefix of the bot for this server"
|
||||
@@ -560,7 +560,7 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="clone", help="Clones a channel.")
|
||||
@commands.command(name="clone", help="Clones a channel.")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.has_permissions(manage_channels=True)
|
||||
@@ -604,7 +604,7 @@ class Moderation(commands.Cog):
|
||||
await ctx.send(embed=error)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="nick",
|
||||
@commands.command(name="nick",
|
||||
aliases=['setnick'],
|
||||
help="To change someone's nickname.",
|
||||
usage="nick [member]")
|
||||
@@ -680,7 +680,7 @@ class Moderation(commands.Cog):
|
||||
await ctx.send(embed=error)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="nuke", help="Nukes a channel", usage="nuke")
|
||||
@commands.command(name="nuke", help="Nukes a channel", usage="nuke")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@top_check()
|
||||
@@ -744,7 +744,7 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="slowmode",
|
||||
@commands.command(name="slowmode",
|
||||
help="Changes the slowmode",
|
||||
usage="slowmode [seconds]",
|
||||
aliases=["slow"])
|
||||
@@ -774,7 +774,7 @@ class Moderation(commands.Cog):
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
@commands.hybrid_command(name="unslowmode",
|
||||
@commands.command(name="unslowmode",
|
||||
help="Disables slowmode",
|
||||
usage="unslowmode",
|
||||
aliases=["unslow"])
|
||||
@@ -870,7 +870,7 @@ class Moderation(commands.Cog):
|
||||
@commands.cooldown(1, 3, commands.BucketType.user)
|
||||
@commands.has_permissions(administrator=True)
|
||||
@commands.bot_has_guild_permissions(manage_roles=True)
|
||||
async def roleicon(self, ctx: commands.Context, role: discord.Role, *, icon: Union[discord.Emoji, discord.PartialEmoji, str] = None):
|
||||
async def roleicon(self, ctx: commands.Context, role: discord.Role, *, icon: str = None):
|
||||
|
||||
if role.position >= ctx.guild.me.top_role.position:
|
||||
error_embed = discord.Embed(
|
||||
@@ -976,11 +976,10 @@ class Moderation(commands.Cog):
|
||||
|
||||
|
||||
|
||||
@commands.hybrid_command(name="unbanall",
|
||||
@commands.command(name="unbanall",
|
||||
help="Unbans Everyone In The Guild!",
|
||||
aliases=['massunban'],
|
||||
usage="Unbanall",
|
||||
with_app_command=True)
|
||||
usage="Unbanall")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
@commands.cooldown(1, 30, commands.BucketType.user)
|
||||
@@ -1041,7 +1040,7 @@ class Moderation(commands.Cog):
|
||||
view.add_item(button1)
|
||||
await ctx.reply(embed=embed, view=view, mention_author=False)
|
||||
|
||||
@commands.hybrid_command(name="audit",
|
||||
@commands.command(name="audit",
|
||||
help="See recents audit log action in the server .")
|
||||
@blacklist_check()
|
||||
@ignore_check()
|
||||
|
||||
@@ -132,7 +132,7 @@ class Mute(commands.Cog):
|
||||
return timedelta(days=time_value), f"{time_value} days"
|
||||
return None, None
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="mute",
|
||||
help="Mutes a user with optional time and reason",
|
||||
usage="mute <member> [time] [reason]",
|
||||
|
||||
@@ -132,7 +132,7 @@ class Unban(commands.Cog):
|
||||
def get_user_avatar(self, user):
|
||||
return user.avatar.url if user.avatar else user.default_avatar.url
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="unban",
|
||||
help="Unbans a user from the Server",
|
||||
usage="unban <member>",
|
||||
|
||||
@@ -20,7 +20,7 @@ class Unhide(commands.Cog):
|
||||
self.bot = bot
|
||||
self.color = discord.Color.from_rgb(255,0, 0) # Green color for success
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="unhide",
|
||||
help="Unhides a channel for the default role (@everyone).",
|
||||
usage="unhide [channel]",
|
||||
|
||||
@@ -69,7 +69,7 @@ class Unlock(commands.Cog):
|
||||
self.bot = bot
|
||||
self.color = discord.Color.from_rgb(255, 0, 0)
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="unlock",
|
||||
help="Unlocks a channel to allow sending messages.",
|
||||
usage="unlock <channel>",
|
||||
|
||||
@@ -140,7 +140,7 @@ class Unmute(commands.Cog):
|
||||
def get_user_avatar(self, user):
|
||||
return user.avatar.url if user.avatar else user.default_avatar.url
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="unmute",
|
||||
help="Unmutes a user from the Server",
|
||||
usage="unmute <member>",
|
||||
|
||||
@@ -94,7 +94,7 @@ class Warn(commands.Cog):
|
||||
except Exception as e:
|
||||
print(f"Error during database setup: {e}")
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="warn",
|
||||
help="Warn a user in the server",
|
||||
usage="warn <user> [reason]",
|
||||
@@ -156,7 +156,7 @@ class Warn(commands.Cog):
|
||||
await ctx.send(f"An error occurred: {str(e)}")
|
||||
print(f"Error during warn command: {e}")
|
||||
|
||||
@commands.hybrid_command(
|
||||
@commands.command(
|
||||
name="clearwarns",
|
||||
help="Clear all warnings for a user",
|
||||
aliases=["clearwarn" , "clearwarnings"],
|
||||
|
||||
@@ -19,7 +19,7 @@ import aiohttp
|
||||
import typing
|
||||
from typing import List
|
||||
import aiosqlite
|
||||
from utils.config import OWNER_IDS, BotName
|
||||
from utils.config import OWNER_IDS, BotName, get_shard_kwargs
|
||||
from utils import getConfig, updateConfig
|
||||
from utils.Tools import setup_db
|
||||
from .Context import Context
|
||||
@@ -39,6 +39,11 @@ class Axiom(commands.AutoShardedBot):
|
||||
intents = discord.Intents.all()
|
||||
intents.presences = True
|
||||
intents.members = True
|
||||
shard_kwargs = get_shard_kwargs()
|
||||
# Allow callers to override shard settings via kwargs
|
||||
for key in ("shard_count", "shard_ids"):
|
||||
if key in kwargs:
|
||||
shard_kwargs[key] = kwargs.pop(key)
|
||||
super().__init__(command_prefix=self.get_prefix,
|
||||
case_insensitive=True,
|
||||
intents=intents,
|
||||
@@ -48,11 +53,10 @@ class Axiom(commands.AutoShardedBot):
|
||||
owner_ids=OWNER_IDS,
|
||||
allowed_mentions=discord.AllowedMentions(
|
||||
everyone=False, replied_user=False, roles=False),
|
||||
sync_commands_debug=True,
|
||||
sync_commands=True,
|
||||
shard_count=1)
|
||||
**shard_kwargs)
|
||||
self.status_index = 0
|
||||
self.status_list = []
|
||||
self._slash_synced = False
|
||||
|
||||
async def setup_hook(self):
|
||||
await setup_db()
|
||||
|
||||
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)),
|
||||
)
|
||||
@@ -77,6 +77,51 @@ def _parse_ids(env_key: str) -> list[int]:
|
||||
OWNER_IDS: list[int] = _parse_ids("OWNER_IDS")
|
||||
OWNER_IDS_STR: list[str] = [str(i) for i in OWNER_IDS]
|
||||
|
||||
# Discord application / OAuth client id (same as dashboard DISCORD_CLIENT_ID)
|
||||
DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID", "").strip()
|
||||
|
||||
# ── Discord sharding ──────────────────────────────────────────────────────────
|
||||
# SHARD_COUNT empty = AutoShardedBot asks Discord for recommended count (in-process).
|
||||
# SHARD_IDS (e.g. "0,1") + SHARD_COUNT = this process only runs those shards (cluster).
|
||||
# Primary process runs API / slash sync / emoji sync / stats (shard 0 or override).
|
||||
|
||||
def _parse_optional_int(env_key: str) -> int | None:
|
||||
raw = os.getenv(env_key, "").strip()
|
||||
if not raw or not raw.isdigit():
|
||||
return None
|
||||
return int(raw)
|
||||
|
||||
|
||||
SHARD_COUNT: int | None = _parse_optional_int("SHARD_COUNT")
|
||||
SHARD_IDS: list[int] = _parse_ids("SHARD_IDS")
|
||||
|
||||
|
||||
def get_shard_kwargs() -> dict:
|
||||
"""Kwargs for AutoShardedBot.__init__ (omit keys for full auto-sharding)."""
|
||||
kwargs: dict = {}
|
||||
if SHARD_COUNT is not None:
|
||||
kwargs["shard_count"] = SHARD_COUNT
|
||||
if SHARD_IDS:
|
||||
if SHARD_COUNT is None:
|
||||
raise ValueError("SHARD_IDS requires SHARD_COUNT to be set")
|
||||
kwargs["shard_ids"] = SHARD_IDS
|
||||
kwargs["shard_count"] = SHARD_COUNT
|
||||
return kwargs
|
||||
|
||||
|
||||
def _resolve_is_shard_primary() -> bool:
|
||||
raw = os.getenv("SHARD_PRIMARY", "").strip().lower()
|
||||
if raw in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if raw in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if not SHARD_IDS:
|
||||
return True
|
||||
return 0 in SHARD_IDS
|
||||
|
||||
|
||||
IS_SHARD_PRIMARY: bool = _resolve_is_shard_primary()
|
||||
|
||||
# Aliases kept for backwards compatibility with files that import these names
|
||||
BOT_OWNER_IDS = OWNER_IDS
|
||||
BOT_OWNER_IDS_STR = OWNER_IDS_STR
|
||||
|
||||
220
bot/utils/slash_sync.py
Normal file
220
bot/utils/slash_sync.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# ╔══════════════════════════════════════════════════════════════════╗
|
||||
# ║ ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ |H|e|x|a|H|o|s|t| ║
|
||||
# ║ +-+-+-+-+-+-+-+-+ ║
|
||||
# ║ ║
|
||||
# ║ © 2026 HexaHost — All Rights Reserved ║
|
||||
# ║ ║
|
||||
# ║ discord ── https://discord.gg/hexahost ║
|
||||
# ║ github ── https://github.com/theoneandonlymace ║
|
||||
# ║ ║
|
||||
# ╚══════════════════════════════════════════════════════════════════╝
|
||||
"""Register slash (/) commands with Discord.
|
||||
|
||||
IMPORTANT: Never register the same commands both globally AND per-guild —
|
||||
Discord shows them twice in that server (e.g. /afk, /2048 duplicated).
|
||||
|
||||
Modes (SLASH_SYNC_MODE):
|
||||
global — default. Sync once worldwide; clears leftover guild copies.
|
||||
guild — instant in connected servers only (no global registration).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("axiom.slash_sync")
|
||||
|
||||
MAX_TOP_LEVEL = 100
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
raw = os.getenv(key)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _parse_guild_ids(raw: str | None) -> list[int]:
|
||||
if not raw:
|
||||
return []
|
||||
return [int(p.strip()) for p in raw.split(",") if p.strip().isdigit()]
|
||||
|
||||
|
||||
def _sync_mode() -> str:
|
||||
"""
|
||||
Resolve sync mode.
|
||||
- SLASH_SYNC_MODE=global|guild
|
||||
- Legacy: SLASH_SYNC_ALL_GUILDS / SLASH_GUILD_IDS imply guild mode if set true/nonempty
|
||||
while SLASH_SYNC_GLOBAL=false
|
||||
"""
|
||||
mode = (os.getenv("SLASH_SYNC_MODE") or "").strip().lower()
|
||||
if mode in ("global", "guild"):
|
||||
return mode
|
||||
|
||||
# Legacy env compatibility — prefer no duplicates (global) unless guild-only requested
|
||||
if _env_bool("SLASH_SYNC_ALL_GUILDS", False) or _parse_guild_ids(os.getenv("SLASH_GUILD_IDS")):
|
||||
if not _env_bool("SLASH_SYNC_GLOBAL", True):
|
||||
return "guild"
|
||||
return "global"
|
||||
|
||||
|
||||
def _tree_command_count(bot: "commands.Bot") -> int:
|
||||
return len(bot.tree.get_commands())
|
||||
|
||||
|
||||
def _prune_tree_to_limit(bot: "commands.Bot", limit: int = MAX_TOP_LEVEL) -> list[str]:
|
||||
commands_list = list(bot.tree.get_commands())
|
||||
if len(commands_list) <= limit:
|
||||
return []
|
||||
|
||||
singles = [c for c in commands_list if not isinstance(c, app_commands.Group)]
|
||||
groups = [c for c in commands_list if isinstance(c, app_commands.Group)]
|
||||
ordered = groups + singles
|
||||
removed: list[str] = []
|
||||
while len(ordered) > limit:
|
||||
cmd = ordered.pop()
|
||||
bot.tree.remove_command(cmd.name)
|
||||
removed.append(cmd.name)
|
||||
return removed
|
||||
|
||||
|
||||
async def clear_guild_commands(
|
||||
bot: "commands.Bot",
|
||||
guild: discord.abc.Snowflake,
|
||||
) -> None:
|
||||
"""Remove guild-scoped slash commands so only global ones remain (fixes duplicates)."""
|
||||
bot.tree.clear_commands(guild=guild)
|
||||
await bot.tree.sync(guild=guild)
|
||||
|
||||
|
||||
async def sync_guild(
|
||||
bot: "commands.Bot",
|
||||
guild: discord.abc.Snowflake,
|
||||
*,
|
||||
copy_global: bool = True,
|
||||
) -> list[app_commands.AppCommand]:
|
||||
if copy_global:
|
||||
bot.tree.copy_global_to(guild=guild)
|
||||
return await bot.tree.sync(guild=guild)
|
||||
|
||||
|
||||
async def sync_slash_commands(
|
||||
bot: "commands.Bot",
|
||||
*,
|
||||
guilds: Optional[Iterable[discord.abc.Snowflake]] = None,
|
||||
global_sync: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Sync application commands without creating global+guild duplicates.
|
||||
|
||||
Env:
|
||||
SLASH_SYNC_MODE=global|guild (default: global)
|
||||
SLASH_GUILD_IDS=id,id (optional guild list for guild mode)
|
||||
SLASH_CLEAR_GUILD_COMMANDS=true (default true in global mode — removes old guild copies)
|
||||
"""
|
||||
mode = _sync_mode()
|
||||
clear_guild = _env_bool("SLASH_CLEAR_GUILD_COMMANDS", True)
|
||||
env_guild_ids = _parse_guild_ids(os.getenv("SLASH_GUILD_IDS"))
|
||||
|
||||
removed = _prune_tree_to_limit(bot)
|
||||
if removed:
|
||||
print(
|
||||
f"\033[33m◈ Slash tree pruned by {len(removed)} (Discord max {MAX_TOP_LEVEL} top-level)\033[0m"
|
||||
)
|
||||
|
||||
local_count = _tree_command_count(bot)
|
||||
result: dict = {
|
||||
"mode": mode,
|
||||
"local_tree": local_count,
|
||||
"global": None,
|
||||
"guilds": {},
|
||||
"cleared_guilds": [],
|
||||
"errors": [],
|
||||
"pruned": removed,
|
||||
}
|
||||
|
||||
async def _try_sync(label: str, factory):
|
||||
try:
|
||||
return await factory(), None
|
||||
except Exception as e:
|
||||
logger.error("%s failed: %s", label, e)
|
||||
return None, f"{label}: {e}"
|
||||
|
||||
# Resolve target guilds (for guild mode or for clearing duplicates)
|
||||
if guilds is not None:
|
||||
target_guilds = list(guilds)
|
||||
elif env_guild_ids:
|
||||
target_guilds = [discord.Object(id=gid) for gid in env_guild_ids]
|
||||
else:
|
||||
target_guilds = list(bot.guilds)
|
||||
|
||||
if mode == "guild":
|
||||
# Instant per-server only — do NOT also sync globally (would duplicate)
|
||||
for guild in target_guilds:
|
||||
gid = getattr(guild, "id", guild)
|
||||
|
||||
def _make_factory(g=guild):
|
||||
async def _factory():
|
||||
bot.tree.copy_global_to(guild=g)
|
||||
return await bot.tree.sync(guild=g)
|
||||
|
||||
return _factory
|
||||
|
||||
synced, err = await _try_sync(f"Guild {gid} slash sync", _make_factory())
|
||||
if err:
|
||||
result["errors"].append(err)
|
||||
print(f"\033[31m◈ {err}\033[0m")
|
||||
else:
|
||||
result["guilds"][str(gid)] = len(synced or [])
|
||||
print(f"◈ Slash sync (guild {gid}): {result['guilds'][str(gid)]} command(s)")
|
||||
await asyncio.sleep(0.35)
|
||||
else:
|
||||
# Global mode — one registry worldwide
|
||||
if global_sync:
|
||||
synced, err = await _try_sync("Global slash sync", lambda: bot.tree.sync())
|
||||
if err:
|
||||
result["errors"].append(err)
|
||||
print(f"\033[31m◈ {err}\033[0m")
|
||||
else:
|
||||
result["global"] = len(synced or [])
|
||||
print(f"◈ Slash sync (global): {result['global']} command(s)")
|
||||
|
||||
# Remove guild-scoped copies left from older “sync both” runs
|
||||
if clear_guild and target_guilds:
|
||||
for guild in target_guilds:
|
||||
gid = getattr(guild, "id", guild)
|
||||
|
||||
def _make_clear(g=guild):
|
||||
async def _factory():
|
||||
await clear_guild_commands(bot, g)
|
||||
return []
|
||||
|
||||
return _factory
|
||||
|
||||
_, err = await _try_sync(f"Clear guild {gid} slash cmds", _make_clear())
|
||||
if err:
|
||||
result["errors"].append(err)
|
||||
print(f"\033[31m◈ {err}\033[0m")
|
||||
else:
|
||||
result["cleared_guilds"].append(str(gid))
|
||||
print(f"◈ Cleared guild-scoped slash cmds for {gid} (removes duplicates)")
|
||||
await asyncio.sleep(0.35)
|
||||
|
||||
prefix_count = len(list(bot.commands))
|
||||
print(
|
||||
f"Synced Total {prefix_count} Client Commands and "
|
||||
f"{result.get('global') or local_count} Slash Commands "
|
||||
f"[mode={mode}]"
|
||||
)
|
||||
return result
|
||||
@@ -22,7 +22,11 @@ const AutomodForm = dynamic(() => import("@/components/dashboard/automod-form").
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -38,7 +42,12 @@ export default async function AutomodPage({ params }: { params: { guildId: strin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AutomodForm initialConfig={config} guildId={params.guildId} />
|
||||
<AutomodForm
|
||||
initialConfig={config}
|
||||
guildId={params.guildId}
|
||||
channels={channels || []}
|
||||
roles={roles || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,17 +14,20 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Zap,
|
||||
Type,
|
||||
Link as LinkIcon,
|
||||
MessageSquare,
|
||||
UserMinus,
|
||||
ShieldAlert,
|
||||
Smile,
|
||||
EyeOff,
|
||||
Gavel,
|
||||
RefreshCcw,
|
||||
Save
|
||||
Save,
|
||||
Info,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -32,63 +35,162 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AutomodConfig } from "@/types/api";
|
||||
import {
|
||||
AutomodConfig,
|
||||
AutomodEventConfig,
|
||||
DiscordChannel,
|
||||
DiscordRole,
|
||||
} from "@/types/api";
|
||||
|
||||
const PUNISHMENT_OPTIONS = [
|
||||
{ value: "delete", label: "Delete Message" },
|
||||
{ value: "warn", label: "Warn User" },
|
||||
{ value: "mute", label: "Mute User" },
|
||||
{ value: "kick", label: "Kick User" },
|
||||
{ value: "ban", label: "Ban User" },
|
||||
{ value: "Mute", label: "Mute" },
|
||||
{ value: "Kick", label: "Kick" },
|
||||
{ value: "Ban", label: "Ban" },
|
||||
];
|
||||
|
||||
const RULES = [
|
||||
{ id: 'anti_spam', name: 'Anti Spam', desc: 'Detects and removes repetitive messages or rapid firing.', icon: Zap },
|
||||
{ id: 'anti_caps', name: 'Anti Caps', desc: 'Prevents excessive use of uppercase letters.', icon: Type },
|
||||
{ id: 'anti_links', name: 'Anti Links', desc: 'Blocks unauthorized external links in channels.', icon: LinkIcon },
|
||||
{ id: 'anti_invites', name: 'Anti Invites', desc: 'Automatically removes Discord server invite links.', icon: MessageSquare },
|
||||
{ id: 'anti_mentions', name: 'Anti Mass Mention', desc: 'Protects against mentioned spam (@everyone, @here).', icon: UserMinus },
|
||||
{ id: "anti_spam", name: "Anti Spam", desc: "Repetitive messages or rapid firing.", icon: Zap },
|
||||
{ id: "anti_caps", name: "Anti Caps", desc: "Excessive uppercase letters.", icon: Type },
|
||||
{ id: "anti_link", name: "Anti Links", desc: "Unauthorized external links.", icon: LinkIcon },
|
||||
{ id: "anti_invites", name: "Anti Invites", desc: "Discord invite links.", icon: MessageSquare },
|
||||
{ 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 {
|
||||
initialConfig: AutomodConfig;
|
||||
guildId: string;
|
||||
channels: DiscordChannel[];
|
||||
roles: DiscordRole[];
|
||||
}
|
||||
|
||||
export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
const [config, setConfig] = useState<AutomodConfig>(initialConfig);
|
||||
export function AutomodForm({ initialConfig, guildId, channels, roles }: AutomodFormProps) {
|
||||
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 handleToggle = (key: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
enabled: key === 'master' ? !config.enabled : config.enabled,
|
||||
});
|
||||
const maxIgnored = initialConfig.limits?.max_ignored_roles ?? 10;
|
||||
const thresholdMeta = initialConfig.threshold_meta || {};
|
||||
|
||||
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 newPunishments = { ...config.punishments };
|
||||
newPunishments[ruleId] = value;
|
||||
setConfig({ ...config, punishments: newPunishments });
|
||||
const setEventEnabled = (ruleId: string, on: boolean) => {
|
||||
setEvents((prev) => ({
|
||||
...prev,
|
||||
[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 () => {
|
||||
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, {
|
||||
enabled: config.enabled,
|
||||
punishments: config.punishments,
|
||||
});
|
||||
|
||||
const promise = api.updateAutomod(guildId, payload);
|
||||
toast.promise(promise, {
|
||||
loading: 'Saving configuration...',
|
||||
success: 'Configuration saved successfully!',
|
||||
error: (err) => err.message || 'Failed to update settings',
|
||||
loading: "Saving configuration...",
|
||||
success: "Configuration saved successfully!",
|
||||
error: (err) => err.message || "Failed to update settings",
|
||||
});
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -100,78 +202,127 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
<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="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">Master Control</h3>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={() => handleToggle('master')}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">
|
||||
Master Control
|
||||
</h3>
|
||||
<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 className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
<div
|
||||
key={rule.id}
|
||||
className={cn(
|
||||
"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 items-center gap-4">
|
||||
<div className={cn(
|
||||
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
|
||||
isEnabled ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"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" />
|
||||
</div>
|
||||
<div>
|
||||
<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 className="flex items-center gap-4">
|
||||
{isEnabled && (
|
||||
<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" />
|
||||
<Select
|
||||
value={config.punishments[rule.id] || "delete"}
|
||||
onValueChange={(val) => handlePunishmentChange(rule.id, val)}
|
||||
options={PUNISHMENT_OPTIONS}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
||||
<Switch
|
||||
disabled={!config.enabled}
|
||||
checked={config.punishments?.[rule.id] !== undefined}
|
||||
onCheckedChange={() => {
|
||||
const newPunishments = { ...config.punishments };
|
||||
if (newPunishments[rule.id]) {
|
||||
delete newPunishments[rule.id];
|
||||
} else {
|
||||
newPunishments[rule.id] = "delete";
|
||||
}
|
||||
setConfig({...config, punishments: newPunishments});
|
||||
}}
|
||||
{isOn && !isNsfw && (
|
||||
<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" />
|
||||
<Select
|
||||
value={cfg.punishment || "Mute"}
|
||||
onValueChange={(val) => setPunishment(rule.id, val)}
|
||||
options={PUNISHMENT_OPTIONS}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isOn && isNsfw && (
|
||||
<span className="text-xs text-slate-400 font-mono">block_message</span>
|
||||
)}
|
||||
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
||||
<Switch
|
||||
disabled={!enabled}
|
||||
checked={!!cfg.enabled}
|
||||
onCheckedChange={(on) => setEventEnabled(rule.id, on)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,28 +330,87 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-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>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-slate-900/50 rounded-xl border border-white/5 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">Log Channel</span>
|
||||
<span className="text-xs font-mono text-primary">#{config.logging_channel || 'None'}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 italic text-center">Mod logs are automatically sent to the configured channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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-2">
|
||||
Ignored Roles
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mb-3">
|
||||
Up to {maxIgnored} roles bypass Automod filters.
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
||||
{roles
|
||||
.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="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
|
||||
<ShieldAlert className="h-32 w-32 text-white" />
|
||||
<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-2">
|
||||
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>
|
||||
<h3 className="text-sm font-bold text-white mb-2">Automod AI</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed mb-4">Our neural network analyzes message context to prevent false positives.</p>
|
||||
<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`),
|
||||
updateAutomod: (guildId: string, data: Partial<AutomodConfig>) =>
|
||||
updateAutomod: (guildId: string, data: AutomodUpdate) =>
|
||||
request<{ status: string }>(`/guilds/${guildId}/automod`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -53,13 +53,34 @@ export interface PrefixConfig {
|
||||
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 {
|
||||
guild_id: number;
|
||||
enabled: boolean;
|
||||
punishments: Record<string, string>;
|
||||
events: Record<string, AutomodEventConfig>;
|
||||
thresholds: Record<string, Record<string, number>>;
|
||||
threshold_meta: Record<string, AutomodThresholdMeta[]>;
|
||||
ignored_roles: number[];
|
||||
ignored_channels: number[];
|
||||
logging_channel: number | null;
|
||||
limits?: {
|
||||
max_ignored_roles: number;
|
||||
max_ignored_channels: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TicketCategory {
|
||||
@@ -124,9 +145,12 @@ export interface PrefixUpdate {
|
||||
export interface AutomodUpdate {
|
||||
enabled?: boolean;
|
||||
punishments?: Record<string, string>;
|
||||
events?: Record<string, AutomodEventConfig>;
|
||||
thresholds?: Record<string, Record<string, number>>;
|
||||
ignored_roles?: number[];
|
||||
ignored_channels?: number[];
|
||||
logging_channel?: number;
|
||||
logging_channel?: number | null;
|
||||
clear_logging?: boolean;
|
||||
}
|
||||
|
||||
export interface LevelingUpdate {
|
||||
|
||||
@@ -47,9 +47,13 @@ export interface AutomodConfig {
|
||||
guild_id: number;
|
||||
enabled: boolean;
|
||||
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_channels: number[];
|
||||
logging_channel: number | null;
|
||||
limits?: { max_ignored_roles: number; max_ignored_channels: number };
|
||||
}
|
||||
|
||||
export interface TicketConfig {
|
||||
|
||||
Reference in New Issue
Block a user