mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-09-14 03:56:36 -05:00
feat: mod logging overhaul, bug fixes, minesweeper fix, emote credits pending view
Moderation & logging: - cleanban now collects all messages before banning and attaches them as a .txt audit log file via the member_ban log channel - purgememberall restricted to server owner only (nuke protection) - unban, lock, unlock, spam ban-button, and manual infraction deletes all now write to the DB infractions table and post to the mod log - new log types (channel_lock, infraction_modify, cleanban) added and seeded to route to the main mod log channel (982644273960873994) Bug fixes: - unban: mod log no longer fires on failure (missing return added) - mute: fixed DB connection leaks and silent unmute failures in schedule_unmute and check_mutes (try/finally + proper error logging) - suggest: fixed AttributeError crash when original_embed is None and IndexError risk on set_field_at calls without field count guards - ban/cleanban: removed isinstance(user, discord.User) guard that silently prevented DMs from ever being sent to in-server members - cleanban: wrapped collect_status.delete() in try/except - commands/infractions: fixed DB connection leak with try/finally - events/botban: silent ban failure now logs error instead of swallowing it Minesweeper: - track detonated_cell on mine hit; show 💥 on the clicked mine and 💣 on all others when the board is revealed on loss Emote credits: - new /emote_credits_pending command: shows the user their own unreviewed submissions; admins can pass all:True to see the full pending queue Code quality: - comprehensive docstrings and inline comments added across all cogs, events, commands, utils, and supporting modules
This commit is contained in:
20
bot.py
20
bot.py
@@ -54,6 +54,13 @@ bot.tree.add_command(xp_admin_group)
|
||||
|
||||
# --- Cog loading ---
|
||||
async def load_cogs(folder: str):
|
||||
"""Load (or reload) every cog .py file in the given folder.
|
||||
|
||||
Skips utility modules listed in non_cog_files (they have no setup()
|
||||
function) and any cogs in disabled_cogs (unloads them if they were
|
||||
previously loaded, then skips). Uses reload_extension when a module is
|
||||
already registered so hot-reloading works without a full restart.
|
||||
"""
|
||||
non_cog_files = {"add_xp.py", "database.py", "utils.py", "__init__.py", "groups.py", "loader.py", "constants.py"}
|
||||
disabled_cogs = {"archipelago_monitor.py"}
|
||||
for file in glob.glob(f"{folder}/*.py"):
|
||||
@@ -81,6 +88,12 @@ async def load_cogs(folder: str):
|
||||
# --- Events and commands ---
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
"""Run startup tasks after the bot connects and its cache is populated.
|
||||
|
||||
Verifies XP and sparkle database connectivity, loads all cog folders,
|
||||
then syncs slash commands globally. Runs every time the bot (re)connects,
|
||||
so load_cogs is written to be idempotent via reload_extension.
|
||||
"""
|
||||
logger.info(f"Logged in as {bot.user}!")
|
||||
|
||||
for lifetime in (True, False):
|
||||
@@ -111,6 +124,7 @@ async def on_ready():
|
||||
|
||||
@bot.event
|
||||
async def on_command_error(ctx, error):
|
||||
"""Suppress CommandNotFound silently; log all other prefix-command errors."""
|
||||
if isinstance(error, commands.CommandNotFound):
|
||||
return
|
||||
logger.error(f"Command error: {error}", exc_info=True)
|
||||
@@ -130,6 +144,11 @@ async def reload(ctx):
|
||||
|
||||
@bot.event
|
||||
async def on_message(message):
|
||||
"""Award XP for every non-bot message and then process prefix commands.
|
||||
|
||||
XP errors are caught and logged rather than propagated so a broken XP
|
||||
system never prevents prefix commands from running.
|
||||
"""
|
||||
if message.author.bot:
|
||||
return
|
||||
try:
|
||||
@@ -140,6 +159,7 @@ async def on_message(message):
|
||||
|
||||
# --- Entry point ---
|
||||
async def main():
|
||||
"""Start the bot using the TOKEN from the environment (.env file)."""
|
||||
try:
|
||||
async with bot:
|
||||
await bot.start(TOKEN)
|
||||
|
||||
@@ -4,24 +4,32 @@ from discord.ext import commands
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Bonk(commands.Cog):
|
||||
"""Cog providing the /bonk slash command."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
# resolve path at init so we're not doing it on every command call
|
||||
# Resolve path at init so we're not doing it on every command call
|
||||
self.bonk_path = Path(__file__).parent.parent / "media" / "kat_bonk.png"
|
||||
|
||||
@app_commands.command(name="bonk", description="Bonk another user!")
|
||||
@app_commands.describe(user="The user you want to bonk")
|
||||
async def bonk(self, interaction: discord.Interaction, user: discord.User):
|
||||
"""Send the bonk image mentioning both the caller and the target.
|
||||
|
||||
Prevents self-bonking. Defers before sending because the response
|
||||
includes a file attachment.
|
||||
"""
|
||||
try:
|
||||
# defer because we're sending a file attachment
|
||||
# Defer because we're sending a file attachment
|
||||
await interaction.response.defer(thinking=True)
|
||||
|
||||
if not os.path.exists(self.bonk_path):
|
||||
await interaction.followup.send("Bonk image not found!", ephemeral=True)
|
||||
return
|
||||
|
||||
# no self-bonking
|
||||
# No self-bonking
|
||||
if user.id == interaction.user.id:
|
||||
await interaction.followup.send("You cannot bonk yourself silly", ephemeral=False)
|
||||
return
|
||||
@@ -37,5 +45,6 @@ class Bonk(commands.Cog):
|
||||
await interaction.followup.send("An error occurred while processing the bonk.", ephemeral=True)
|
||||
raise e
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Bonk(bot))
|
||||
|
||||
@@ -4,11 +4,14 @@ from discord import app_commands
|
||||
import random
|
||||
|
||||
class Coinflip(commands.Cog):
|
||||
"""Cog providing the /coinflip slash command."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="coinflip")
|
||||
async def coinflip(self, interaction: discord.Interaction):
|
||||
"""Flip a virtual coin and respond with Heads or Tails."""
|
||||
# 0 = tails, 1 = heads
|
||||
coin = random.randrange(0,2)
|
||||
if coin == 1:
|
||||
|
||||
@@ -5,12 +5,24 @@ import random
|
||||
import re
|
||||
|
||||
class DiceRoll(commands.Cog):
|
||||
"""Cog providing the /diceroll slash command."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="diceroll", description="Roll dice, supported die are d4, d6, d8, d10, d12, d20, d100")
|
||||
@app_commands.describe(dice="Enter your roll (e.g., d20, 2d6+3, 3d8-2)")
|
||||
async def diceroll(self, interaction: discord.Interaction, dice: str):
|
||||
"""Parse a dice string in NdS±M format, roll the dice, and report individual rolls and total.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dice:
|
||||
A dice expression such as ``d20``, ``2d6+3``, or ``3d8-2``.
|
||||
N (number of dice) is optional and defaults to 1.
|
||||
Only standard tabletop die sizes are accepted (d4 through d100).
|
||||
A maximum of 100 dice may be rolled at once.
|
||||
"""
|
||||
# expects NdS+M format — num dice (optional), die sides, optional +/- modifier
|
||||
pattern = r"^(\d*)d(\d+)([+-]\d+)?$"
|
||||
match = re.match(pattern, dice.replace(" ", "").lower())
|
||||
|
||||
@@ -9,17 +9,40 @@ from typing import Optional
|
||||
from embed.embed_color import get_embed_color
|
||||
|
||||
class EmoteCredits(ModerationBase, commands.Cog):
|
||||
"""Cog for tracking and querying emote/sticker artist credits.
|
||||
|
||||
Users submit credits via /emote_credits_add; submissions are routed to an
|
||||
approval channel where admins accept or deny them via button views. Credits
|
||||
are stored in emote_credits.db and queried via /emote_credit and related
|
||||
slash commands.
|
||||
|
||||
Slash commands:
|
||||
- ``/emote_credit`` — Look up the artist for a specific emote/sticker.
|
||||
- ``/emote_credits_add`` — Submit a new credit for admin approval.
|
||||
- ``/emote_credits_update`` — Submit a correction to an existing credit.
|
||||
- ``/emote_artists`` — List all credited artists ranked by count.
|
||||
- ``/emote_by_artist`` — View all emotes credited to a specific artist.
|
||||
- ``/missing_credits`` — (admin) List uncredited emotes/stickers.
|
||||
- ``/emote_credits_resolve`` — (admin) Convert raw @username credits to mentions.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "emote_credits.db"
|
||||
# Channel where new credit submissions are posted for admin review
|
||||
self.approval_channel_id = 1470441786810826884
|
||||
|
||||
async def cog_load(self):
|
||||
"""Initialise the database tables on cog load."""
|
||||
await self._init_db()
|
||||
|
||||
async def _init_db(self):
|
||||
"""Initialize the database"""
|
||||
"""Create emote_credits and pending_credits tables if they don't exist.
|
||||
|
||||
Also migrates older DB schemas by adding ``is_update`` and ``old_artist``
|
||||
columns to pending_credits if they are missing (safe to call repeatedly).
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
# stores finalized credits
|
||||
await conn.execute("""
|
||||
@@ -55,7 +78,15 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
await conn.commit()
|
||||
|
||||
async def get_credit(self, emote_name: str):
|
||||
"""Get credit for an emote from database"""
|
||||
"""Return the credited artist for the given emote name, or None if not found.
|
||||
|
||||
The lookup is case-insensitive.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote_name:
|
||||
The emote or sticker name to look up.
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
async with conn.execute(
|
||||
"SELECT artist FROM emote_credits WHERE LOWER(emote_name) = LOWER(?)",
|
||||
@@ -65,10 +96,21 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
return result[0] if result else None
|
||||
|
||||
async def cog_unload(self):
|
||||
"""No-op cleanup; aiosqlite connections are opened/closed per-query."""
|
||||
pass
|
||||
|
||||
async def add_credit(self, emote_name: str, artist: str, added_by: Optional[int] = None):
|
||||
"""Add a credit to the database"""
|
||||
"""Insert or replace a credit record in the emote_credits table.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote_name:
|
||||
The emote or sticker name to credit.
|
||||
artist:
|
||||
The artist to credit.
|
||||
added_by:
|
||||
Discord user ID of the moderator approving the credit, or None.
|
||||
"""
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
await conn.execute(
|
||||
"INSERT OR REPLACE INTO emote_credits (emote_name, artist, added_by) VALUES (?, ?, ?)",
|
||||
@@ -77,14 +119,26 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
await conn.commit()
|
||||
|
||||
async def get_all_credits(self):
|
||||
"""Get all credits from database"""
|
||||
"""Return a set of all credited emote names (lowercased) for fast membership checks."""
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
async with conn.execute("SELECT emote_name FROM emote_credits") as cursor:
|
||||
results = {row[0].lower() async for row in cursor}
|
||||
return results
|
||||
|
||||
def parse_emoji_name(self, emote_input: str) -> str:
|
||||
"""Extract emoji name from Discord emoji format or return as-is"""
|
||||
"""Extract the emoji name from a Discord emoji tag, or return the input unchanged.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote_input:
|
||||
Either a Discord emoji tag like ``<:name:id>`` / ``<a:name:id>``,
|
||||
or a plain text name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The extracted name portion, or the original string if no tag was found.
|
||||
"""
|
||||
# Discord custom emojis look like <:name:id> or <a:name:id> for animated
|
||||
emoji_pattern = r'<a?:([^:]+):\d+>'
|
||||
match = re.match(emoji_pattern, emote_input)
|
||||
@@ -96,6 +150,16 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
@app_commands.command(name="emote_credit", description="Find out who created a specific emoji or sticker")
|
||||
@app_commands.describe(emote="The emoji or sticker name (you can type the emoji directly!)")
|
||||
async def emote_credit(self, interaction: discord.Interaction, emote: str):
|
||||
"""Look up the credited artist for a server emoji or sticker.
|
||||
|
||||
Accepts either a raw emoji tag (``<:name:id>``) or a plain text name.
|
||||
Responds publicly on success, ephemerally on failure to keep the channel clean.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote:
|
||||
The emoji tag or name to query.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
emoji_name = self.parse_emoji_name(emote)
|
||||
@@ -124,6 +188,20 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
artist="The artist's username (e.g., @username)"
|
||||
)
|
||||
async def emote_credits_add(self, interaction: discord.Interaction, emote: str, artist: str):
|
||||
"""Submit a new artist credit for staff approval.
|
||||
|
||||
Rejects the submission immediately if a credit already exists for the
|
||||
emote. Otherwise inserts a pending record and posts an approval embed
|
||||
with Accept/Deny buttons to the approval channel. The submitter is DM'd
|
||||
with the outcome when staff decide.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote:
|
||||
The emoji tag or name to credit.
|
||||
artist:
|
||||
The artist's name or Discord mention.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
emoji_name = self.parse_emoji_name(emote)
|
||||
@@ -189,6 +267,19 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
artist="The corrected artist name"
|
||||
)
|
||||
async def emote_credits_update(self, interaction: discord.Interaction, emote: str, artist: str):
|
||||
"""Submit a correction to an existing emote credit for staff approval.
|
||||
|
||||
Requires that a credit already exists; use ``/emote_credits_add`` for new
|
||||
entries. Submits to the approval channel with the old and new artist names
|
||||
visible side by side so staff can compare before deciding.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emote:
|
||||
The emoji tag or name whose credit needs correcting.
|
||||
artist:
|
||||
The corrected artist name.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
emoji_name = self.parse_emoji_name(emote)
|
||||
@@ -253,8 +344,84 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="emote_credits_pending", description="View pending emote credit submissions awaiting approval")
|
||||
@app_commands.describe(all="[Admin] Show all pending submissions from everyone, not just your own")
|
||||
async def emote_credits_pending(self, interaction: discord.Interaction, all: bool = False):
|
||||
"""Show pending (not yet approved) credit submissions.
|
||||
|
||||
Without arguments, shows only the invoking user's own pending submissions.
|
||||
Passing ``all=True`` is admin-only and shows the full pending queue for
|
||||
all users, sorted by submission date.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
all:
|
||||
If True (admin only), list every pending submission regardless of submitter.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if all and not await self._check_admin(interaction):
|
||||
await interaction.followup.send("❌ Only admins can view all pending submissions.", ephemeral=True)
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
if all:
|
||||
async with conn.execute(
|
||||
"SELECT id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist "
|
||||
"FROM pending_credits ORDER BY submitted_at ASC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
else:
|
||||
async with conn.execute(
|
||||
"SELECT id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist "
|
||||
"FROM pending_credits WHERE submitted_by = ? ORDER BY submitted_at ASC",
|
||||
(interaction.user.id,)
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
msg = "There are no pending submissions." if all else "You have no pending submissions."
|
||||
await interaction.followup.send(msg, ephemeral=True)
|
||||
return
|
||||
|
||||
lines = []
|
||||
for row_id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist in rows:
|
||||
submitter_str = f"<@{submitted_by}>" if all else ""
|
||||
kind = "update" if is_update else "new"
|
||||
if is_update and old_artist:
|
||||
detail = f"`{emote_name}`: {old_artist} → **{artist}** ({kind})"
|
||||
else:
|
||||
detail = f"`{emote_name}` by **{artist}** ({kind})"
|
||||
date_str = submitted_at[:10] if submitted_at else "?"
|
||||
line = f"• {detail} — submitted {date_str}"
|
||||
if all:
|
||||
line += f" by {submitter_str}"
|
||||
lines.append(line)
|
||||
|
||||
title = f"🕐 All Pending Submissions ({len(rows)})" if all else f"🕐 Your Pending Submissions ({len(rows)})"
|
||||
chunks = [lines[i:i+20] for i in range(0, len(lines), 20)]
|
||||
for i, chunk in enumerate(chunks):
|
||||
embed = discord.Embed(
|
||||
title=f"{title} — Page {i+1}/{len(chunks)}" if len(chunks) > 1 else title,
|
||||
description="\n".join(chunk),
|
||||
color=get_embed_color(interaction.user.id)
|
||||
)
|
||||
embed.set_footer(text="Submissions are reviewed by admins in the approval channel.")
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
async def _check_admin(self, interaction: discord.Interaction) -> bool:
|
||||
"""Return True if the interaction user passes the is_admin check."""
|
||||
if not interaction.guild or not isinstance(interaction.user, discord.Member):
|
||||
return False
|
||||
return interaction.user.guild_permissions.administrator
|
||||
|
||||
@app_commands.command(name="emote_artists", description="List all artists who have credited emotes or stickers")
|
||||
async def emote_artists(self, interaction: discord.Interaction):
|
||||
"""List all credited artists, sorted by emote count descending.
|
||||
|
||||
Results are paginated into embeds of 20 entries each and sent as
|
||||
sequential followup messages (Discord enforces a 10-embed-per-send cap).
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as conn:
|
||||
@@ -288,6 +455,18 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
@app_commands.command(name="emote_by_artist", description="View all emotes and stickers credited to a specific artist")
|
||||
@app_commands.describe(artist="The artist's name, @mention, or user ID")
|
||||
async def emote_by_artist(self, interaction: discord.Interaction, artist: str):
|
||||
"""Show all emotes credited to the given artist, with inline emoji previews.
|
||||
|
||||
Performs a multi-strategy lookup to handle credits stored as plain text
|
||||
names, @user_id mentions, or usernames. Falls back to fetching member
|
||||
names from the Discord gateway and the API when the in-memory cache misses.
|
||||
Offers partial-match suggestions if no exact artist is found.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
artist:
|
||||
Artist name, @mention, or Discord user ID to search for.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Build a list of search terms to try. Credits may be stored as plain
|
||||
@@ -488,6 +667,12 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
@app_commands.command(name="emote_credits_resolve", description="[Admin] Auto-convert @username credits to user mentions where possible")
|
||||
@ModerationBase.is_admin()
|
||||
async def emote_credits_resolve(self, interaction: discord.Interaction):
|
||||
"""Batch-convert raw ``@username`` artist strings to ``<@user_id>`` mentions (admin only).
|
||||
|
||||
Chunks the guild member list and matches case-insensitively against
|
||||
username, global_name, and display_name. Reports resolved and unresolved
|
||||
entries; unresolved entries are left unchanged.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild = interaction.guild
|
||||
if not guild:
|
||||
@@ -551,7 +736,7 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
@commands.command(name="missing_credits")
|
||||
@ModerationBase.is_admin()
|
||||
async def missing_credits_text(self, ctx):
|
||||
"""List all server emotes and stickers that don't have credits"""
|
||||
"""Prefix-command mirror of /missing_credits — list uncredited emotes and stickers."""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
@@ -620,6 +805,13 @@ class EmoteCredits(ModerationBase, commands.Cog):
|
||||
|
||||
|
||||
class CreditApprovalView(discord.ui.View):
|
||||
"""Persistent button view for approving or denying emote credit submissions.
|
||||
|
||||
Uses timeout=None so buttons remain functional after bot restarts.
|
||||
Both Approve and Deny remove the pending record from the DB and notify
|
||||
the original submitter via DM.
|
||||
"""
|
||||
|
||||
def __init__(self, cog, submission_id, emote_name, artist, submitted_by, is_update=False, old_artist=None):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
@@ -632,6 +824,7 @@ class CreditApprovalView(discord.ui.View):
|
||||
|
||||
@discord.ui.button(label="Approve", style=discord.ButtonStyle.green, custom_id="approve_credit")
|
||||
async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Approve the submission — write to emote_credits table and DM the submitter."""
|
||||
# Add to database
|
||||
await self.cog.add_credit(self.emote_name, self.artist, interaction.user.id)
|
||||
|
||||
@@ -676,6 +869,7 @@ class CreditApprovalView(discord.ui.View):
|
||||
|
||||
@discord.ui.button(label="Deny", style=discord.ButtonStyle.red, custom_id="deny_credit")
|
||||
async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Deny the submission — remove from pending table and DM the submitter."""
|
||||
# Remove from pending
|
||||
async with aiosqlite.connect(self.cog.db_path) as conn:
|
||||
await conn.execute("DELETE FROM pending_credits WHERE id = ?", (self.submission_id,))
|
||||
|
||||
@@ -40,11 +40,14 @@ FAQ_ITEMS = [
|
||||
|
||||
|
||||
class FAQ(commands.Cog):
|
||||
"""Cog providing the /faq slash command."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="faq", description="View frequently asked questions")
|
||||
async def faq(self, interaction: discord.Interaction):
|
||||
"""Display all FAQ entries in a single embed using the caller's accent colour."""
|
||||
embed = discord.Embed(
|
||||
title="Frequently Asked Questions",
|
||||
color=get_embed_color(interaction.user.id),
|
||||
|
||||
@@ -13,6 +13,14 @@ from utils.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ColorImageGen(commands.Cog):
|
||||
"""Cog providing the !generateimages command (admin only).
|
||||
|
||||
Renders a color preview image listing every role in COLOR_ROLE_NAMES with
|
||||
its Discord-assigned color, saves it to media/colorimage.png, and sends a
|
||||
preview to the command channel. Configuration is hot-reloaded from
|
||||
role_color.py each time the command runs.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
# Import and get fresh references on initialization
|
||||
@@ -35,6 +43,13 @@ class ColorImageGen(commands.Cog):
|
||||
@commands.command(name="generateimages")
|
||||
@ModerationBase.is_admin()
|
||||
async def generate_list(self, ctx):
|
||||
"""Regenerate the color role preview image and send it to the current channel.
|
||||
|
||||
Reloads role_color.py config on each run so changes made after startup
|
||||
are picked up without restarting the bot. If any role in COLOR_ROLE_NAMES
|
||||
does not exist in the guild, the command aborts with an error message.
|
||||
The generated PNG is saved to media/colorimage.png and also sent inline.
|
||||
"""
|
||||
# Reload config to get latest values in case role_color.py changed since startup
|
||||
self._reload_config()
|
||||
|
||||
|
||||
@@ -24,11 +24,18 @@ GIVEAWAY_EMOJI = "🎉"
|
||||
|
||||
|
||||
def _is_admin(user: discord.Member) -> bool:
|
||||
"""Return True if the member is the bot owner or holds an admin role."""
|
||||
from utils.constants import LILAC_ID
|
||||
return user.id == LILAC_ID or any(r.id in ADMIN_ROLE_IDS for r in user.roles)
|
||||
|
||||
|
||||
class GiveawayRollAgainView(discord.ui.View):
|
||||
"""Persistent button view attached to ended giveaway messages.
|
||||
|
||||
Uses timeout=None so it stays active after bot restarts. The custom_id
|
||||
includes the giveaway ID so the view can be re-registered on startup.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot, giveaway_id: int):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
@@ -42,6 +49,7 @@ class GiveawayRollAgainView(discord.ui.View):
|
||||
self.add_item(btn)
|
||||
|
||||
async def _roll_callback(self, interaction: discord.Interaction):
|
||||
"""Re-roll the giveaway and announce a new winner (admin only)."""
|
||||
if not isinstance(interaction.user, discord.Member) or not _is_admin(interaction.user):
|
||||
return await interaction.response.send_message(
|
||||
"Only admins can re-roll a giveaway.", ephemeral=True
|
||||
@@ -101,11 +109,20 @@ class GiveawayRollAgainView(discord.ui.View):
|
||||
|
||||
|
||||
class GiveawayCog(commands.Cog):
|
||||
"""Cog providing the /giveaway start slash command and background expiry task.
|
||||
|
||||
Giveaways are persisted to giveaways.db. A background task polls every 30
|
||||
seconds for past-deadline entries and calls _end_giveaway to pick a winner
|
||||
and post the result. Roll Again views are re-registered on startup so the
|
||||
button remains functional after restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "giveaways.db"
|
||||
|
||||
async def cog_load(self):
|
||||
"""Set up DB, re-register persistent views, and start the check loop."""
|
||||
await self._setup_db()
|
||||
# Re-register persistent roll-again views for already-ended giveaways so
|
||||
# buttons continue to work after a restart.
|
||||
@@ -119,9 +136,11 @@ class GiveawayCog(commands.Cog):
|
||||
self.check_giveaways.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Cancel the background task when the cog unloads."""
|
||||
self.check_giveaways.cancel()
|
||||
|
||||
async def _setup_db(self):
|
||||
"""Create the giveaways table if it doesn't exist."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -160,6 +179,21 @@ class GiveawayCog(commands.Cog):
|
||||
deadline: str,
|
||||
timezone: Optional[str] = None,
|
||||
):
|
||||
"""Start a giveaway with a prize and deadline.
|
||||
|
||||
Parses the deadline as a relative duration or an absolute datetime string.
|
||||
Persists the giveaway before posting so the ID is available for the embed
|
||||
footer and the message ID can be stored back after the post.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prize:
|
||||
What is being given away.
|
||||
deadline:
|
||||
Duration like ``2h``, ``3d``, or a natural date/time string.
|
||||
timezone:
|
||||
Optional timezone name for interpreting absolute deadline strings.
|
||||
"""
|
||||
# Parse deadline — try duration first, then datetime string
|
||||
try:
|
||||
delta = parse_timeframe(deadline)
|
||||
@@ -172,7 +206,7 @@ class GiveawayCog(commands.Cog):
|
||||
|
||||
unix_ts = int(ends_at.timestamp())
|
||||
|
||||
# Persist before posting so we have an ID
|
||||
# Persist before posting so we have an ID for the embed footer
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cur = await db.execute(
|
||||
"""
|
||||
@@ -199,7 +233,7 @@ class GiveawayCog(commands.Cog):
|
||||
await interaction.response.send_message(embed=embed)
|
||||
msg = await interaction.original_response()
|
||||
|
||||
# Add the entry reaction
|
||||
# Add the entry reaction so users know what to click
|
||||
await msg.add_reaction(GIVEAWAY_EMOJI)
|
||||
|
||||
# Store the message ID so the task can fetch reactions later
|
||||
@@ -214,6 +248,7 @@ class GiveawayCog(commands.Cog):
|
||||
|
||||
@tasks.loop(seconds=30)
|
||||
async def check_giveaways(self):
|
||||
"""Poll every 30 seconds for giveaways whose deadline has passed."""
|
||||
now = datetime.now(dt_timezone.utc)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
@@ -233,6 +268,22 @@ class GiveawayCog(commands.Cog):
|
||||
message_id: int | None,
|
||||
prize: str,
|
||||
):
|
||||
"""End a giveaway, pick a winner from reaction users, and announce the result.
|
||||
|
||||
Marks the giveaway as ended immediately to prevent double-firing if the
|
||||
loop tick overlaps. Handles the no-participants edge case gracefully.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
giveaway_id:
|
||||
DB ID of the giveaway to end.
|
||||
channel_id:
|
||||
Discord channel where the giveaway was posted.
|
||||
message_id:
|
||||
ID of the original giveaway message (for fetching reactions).
|
||||
prize:
|
||||
Prize text to include in the result embed.
|
||||
"""
|
||||
# Mark ended immediately to prevent double-firing
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
@@ -293,6 +344,7 @@ class GiveawayCog(commands.Cog):
|
||||
|
||||
@check_giveaways.before_loop
|
||||
async def before_check_giveaways(self):
|
||||
"""Wait until the bot is ready before the polling loop starts."""
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ HELP_PAGES = {
|
||||
|
||||
|
||||
def build_overview_embed(color: discord.Color) -> discord.Embed:
|
||||
"""Build the top-level overview embed listing all categories and command counts."""
|
||||
embed = discord.Embed(
|
||||
title="Lacie Help",
|
||||
description="Select a category below to see available commands.",
|
||||
@@ -104,6 +105,7 @@ def build_overview_embed(color: discord.Color) -> discord.Embed:
|
||||
|
||||
|
||||
def build_category_embed(category: str, color: discord.Color) -> discord.Embed:
|
||||
"""Build a detail embed listing every command in a category."""
|
||||
embed = discord.Embed(title=f"{category}", color=color)
|
||||
for command, description in HELP_PAGES[category]:
|
||||
embed.add_field(name=command, value=description, inline=False)
|
||||
@@ -111,6 +113,8 @@ def build_category_embed(category: str, color: discord.Color) -> discord.Embed:
|
||||
|
||||
|
||||
class HelpSelect(discord.ui.Select):
|
||||
"""Dropdown that switches between the overview and per-category embeds."""
|
||||
|
||||
def __init__(self, embed_color: discord.Color):
|
||||
self.embed_color = embed_color
|
||||
options = [discord.SelectOption(label="Overview", value="__overview__")] + [
|
||||
@@ -120,6 +124,7 @@ class HelpSelect(discord.ui.Select):
|
||||
super().__init__(placeholder="Select a category...", options=options)
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
"""Swap the embed for the selected category (or back to overview)."""
|
||||
if self.values[0] == "__overview__":
|
||||
embed = build_overview_embed(self.embed_color)
|
||||
else:
|
||||
@@ -128,27 +133,33 @@ class HelpSelect(discord.ui.Select):
|
||||
|
||||
|
||||
class HelpView(discord.ui.View):
|
||||
"""View wrapping the HelpSelect dropdown. Times out after 2 minutes of inactivity."""
|
||||
|
||||
def __init__(self, color: discord.Color):
|
||||
super().__init__(timeout=120)
|
||||
self.add_item(HelpSelect(embed_color=color))
|
||||
|
||||
|
||||
class Help(commands.Cog):
|
||||
"""Cog providing both the /help slash command and the !help prefix command."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="help", description="View all available commands")
|
||||
async def help_slash(self, interaction: discord.Interaction):
|
||||
"""Show the interactive help menu via slash command."""
|
||||
color = get_embed_color(interaction.user.id)
|
||||
embed = build_overview_embed(color)
|
||||
await interaction.response.send_message(embed=embed, view=HelpView(color))
|
||||
|
||||
@commands.command(name="help")
|
||||
async def help_prefix(self, ctx: commands.Context):
|
||||
"""Show the interactive help menu via prefix command."""
|
||||
color = get_embed_color(ctx.author.id)
|
||||
embed = build_overview_embed(color)
|
||||
await ctx.send(embed=embed, view=HelpView(color))
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Help(bot))
|
||||
await bot.add_cog(Help(bot))
|
||||
|
||||
@@ -6,7 +6,12 @@ from pathlib import Path
|
||||
|
||||
|
||||
class InfractionsCommand(commands.Cog):
|
||||
"""Command for users to view their own infractions"""
|
||||
"""Cog providing the /infractions slash command for self-service infraction lookup.
|
||||
|
||||
Results are sent via DM so the user's infraction history is not exposed
|
||||
publicly. The command defers ephemerally before fetching so the bot can
|
||||
take more than 3 seconds without timing out.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
@@ -14,7 +19,8 @@ class InfractionsCommand(commands.Cog):
|
||||
|
||||
@app_commands.command(name="infractions", description="View your infractions in this server")
|
||||
async def infractions(self, interaction: discord.Interaction):
|
||||
# send via DM so it doesn't expose their infraction history publicly
|
||||
"""Fetch the caller's active infractions and deliver them via DM."""
|
||||
# Defer ephemerally — DB read may take a moment
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not interaction.guild:
|
||||
@@ -23,18 +29,20 @@ class InfractionsCommand(commands.Cog):
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
|
||||
# only show active (not removed) infractions for this user in this guild
|
||||
c.execute("""
|
||||
SELECT id, type, reason, timestamp
|
||||
FROM infractions
|
||||
WHERE user_id=? AND guild_id=? AND removed=0
|
||||
ORDER BY timestamp DESC
|
||||
""", (interaction.user.id, interaction.guild.id))
|
||||
# Only show active (not removed) infractions for this user in this guild
|
||||
c.execute("""
|
||||
SELECT id, type, reason, timestamp
|
||||
FROM infractions
|
||||
WHERE user_id=? AND guild_id=? AND removed=0
|
||||
ORDER BY timestamp DESC
|
||||
""", (interaction.user.id, interaction.guild.id))
|
||||
|
||||
results = c.fetchall()
|
||||
conn.close()
|
||||
results = c.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not results:
|
||||
await interaction.followup.send("You have no active infractions in this server.", ephemeral=True)
|
||||
@@ -44,7 +52,7 @@ class InfractionsCommand(commands.Cog):
|
||||
rows = []
|
||||
for row in results:
|
||||
inf_id, inf_type, reason, timestamp = row
|
||||
# strip the T and milliseconds out of the ISO timestamp for readability
|
||||
# Strip the T and milliseconds out of the ISO timestamp for readability
|
||||
timestamp_formatted = timestamp.replace("T", " ")[:19]
|
||||
reason_text = reason or "None"
|
||||
|
||||
@@ -55,12 +63,12 @@ class InfractionsCommand(commands.Cog):
|
||||
"reason": reason_text
|
||||
})
|
||||
|
||||
# auto-size each column to the widest value in it
|
||||
# Auto-size each column to the widest value in it
|
||||
widths = {key: max(len(key), *(len(r[key]) for r in rows)) for key in rows[0].keys()}
|
||||
header = " | ".join(f"{key.capitalize():{widths[key]}}" for key in rows[0].keys())
|
||||
separator = "-" * len(header)
|
||||
|
||||
# split into pages in case they have a lot of infractions — Discord has a 2000 char limit
|
||||
# Split into pages in case they have many infractions — Discord has a 2000 char limit
|
||||
chunk_size = 1800
|
||||
pages = []
|
||||
current_chunk = [header, separator]
|
||||
@@ -84,7 +92,7 @@ class InfractionsCommand(commands.Cog):
|
||||
|
||||
for page in pages:
|
||||
await interaction.user.send(dm_header + page)
|
||||
dm_header = "" # only show the header on the first page
|
||||
dm_header = "" # Only show the header on the first page
|
||||
|
||||
await interaction.followup.send("Your infractions have been sent to your DMs!", ephemeral=True)
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ import random
|
||||
MEOW_LIST = ["Meowwwww~", "Purrrrrr", "Nyaaaaaa", "Meow Meow", "Nya!", "Meow :3"]
|
||||
|
||||
class Meow(commands.Cog):
|
||||
"""Cog providing the /meow slash command."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="meow")
|
||||
async def meow(self, interaction: discord.Interaction):
|
||||
"""Respond with a random meow-like sound from MEOW_LIST."""
|
||||
meow_index = random.randrange(0, len(MEOW_LIST))
|
||||
await interaction.response.send_message(MEOW_LIST[meow_index])
|
||||
|
||||
|
||||
@@ -7,12 +7,21 @@ from embed.embed_color import get_embed_color
|
||||
from pathlib import Path
|
||||
|
||||
class Ping(commands.Cog):
|
||||
"""Cog providing the /ping slash command for latency diagnostics."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Reuse the suggestions DB for the latency probe — any DB will do
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "suggestions.db"
|
||||
|
||||
@app_commands.command(name="ping", description="Check the bot's latency")
|
||||
async def ping(self, interaction: discord.Interaction):
|
||||
"""Report WebSocket, API, and database latency in a single embed.
|
||||
|
||||
API latency is measured as the round-trip time to send the initial
|
||||
response. WebSocket latency comes from discord.py's heartbeat tracking.
|
||||
Database latency is measured by running ``SELECT 1`` against the DB.
|
||||
"""
|
||||
# send a message first, then measure how long it took — that's our API round-trip
|
||||
start_time = time.perf_counter()
|
||||
await interaction.response.send_message("Pinging...")
|
||||
|
||||
@@ -36,12 +36,25 @@ def load_color_role_ids() -> dict[str, int]:
|
||||
|
||||
|
||||
def save_color_role_ids(data: dict[str, int]):
|
||||
"""Persist the original_id -> color_copy_id mapping to DATA_PATH as JSON."""
|
||||
DATA_PATH.parent.mkdir(exist_ok=True)
|
||||
with open(DATA_PATH, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
class PrestigeColor(commands.Cog):
|
||||
"""Cog providing the /prestige slash command group.
|
||||
|
||||
Manages color-copy roles for prestige (high-ranked) members. Each prestige
|
||||
role gets a duplicate "color copy" role placed just above Acclaimed Ritualist
|
||||
in the hierarchy so its color overrides the prestige role below it.
|
||||
|
||||
Subcommands:
|
||||
- ``/prestige setup`` — (admin) Create color copy roles for each prestige role.
|
||||
- ``/prestige color`` — Switch your active prestige color.
|
||||
- ``/prestige removecolor`` — Remove your prestige color.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@@ -53,6 +66,12 @@ class PrestigeColor(commands.Cog):
|
||||
)
|
||||
@ModerationBase.is_admin()
|
||||
async def setup(self, interaction: discord.Interaction):
|
||||
"""Create or recreate color copy roles for every prestige role (admin only).
|
||||
|
||||
Skips roles whose copies already exist in the server. Positions each new
|
||||
copy just above ACCLAIMED_ROLE_ID so it wins the color resolution order.
|
||||
The resulting original_id -> copy_id mapping is saved to DATA_PATH.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild = interaction.guild
|
||||
if not guild:
|
||||
@@ -110,6 +129,12 @@ class PrestigeColor(commands.Cog):
|
||||
await interaction.followup.send("\n".join(lines) or "Nothing to do.", ephemeral=True)
|
||||
|
||||
async def prestige_autocomplete(self, interaction: discord.Interaction, current: str):
|
||||
"""Autocomplete callback for the ``prestige`` parameter of /prestige color.
|
||||
|
||||
Filters the autocomplete list to only the prestige roles the invoking
|
||||
member actually holds (no point offering roles they can't pick). Color
|
||||
copy IDs that haven't been set up yet are also excluded.
|
||||
"""
|
||||
# Only show roles the user actually has — no point listing ones they can't pick
|
||||
color_ids = load_color_role_ids()
|
||||
member = interaction.user
|
||||
@@ -131,6 +156,18 @@ class PrestigeColor(commands.Cog):
|
||||
@app_commands.describe(prestige="The prestige role whose color you want to display.")
|
||||
@app_commands.autocomplete(prestige=prestige_autocomplete)
|
||||
async def color(self, interaction: discord.Interaction, prestige: str):
|
||||
"""Equip a prestige color role copy, swapping out any previously active copy.
|
||||
|
||||
Prevents a user from equipping the color copy of their highest prestige role
|
||||
since the original already controls the display color at that rank. When
|
||||
switching colors, the old copy is removed and the original role is restored
|
||||
before the new copy is added, so the user never loses a prestige title.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prestige:
|
||||
The display name of the prestige role (from autocomplete).
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild = interaction.guild
|
||||
if not guild:
|
||||
@@ -216,6 +253,7 @@ class PrestigeColor(commands.Cog):
|
||||
|
||||
@prestige_group.command(name="removecolor", description="Remove your prestige color.")
|
||||
async def removecolor(self, interaction: discord.Interaction):
|
||||
"""Remove all prestige color copy roles and restore the original prestige roles."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
guild = interaction.guild
|
||||
if not guild:
|
||||
|
||||
@@ -30,6 +30,7 @@ FOOTER_COLOR = (90, 90, 90)
|
||||
|
||||
|
||||
def _make_circle(data: bytes, size: int) -> Image.Image:
|
||||
"""Crop avatar bytes to a circular image of the given pixel size."""
|
||||
img = Image.open(io.BytesIO(data)).convert("RGBA").resize(
|
||||
(size, size), Image.Resampling.LANCZOS
|
||||
)
|
||||
@@ -41,6 +42,7 @@ def _make_circle(data: bytes, size: int) -> Image.Image:
|
||||
|
||||
|
||||
def _wrap(text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_px: int) -> list[str]:
|
||||
"""Word-wrap text to fit within max_px pixels wide using the given font."""
|
||||
dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
words = text.split(" ")
|
||||
lines: list[str] = []
|
||||
@@ -59,6 +61,11 @@ def _wrap(text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_px:
|
||||
|
||||
|
||||
def _clean_content(content: str, guild: discord.Guild | None) -> str:
|
||||
"""Replace Discord mention/emoji markup with human-readable text.
|
||||
|
||||
Converts <@user_id> → @display_name, <#channel_id> → #channel_name,
|
||||
<@&role_id> → @role_name, and custom emoji tags → :name:.
|
||||
"""
|
||||
def sub_user(m: re.Match) -> str:
|
||||
if not guild:
|
||||
return f"@{m.group(1)}"
|
||||
@@ -104,6 +111,31 @@ def _render(
|
||||
channel_name: str,
|
||||
grayscale: bool = False,
|
||||
) -> bytes:
|
||||
"""Render a quote image and return the PNG bytes.
|
||||
|
||||
Layout:
|
||||
- Left panel (~46% of canvas): avatar scaled to fill, faded to black with a
|
||||
tilted angled gradient mask.
|
||||
- Right panel (~54%): center-aligned body text, attribution row (dash +
|
||||
small circular avatar + display name), @username, and a footer watermark.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
avatar_data:
|
||||
Raw bytes of the author's avatar image.
|
||||
display_name:
|
||||
The author's server display name.
|
||||
username:
|
||||
The author's Discord username (shown as @handle below the name).
|
||||
content:
|
||||
The quote body text (truncated to MAX_CHARS).
|
||||
timestamp:
|
||||
Formatted date string for the watermark.
|
||||
channel_name:
|
||||
Channel name (not currently rendered but available for future use).
|
||||
grayscale:
|
||||
If True, convert the final image to grayscale before returning.
|
||||
"""
|
||||
# ── fonts ──────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
renogare = str(FONT_DIR / "Renogare-Regular.otf")
|
||||
@@ -132,7 +164,8 @@ def _render(
|
||||
cy = (nh - HEIGHT) // 2
|
||||
av_raw = av_raw.crop((cx, cy, cx + LEFT_W, cy + HEIGHT))
|
||||
|
||||
# angled gradient mask: opaque left → transparent right, tilted by ANGLE_DEG
|
||||
# Angled gradient mask: opaque left → transparent right, tilted by ANGLE_DEG.
|
||||
# The tilt makes the fade line lean slightly, giving a less harsh edge.
|
||||
fade_base = int(LEFT_W * FADE_FROM)
|
||||
fade_range = max(1, LEFT_W - fade_base)
|
||||
angle_rad = math.radians(ANGLE_DEG)
|
||||
@@ -157,7 +190,7 @@ def _render(
|
||||
text_w = text_right - text_left
|
||||
center_x = (LEFT_W + WIDTH) // 2
|
||||
|
||||
# measure helpers
|
||||
# Measure helpers
|
||||
dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||
body_lh = dummy.textbbox((0, 0), "Ag", font=font_body)
|
||||
body_line_h = (body_lh[3] - body_lh[1]) + 8
|
||||
@@ -181,7 +214,7 @@ def _render(
|
||||
)
|
||||
start_y = (HEIGHT - total_h) // 2
|
||||
|
||||
# body text — center-aligned
|
||||
# Body text — center-aligned
|
||||
y = start_y
|
||||
for line in body_lines:
|
||||
bbox = dummy.textbbox((0, 0), line, font=font_body)
|
||||
@@ -189,7 +222,7 @@ def _render(
|
||||
d.text((center_x - lw // 2, y), line, font=font_body, fill=TEXT_COLOR)
|
||||
y += body_line_h
|
||||
|
||||
# attribution row: "- [avatar] display_name"
|
||||
# Attribution row: "- [avatar] display_name"
|
||||
attr_y = y + ATTR_GAP
|
||||
|
||||
small_av = _make_circle(avatar_data, AV_SM)
|
||||
@@ -212,7 +245,7 @@ def _render(
|
||||
hw = dummy.textbbox((0, 0), handle_tx, font=font_handle)[2]
|
||||
d.text((center_x - hw // 2, handle_y), handle_tx, font=font_handle, fill=MUTED_COLOR)
|
||||
|
||||
# watermark — bottom right
|
||||
# Watermark — bottom right
|
||||
wm = timestamp
|
||||
wm_w = dummy.textbbox((0, 0), wm, font=font_footer)[2]
|
||||
d.text((WIDTH - PAD_X - wm_w, HEIGHT - 28), wm, font=font_footer, fill=FOOTER_COLOR)
|
||||
@@ -227,14 +260,23 @@ def _render(
|
||||
|
||||
|
||||
class Quote(commands.Cog):
|
||||
"""Cog providing the /quote slash command.
|
||||
|
||||
Fetches the target message, downloads the author's avatar via aiohttp,
|
||||
renders a quote image in a thread executor (PIL is CPU-bound), and posts
|
||||
the result as a PNG file.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def cog_load(self):
|
||||
"""Open a shared aiohttp session for avatar downloads."""
|
||||
self.session = aiohttp.ClientSession()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Close the aiohttp session on unload."""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
@@ -253,6 +295,26 @@ class Quote(commands.Cog):
|
||||
channel_id: str | None = None,
|
||||
grayscale: bool = False,
|
||||
):
|
||||
"""Generate a stylised quote image from any message.
|
||||
|
||||
Supports three ways to specify the source channel:
|
||||
- No channel argument: use the current channel.
|
||||
- channel: resolved by Discord's type system (text channels only).
|
||||
- channel_id: raw integer ID for threads or channels not in the chooser.
|
||||
|
||||
Temporarily joins threads the bot is not a member of and leaves after fetching.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
ID of the message to quote.
|
||||
channel:
|
||||
Text channel the message is in (optional).
|
||||
channel_id:
|
||||
Numeric channel/thread ID (optional, for threads and unlisted channels).
|
||||
grayscale:
|
||||
If True, render the final image in grayscale.
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Resolve target channel / thread
|
||||
@@ -284,7 +346,7 @@ class Quote(commands.Cog):
|
||||
)
|
||||
return
|
||||
|
||||
# Join threads the bot isn't already a member of
|
||||
# Join threads the bot isn't already a member of so we can read history
|
||||
if isinstance(target, discord.Thread) and not target.me:
|
||||
await target.join()
|
||||
joined_thread = target
|
||||
@@ -337,6 +399,7 @@ class Quote(commands.Cog):
|
||||
async with self.session.get(author.display_avatar.url) as resp:
|
||||
avatar_data = await resp.read()
|
||||
|
||||
# Run the PIL render in a thread executor — it's CPU-bound
|
||||
image_bytes = await asyncio.to_thread(
|
||||
_render, avatar_data, display_name, username, content, ts, channel_name, grayscale
|
||||
)
|
||||
|
||||
@@ -5,12 +5,23 @@ import random
|
||||
|
||||
|
||||
class RNG(commands.Cog):
|
||||
"""Cog providing the /rng slash command for random number generation."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@app_commands.command(name="rng", description="Generate a random number between two values")
|
||||
@app_commands.describe(minimum="The minimum value (inclusive)", maximum="The maximum value (inclusive)")
|
||||
async def rng(self, interaction: discord.Interaction, minimum: int, maximum: int):
|
||||
"""Generate a random integer in [minimum, maximum] (both endpoints inclusive).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
minimum:
|
||||
Lower bound (inclusive).
|
||||
maximum:
|
||||
Upper bound (inclusive). Must be strictly greater than minimum.
|
||||
"""
|
||||
if minimum >= maximum:
|
||||
await interaction.response.send_message("Minimum must be less than maximum!", ephemeral=True)
|
||||
return
|
||||
|
||||
@@ -25,6 +25,18 @@ COLOR_ROLE_NAMES = [
|
||||
FONTS_PATH = Path(__file__).parent.parent / "fonts"
|
||||
|
||||
class ColorRoles(commands.Cog):
|
||||
"""Cog providing the /color slash-command group for cosmetic color role management.
|
||||
|
||||
Members can freely assign or remove any role from COLOR_ROLE_NAMES.
|
||||
Admin variants (setfor, removefor) allow moderators to manage color roles on
|
||||
behalf of other members.
|
||||
|
||||
Role assignment always strips all existing color roles first so a member
|
||||
can only ever hold one color role at a time. The `/color list` command
|
||||
serves a pre-generated image from media/colorimage.png, falling back to
|
||||
the older split images if the combined file hasn't been generated yet.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
@@ -34,6 +46,11 @@ class ColorRoles(commands.Cog):
|
||||
@app_commands.describe(color="The color role you'd like to have.")
|
||||
@app_commands.choices(color=[app_commands.Choice(name=name, value=name) for name in COLOR_ROLE_NAMES])
|
||||
async def set_color(self, interaction: discord.Interaction, color: app_commands.Choice[str]):
|
||||
"""Assign the chosen color role to the caller, removing any existing color role first.
|
||||
|
||||
Fetches the full Member object if interaction.user comes back as a bare
|
||||
User, which can happen in certain edge cases with the gateway cache.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
guild = interaction.guild
|
||||
@@ -104,6 +121,7 @@ class ColorRoles(commands.Cog):
|
||||
@app_commands.choices(color=[app_commands.Choice(name=name, value=name) for name in COLOR_ROLE_NAMES])
|
||||
@ModerationBase.is_admin()
|
||||
async def set_color_for(self, interaction: discord.Interaction, user: discord.Member, color: app_commands.Choice[str]):
|
||||
"""Admin-only: assign a color role to another member, stripping their current color first."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
try:
|
||||
guild = interaction.guild
|
||||
@@ -159,6 +177,7 @@ class ColorRoles(commands.Cog):
|
||||
|
||||
@color_group.command(name="remove", description="Remove your current color role.")
|
||||
async def remove_color(self, interaction: discord.Interaction):
|
||||
"""Remove all color roles from the caller. Succeeds silently if none are held."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
try:
|
||||
guild = interaction.guild
|
||||
@@ -212,6 +231,7 @@ class ColorRoles(commands.Cog):
|
||||
@app_commands.describe(user="The user to remove color roles from.")
|
||||
@ModerationBase.is_admin()
|
||||
async def remove_color_for(self, interaction: discord.Interaction, user: discord.Member):
|
||||
"""Admin-only: remove all color roles from the specified member."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
try:
|
||||
guild = interaction.guild
|
||||
@@ -255,6 +275,12 @@ class ColorRoles(commands.Cog):
|
||||
|
||||
@color_group.command(name="list", description="Show all available role colors")
|
||||
async def list_colors(self, interaction: discord.Interaction):
|
||||
"""Send the color role reference image.
|
||||
|
||||
Prefers media/colorimage.png (a single combined image). If that file
|
||||
doesn't exist yet, falls back to colorimage1.png / colorimage2.png
|
||||
(the old split format generated before the combined image was added).
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
try:
|
||||
media_dir = Path(__file__).resolve().parent.parent / "media"
|
||||
|
||||
@@ -10,14 +10,32 @@ from typing import Optional
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class RoleTrack(commands.Cog):
|
||||
"""Cog that automatically saves and restores member roles across leaves and rejoins.
|
||||
|
||||
Role snapshots are stored in roletrack.db with a composite primary key of
|
||||
(user_id, guild_id). Role IDs are serialised as a comma-separated string
|
||||
in a single TEXT column — simple enough that a join table isn't needed.
|
||||
|
||||
Three event listeners keep the snapshot current:
|
||||
- on_member_remove: snapshot on leave.
|
||||
- on_member_join: restore from snapshot on rejoin; snapshot if brand new.
|
||||
- on_member_update: re-snapshot whenever roles change.
|
||||
|
||||
Two slash commands expose the system to members:
|
||||
- /syncroles: force a manual snapshot of current roles.
|
||||
- /checkroles: display the currently stored snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "roletrack.db"
|
||||
|
||||
async def cog_load(self):
|
||||
"""Initialise the database table on cog load."""
|
||||
await self.init_db()
|
||||
|
||||
async def init_db(self):
|
||||
"""Create the tracked_roles table if it doesn't exist."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
# role_ids stored as comma-separated string — simple enough, don't need a join table
|
||||
await db.execute('''
|
||||
@@ -33,7 +51,14 @@ class RoleTrack(commands.Cog):
|
||||
await db.commit()
|
||||
|
||||
async def save_user_roles(self, member: discord.Member):
|
||||
"""Save all roles for a user (excluding @everyone)"""
|
||||
"""Snapshot all roles for a member, excluding @everyone.
|
||||
|
||||
Uses INSERT OR REPLACE so this works for both first-time inserts and
|
||||
subsequent updates without needing separate code paths.
|
||||
|
||||
Note: @everyone's role ID equals the guild ID, which is how the filter
|
||||
works below.
|
||||
"""
|
||||
# @everyone's id == guild id, so this filters it out
|
||||
role_ids = [str(role.id) for role in member.roles if role.id != member.guild.id]
|
||||
role_ids_str = ",".join(role_ids) if role_ids else ""
|
||||
@@ -47,7 +72,7 @@ class RoleTrack(commands.Cog):
|
||||
await db.commit()
|
||||
|
||||
async def get_saved_roles(self, user_id: int, guild_id: int):
|
||||
"""Get saved role IDs for a user"""
|
||||
"""Return the list of saved role IDs for a member, or an empty list if none are stored."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute('''
|
||||
SELECT role_ids FROM tracked_roles
|
||||
@@ -60,7 +85,11 @@ class RoleTrack(commands.Cog):
|
||||
|
||||
@app_commands.command(name="syncroles", description="Manually sync your current roles to the role tracking system")
|
||||
async def syncroles(self, interaction: discord.Interaction):
|
||||
"""Command to manually sync user's current roles"""
|
||||
"""Force a manual snapshot of the caller's current roles.
|
||||
|
||||
Useful if automatic tracking missed a role assignment, or after an
|
||||
admin manually adds roles that the listener didn't fire for.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not interaction.guild or not isinstance(interaction.user, discord.Member):
|
||||
@@ -103,7 +132,12 @@ class RoleTrack(commands.Cog):
|
||||
|
||||
@app_commands.command(name="checkroles", description="Check what roles are saved in the database for you")
|
||||
async def checkroles(self, interaction: discord.Interaction):
|
||||
"""Debug command to check saved roles"""
|
||||
"""Display the currently stored role snapshot for the caller.
|
||||
|
||||
Shows each role by name where possible; for roles that have since been
|
||||
deleted from the server, shows the raw ID so the member can see they
|
||||
are stored and won't cause errors on restore.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
if not interaction.guild:
|
||||
@@ -152,12 +186,17 @@ class RoleTrack(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member: discord.Member):
|
||||
"""Save roles when a member leaves"""
|
||||
"""Snapshot the member's roles when they leave so they can be restored on rejoin."""
|
||||
await self.save_user_roles(member)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member):
|
||||
"""Restore roles when a member rejoins, and save them for new members"""
|
||||
"""Restore a returning member's roles, or initialise a snapshot for brand-new members.
|
||||
|
||||
Silently skips roles that no longer exist on the server to avoid errors.
|
||||
Sends a DM to notify the member of the restoration; DM failures are
|
||||
swallowed since users frequently have DMs disabled.
|
||||
"""
|
||||
saved_role_ids = await self.get_saved_roles(member.id, member.guild.id)
|
||||
|
||||
if not saved_role_ids:
|
||||
@@ -201,7 +240,11 @@ class RoleTrack(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
"""Track role changes in real-time"""
|
||||
"""Re-snapshot roles whenever a member's role list changes.
|
||||
|
||||
Comparing before.roles to after.roles avoids unnecessary DB writes for
|
||||
unrelated member updates such as nickname changes.
|
||||
"""
|
||||
# only re-save if roles actually changed
|
||||
if before.roles != after.roles:
|
||||
await self.save_user_roles(after)
|
||||
|
||||
@@ -7,7 +7,16 @@ import random
|
||||
|
||||
SALT_EMOJI_ID = 1074583707459010560
|
||||
|
||||
|
||||
class SaltCommand(ModerationBase):
|
||||
"""Cog providing the !salt moderation command.
|
||||
|
||||
Queues a member to receive the salt emoji reaction on their very next
|
||||
message. The queue is held in memory only — it does not persist across
|
||||
restarts, which is intentional (a salt target that lasts forever without
|
||||
being triggered is harmless to lose).
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
# tracks who's queued to be salted: {guild_id: {user_id: reason}}
|
||||
@@ -17,7 +26,15 @@ class SaltCommand(ModerationBase):
|
||||
@commands.command(name="salt")
|
||||
@ModerationBase.is_admin()
|
||||
async def salt(self, ctx, member: discord.Member, *, reason: Optional[str] = None):
|
||||
"""React with salt emoji to the user's next message"""
|
||||
"""Queue the salt emoji reaction on the target's next message.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
member:
|
||||
The member to salt.
|
||||
reason:
|
||||
Optional reason to show in the confirmation message.
|
||||
"""
|
||||
|
||||
# easter egg — 1% chance of denying the command if used on this specific user
|
||||
if member.id == 252130669919076352:
|
||||
@@ -45,6 +62,12 @@ class SaltCommand(ModerationBase):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message):
|
||||
"""Fire the salt reaction when a queued member sends their next message.
|
||||
|
||||
Dequeues the member immediately after reacting so they only get salted
|
||||
once per !salt invocation. Ignores bots, except for the bot's own user
|
||||
ID which is explicitly kept saltable.
|
||||
"""
|
||||
# ignore bots (except our own bot id — that one should still be saltable)
|
||||
if message.author.bot and message.author.id != 1409637508689563689 or not message.guild:
|
||||
return
|
||||
|
||||
@@ -12,6 +12,7 @@ DB_PATH = Path(__file__).parent.parent / "data" / "scheduled.db"
|
||||
|
||||
|
||||
def _db_init():
|
||||
"""Create the scheduled messages table if it doesn't already exist."""
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled (
|
||||
@@ -26,6 +27,11 @@ def _db_init():
|
||||
|
||||
|
||||
def _db_insert(job_id: int, channel_id: int, fire_at: float, message: str):
|
||||
"""Persist a scheduled job to the database before the asyncio task is created.
|
||||
|
||||
Persisting first ensures the job survives a restart even if the process
|
||||
exits between insertion and the task being created.
|
||||
"""
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.execute(
|
||||
"INSERT INTO scheduled (id, channel_id, fire_at, message) VALUES (?, ?, ?, ?)",
|
||||
@@ -36,6 +42,7 @@ def _db_insert(job_id: int, channel_id: int, fire_at: float, message: str):
|
||||
|
||||
|
||||
def _db_delete(job_id: int):
|
||||
"""Remove a completed or cancelled job from the database."""
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
con.execute("DELETE FROM scheduled WHERE id = ?", (job_id,))
|
||||
con.commit()
|
||||
@@ -43,6 +50,7 @@ def _db_delete(job_id: int):
|
||||
|
||||
|
||||
def _db_load_all() -> list[tuple]:
|
||||
"""Return all rows from the scheduled table as (id, channel_id, fire_at, message) tuples."""
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
rows = con.execute("SELECT id, channel_id, fire_at, message FROM scheduled").fetchall()
|
||||
con.close()
|
||||
@@ -86,6 +94,20 @@ def _parse_delay(time_str: str) -> float | None:
|
||||
|
||||
|
||||
class SendMessage(commands.Cog):
|
||||
"""Owner-only cog for sending and scheduling messages to arbitrary channels.
|
||||
|
||||
Provides four prefix commands (all restricted to OWNER_ID):
|
||||
- !sendmessage — send to any channel in any guild immediately.
|
||||
- !schedulemessage — schedule a message for a future time, persisted to
|
||||
scheduled.db so it survives bot restarts.
|
||||
- !listscheduled — show all pending scheduled job IDs.
|
||||
- !cancelscheduled — cancel and remove a pending scheduled job.
|
||||
|
||||
Scheduled jobs are stored as asyncio Tasks indexed by an auto-incrementing
|
||||
integer ID. On cog load, any jobs that survived the last restart are
|
||||
re-queued; jobs whose fire_at has already passed are sent immediately.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self._scheduled: dict[int, asyncio.Task] = {} # id -> Task
|
||||
@@ -93,6 +115,7 @@ class SendMessage(commands.Cog):
|
||||
_db_init()
|
||||
|
||||
async def cog_load(self):
|
||||
"""Re-schedule any jobs that were pending before the bot restarted."""
|
||||
# re-schedule anything that was pending before the bot restarted
|
||||
rows = await asyncio.to_thread(_db_load_all)
|
||||
if rows:
|
||||
@@ -104,6 +127,11 @@ class SendMessage(commands.Cog):
|
||||
self._schedule_task(job_id, channel_id, delay, message)
|
||||
|
||||
def _schedule_task(self, job_id: int, channel_id: int, delay: float, message: str):
|
||||
"""Create and register an asyncio Task that sends the message after delay seconds.
|
||||
|
||||
Cleans up both the in-memory task dict and the DB row after sending,
|
||||
regardless of whether the send succeeded, so dead jobs don't accumulate.
|
||||
"""
|
||||
async def _send():
|
||||
await asyncio.sleep(delay)
|
||||
channel = self.bot.get_channel(channel_id)
|
||||
|
||||
@@ -27,6 +27,20 @@ DB_PATH = Path(__file__).parent.parent / "data" / "tts.db"
|
||||
|
||||
|
||||
class TTS(commands.GroupCog, name="tts"):
|
||||
"""GroupCog providing voice-channel TTS for muted members.
|
||||
|
||||
When active in a guild, messages sent to the active voice channel's text
|
||||
chat by muted members are synthesised with edge-tts (falling back to gTTS)
|
||||
and played through the bot's voice connection.
|
||||
|
||||
Features:
|
||||
- Per-guild asyncio queue so messages play sequentially without overlap.
|
||||
- Rate limit of one TTS per user per RATE_LIMIT_SECONDS.
|
||||
- Automatic disconnect after IDLE_TIMEOUT_SECONDS of queue inactivity.
|
||||
- Automatic disconnect if all human members leave the voice channel.
|
||||
- Optional TTS nickname stored in tts.db to override the Discord username.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# guild_id -> voice_channel_id currently being listened to
|
||||
@@ -39,6 +53,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
self.rate_limits: dict[tuple[int, int], float] = defaultdict(float)
|
||||
|
||||
async def cog_load(self):
|
||||
"""Create the tts_nicks table if it doesn't exist."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tts_nicks (
|
||||
@@ -49,13 +64,27 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
await db.commit()
|
||||
|
||||
async def _get_tts_name(self, user_id: int, fallback: str) -> str:
|
||||
"""Return the user's saved TTS nickname, or their Discord username as fallback."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute("SELECT nickname FROM tts_nicks WHERE user_id = ?", (user_id,))
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else fallback
|
||||
|
||||
async def _generate_tts(self, text: str, filepath: str) -> bool:
|
||||
"""Generate TTS audio to filepath. Tries edge-tts, falls back to gTTS."""
|
||||
"""Generate TTS audio to filepath. Tries edge-tts first, falls back to gTTS.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
The text to synthesise.
|
||||
filepath:
|
||||
Path to write the resulting MP3 file to.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if audio was generated successfully, False otherwise.
|
||||
"""
|
||||
try:
|
||||
import edge_tts
|
||||
communicate = edge_tts.Communicate(text, TTS_VOICE)
|
||||
@@ -74,7 +103,12 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
return False
|
||||
|
||||
async def _process_queue(self, guild_id: int):
|
||||
"""Sequentially processes TTS entries for a guild."""
|
||||
"""Sequentially process TTS entries for a guild.
|
||||
|
||||
Waits up to IDLE_TIMEOUT_SECONDS for the next queue item. If the timeout
|
||||
fires, disconnects the bot and cleans up state. Exits if the voice
|
||||
connection is lost between items.
|
||||
"""
|
||||
while True:
|
||||
queue = self.queues.get(guild_id)
|
||||
if not queue:
|
||||
@@ -120,6 +154,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
success = await self._generate_tts(f"{name} said: {text}", tmpfile)
|
||||
|
||||
if success and vc.is_connected():
|
||||
# Wait for any currently playing audio to finish before queuing the next
|
||||
while vc.is_playing():
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -144,6 +179,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
queue.task_done()
|
||||
|
||||
def _cleanup(self, guild_id: int):
|
||||
"""Remove all TTS state for a guild and cancel the queue processor task."""
|
||||
self.active_channels.pop(guild_id, None)
|
||||
self.queues.pop(guild_id, None)
|
||||
task = self.queue_tasks.pop(guild_id, None)
|
||||
@@ -155,6 +191,11 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
|
||||
@app_commands.command(name="join", description="Join this voice channel and read muted members' messages aloud")
|
||||
async def tts_join(self, interaction: discord.Interaction):
|
||||
"""Join the current voice channel and start TTS.
|
||||
|
||||
Must be used from a voice channel's text chat. The caller must be in
|
||||
the voice channel. Only one TTS session is allowed per guild at a time.
|
||||
"""
|
||||
if not isinstance(interaction.channel, discord.VoiceChannel):
|
||||
await interaction.response.send_message(
|
||||
"This command can only be used inside a voice channel's text chat!",
|
||||
@@ -219,6 +260,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
|
||||
@app_commands.command(name="leave", description="Leave the voice channel and stop TTS")
|
||||
async def tts_leave(self, interaction: discord.Interaction):
|
||||
"""Disconnect from the voice channel and tear down TTS state for this guild."""
|
||||
guild = interaction.guild
|
||||
if not guild:
|
||||
return
|
||||
@@ -240,6 +282,10 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
@app_commands.command(name="setnick", description="Set a short TTS nickname for yourself. Anyone found misusing this will be punished.")
|
||||
@app_commands.describe(nickname="Your TTS nickname (max 32 characters)")
|
||||
async def tts_setnick(self, interaction: discord.Interaction, nickname: str):
|
||||
"""Save a TTS nickname that will be read instead of the Discord username.
|
||||
|
||||
URLs are stripped from the nickname to prevent misuse.
|
||||
"""
|
||||
nickname = nickname.strip()
|
||||
if not nickname:
|
||||
await interaction.response.send_message("Nickname can't be empty!", ephemeral=True)
|
||||
@@ -264,6 +310,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
|
||||
@app_commands.command(name="clearnick", description="Remove your TTS nickname and go back to your username")
|
||||
async def tts_clearnick(self, interaction: discord.Interaction):
|
||||
"""Delete the saved TTS nickname so the Discord username is used again."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("DELETE FROM tts_nicks WHERE user_id = ?", (interaction.user.id,))
|
||||
await db.commit()
|
||||
@@ -276,6 +323,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
before: discord.VoiceState,
|
||||
after: discord.VoiceState,
|
||||
):
|
||||
"""Disconnect and clean up if all human members leave the active TTS channel."""
|
||||
if not self.bot.user or member.id == self.bot.user.id:
|
||||
return
|
||||
|
||||
@@ -307,6 +355,7 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
"""Queue a TTS entry for muted members' messages in the active voice channel chat."""
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
@@ -326,9 +375,11 @@ class TTS(commands.GroupCog, name="tts"):
|
||||
return
|
||||
|
||||
voice_state = message.author.voice
|
||||
# Only read messages from users who are self-muted or server-muted
|
||||
if not voice_state or not (voice_state.self_mute or voice_state.mute):
|
||||
return
|
||||
|
||||
# Skip slash/prefix commands — they'll already be handled by the bot
|
||||
if message.content.startswith('/') or message.content.startswith('!'):
|
||||
return
|
||||
|
||||
|
||||
293
commands/whitelist.py
Normal file
293
commands/whitelist.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import aiosqlite
|
||||
|
||||
OWNER_ID = 252130669919076352
|
||||
|
||||
RCON_HOST = os.getenv("RCON_HOST")
|
||||
RCON_PORT = int(os.getenv("RCON_PORT", "25575"))
|
||||
RCON_PASSWORD = os.getenv("RCON_PASSWORD")
|
||||
SERVER_IP = os.getenv("MC_SERVER_IP", "vanilla.lilacrose.dev")
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "whitelist.db"
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create the whitelist table if it doesn't exist."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS whitelist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
minecraft_username TEXT NOT NULL UNIQUE,
|
||||
discord_user_id INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
requested_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def rcon_command(cmd: str) -> str:
|
||||
"""Minimal async RCON client — avoids mcrcon's signal.signal() thread issue.
|
||||
|
||||
Implements the Source RCON protocol:
|
||||
1. Open TCP connection.
|
||||
2. Send a SERVERDATA_AUTH (type 3) packet with the password.
|
||||
3. Send a SERVERDATA_EXECCOMMAND (type 2) packet with the command.
|
||||
4. Return the server's response body string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cmd:
|
||||
The Minecraft server command to execute (without leading slash).
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The raw response text from the server.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If RCON authentication fails (wrong password).
|
||||
"""
|
||||
import struct
|
||||
|
||||
RCON_LOGIN = 3
|
||||
RCON_COMMAND = 2
|
||||
REQ_ID = 1
|
||||
|
||||
def _pack(req_id: int, ptype: int, body: str) -> bytes:
|
||||
payload = body.encode("utf-8") + b"\x00\x00"
|
||||
length = 4 + 4 + len(payload)
|
||||
return struct.pack("<iii", length, req_id, ptype) + payload
|
||||
|
||||
def _unpack(data: bytes):
|
||||
length, req_id, ptype = struct.unpack_from("<iii", data, 0)
|
||||
body = data[12 : 8 + length - 2].decode("utf-8", errors="replace")
|
||||
return req_id, ptype, body
|
||||
|
||||
reader, writer = await asyncio.open_connection(RCON_HOST, RCON_PORT)
|
||||
try:
|
||||
# Authenticate
|
||||
writer.write(_pack(REQ_ID, RCON_LOGIN, RCON_PASSWORD))
|
||||
await writer.drain()
|
||||
raw = await reader.read(4096)
|
||||
auth_id, _, _ = _unpack(raw)
|
||||
if auth_id == -1:
|
||||
raise ValueError("RCON authentication failed — wrong password?")
|
||||
|
||||
# Send command
|
||||
writer.write(_pack(REQ_ID, RCON_COMMAND, cmd))
|
||||
await writer.drain()
|
||||
raw = await reader.read(4096)
|
||||
_, _, response = _unpack(raw)
|
||||
return response
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
class Whitelist(commands.GroupCog, name="whitelist"):
|
||||
"""GroupCog providing the /whitelist slash command group.
|
||||
|
||||
Allows server members to request Minecraft whitelist access (/whitelist request)
|
||||
and the owner to approve or remove players (/whitelist add, remove, list).
|
||||
Requests are persisted to whitelist.db and approved via RCON.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
async def cog_load(self):
|
||||
"""Initialise the DB table on cog load."""
|
||||
await init_db()
|
||||
|
||||
# ── /whitelist request ─────────────────────────────────────────────────────
|
||||
|
||||
@app_commands.command(name="request", description="Request to be added to the Minecraft server whitelist")
|
||||
@app_commands.describe(minecraft_username="Your Minecraft username")
|
||||
async def whitelist_request(self, interaction: discord.Interaction, minecraft_username: str):
|
||||
"""Submit a whitelist request for a Minecraft username.
|
||||
|
||||
Rejects duplicate requests for the same username. On acceptance, stores
|
||||
a pending record so the owner can approve via /whitelist add.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT status FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
|
||||
(minecraft_username,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
|
||||
if row:
|
||||
if row[0] == "approved":
|
||||
await interaction.followup.send(
|
||||
f"`{minecraft_username}` is already on the whitelist!", ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
f"There's already a pending request for `{minecraft_username}`.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO whitelist (minecraft_username, discord_user_id, status, requested_at) VALUES (?, ?, 'pending', ?)",
|
||||
(minecraft_username, interaction.user.id, datetime.now(timezone.utc).isoformat())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await interaction.followup.send(
|
||||
f"Your whitelist request for `{minecraft_username}` has been submitted! "
|
||||
f"You'll receive a DM when it's approved.",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# ── /whitelist list ────────────────────────────────────────────────────────
|
||||
|
||||
@app_commands.command(name="list", description="View whitelist requests and approved players")
|
||||
async def whitelist_list(self, interaction: discord.Interaction):
|
||||
"""List all pending and approved whitelist entries (owner only)."""
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT minecraft_username, discord_user_id, status, requested_at FROM whitelist ORDER BY status DESC, requested_at ASC"
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
await interaction.followup.send("No whitelist entries found.", ephemeral=True)
|
||||
return
|
||||
|
||||
pending = [r for r in rows if r[2] == "pending"]
|
||||
approved = [r for r in rows if r[2] == "approved"]
|
||||
|
||||
embed = discord.Embed(title="Minecraft Whitelist", color=0x57f287)
|
||||
|
||||
if pending:
|
||||
lines = []
|
||||
for mc_name, discord_id, _, requested_at in pending:
|
||||
date = requested_at[:10]
|
||||
user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked"
|
||||
lines.append(f"• `{mc_name}` — {user_ref} *(requested {date})*")
|
||||
embed.add_field(name=f"⏳ Pending ({len(pending)})", value="\n".join(lines), inline=False)
|
||||
|
||||
if approved:
|
||||
lines = []
|
||||
for mc_name, discord_id, _, _ in approved:
|
||||
user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked"
|
||||
lines.append(f"• `{mc_name}` — {user_ref}")
|
||||
embed.add_field(name=f"✅ Approved ({len(approved)})", value="\n".join(lines), inline=False)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
# ── /whitelist add ─────────────────────────────────────────────────────────
|
||||
|
||||
@app_commands.command(name="add", description="Add a player to the Minecraft whitelist via RCON")
|
||||
@app_commands.describe(minecraft_username="The Minecraft username to whitelist")
|
||||
async def whitelist_add(self, interaction: discord.Interaction, minecraft_username: str):
|
||||
"""Approve a whitelist request via RCON and DM the requester (owner only).
|
||||
|
||||
If a pending request exists for the username, marks it as approved and
|
||||
notifies the linked Discord user. If no request exists, inserts an
|
||||
approved record directly.
|
||||
"""
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
result = await rcon_command(f"whitelist add {minecraft_username}")
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"RCON error: `{e}`", ephemeral=True)
|
||||
return
|
||||
|
||||
discord_user_id = None
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT discord_user_id FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
|
||||
(minecraft_username,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
|
||||
if row:
|
||||
discord_user_id = row[0]
|
||||
await db.execute(
|
||||
"UPDATE whitelist SET status = 'approved' WHERE minecraft_username = ? COLLATE NOCASE",
|
||||
(minecraft_username,)
|
||||
)
|
||||
else:
|
||||
# No prior request — insert a new approved record
|
||||
await db.execute(
|
||||
"INSERT INTO whitelist (minecraft_username, discord_user_id, status, requested_at) VALUES (?, NULL, 'approved', ?)",
|
||||
(minecraft_username, datetime.now(timezone.utc).isoformat())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
dm_note = ""
|
||||
if discord_user_id:
|
||||
try:
|
||||
user = await self.bot.fetch_user(discord_user_id)
|
||||
await user.send(
|
||||
f"Hey! Your whitelist request for **{minecraft_username}** has been approved! 🎉\n"
|
||||
f"You can now join the Minecraft server at `{SERVER_IP}`."
|
||||
)
|
||||
dm_note = f"\nDM sent to <@{discord_user_id}>."
|
||||
except discord.Forbidden:
|
||||
dm_note = f"\n⚠️ Couldn't DM <@{discord_user_id}> (DMs disabled)."
|
||||
except Exception as e:
|
||||
dm_note = f"\n⚠️ DM to <@{discord_user_id}> failed: {e}"
|
||||
|
||||
await interaction.followup.send(
|
||||
f"✅ Added `{minecraft_username}` to the whitelist.\nServer: `{result}`{dm_note}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# ── /whitelist remove ──────────────────────────────────────────────────────
|
||||
|
||||
@app_commands.command(name="remove", description="Remove a player from the Minecraft whitelist via RCON")
|
||||
@app_commands.describe(minecraft_username="The Minecraft username to remove")
|
||||
async def whitelist_remove(self, interaction: discord.Interaction, minecraft_username: str):
|
||||
"""Remove a player from the Minecraft whitelist via RCON and the DB (owner only)."""
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
try:
|
||||
result = await rcon_command(f"whitelist remove {minecraft_username}")
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"RCON error: `{e}`", ephemeral=True)
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"DELETE FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
|
||||
(minecraft_username,)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await interaction.followup.send(
|
||||
f"🗑️ Removed `{minecraft_username}` from the whitelist.\nServer: `{result}`",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Whitelist(bot))
|
||||
@@ -44,25 +44,38 @@ JOHN_ROLES = [
|
||||
|
||||
|
||||
class AprilFools(commands.Cog):
|
||||
"""Cog providing the /johnify and /unjohnify slash commands for April Fools.
|
||||
|
||||
Johnification renames every channel and role in the server to variations of
|
||||
"John" and saves the original names to april_fools_backup.json. A timed
|
||||
auto-revert task fires at REVERT_TIME to restore everything automatically.
|
||||
The backup file serves as a state sentinel — if it exists on startup the
|
||||
auto-revert is rescheduled, ensuring names are restored even after a restart.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self._revert_task: asyncio.Task | None = None
|
||||
|
||||
async def cog_load(self):
|
||||
"""Re-schedule the auto-revert task if a backup exists from a previous run."""
|
||||
if BACKUP_PATH.exists():
|
||||
logger.info("April Fools: backup found on load, scheduling auto-revert")
|
||||
self._schedule_revert()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Cancel the pending auto-revert task when the cog unloads."""
|
||||
if self._revert_task and not self._revert_task.done():
|
||||
self._revert_task.cancel()
|
||||
|
||||
def _schedule_revert(self):
|
||||
"""Start the auto-revert task, skipping if one is already running."""
|
||||
if self._revert_task and not self._revert_task.done():
|
||||
return
|
||||
self._revert_task = asyncio.create_task(self._wait_and_revert())
|
||||
|
||||
async def _wait_and_revert(self):
|
||||
"""Sleep until REVERT_TIME then call _do_revert to restore all names."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delay = (REVERT_TIME - now).total_seconds()
|
||||
if delay > 0:
|
||||
@@ -73,6 +86,18 @@ class AprilFools(commands.Cog):
|
||||
logger.info(f"April Fools: auto-revert result — {msg}")
|
||||
|
||||
async def _do_johnify(self, guild: discord.Guild) -> tuple[bool, str]:
|
||||
"""Rename all channels and roles to John-themed names and save a backup.
|
||||
|
||||
Writes original names to BACKUP_PATH as JSON before making any changes.
|
||||
Returns ``(False, message)`` immediately if a backup already exists (i.e.,
|
||||
Johnification is already active). A 0.6-second delay is inserted between
|
||||
each rename to avoid Discord API rate limits.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bool, str]
|
||||
``(True, "John.")`` on success, ``(False, reason)`` on failure.
|
||||
"""
|
||||
if BACKUP_PATH.exists():
|
||||
return False, "Already Johnified! Use `/unjohnify` to revert first."
|
||||
|
||||
@@ -113,6 +138,17 @@ class AprilFools(commands.Cog):
|
||||
return True, "John."
|
||||
|
||||
async def _do_revert(self) -> tuple[bool, str]:
|
||||
"""Restore all channel and role names from the backup JSON, then delete it.
|
||||
|
||||
Returns ``(False, reason)`` if no backup exists. Iterates every entry in
|
||||
the backup and renames matching channels/roles; missing channels or roles
|
||||
(deleted since Johnification) are silently skipped.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bool, str]
|
||||
``(True, message)`` on success, ``(False, reason)`` if no backup found.
|
||||
"""
|
||||
if not BACKUP_PATH.exists():
|
||||
return False, "Nothing to revert — no backup found."
|
||||
|
||||
@@ -147,6 +183,7 @@ class AprilFools(commands.Cog):
|
||||
@app_commands.command(name="johnify", description="Johnify the entire server (April Fools)")
|
||||
@ModerationBase.is_admin()
|
||||
async def johnify(self, interaction: discord.Interaction):
|
||||
"""Rename all channels and roles to John-themed names (admin only)."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
if not interaction.guild:
|
||||
await interaction.followup.send("This command can only be used in a server.", ephemeral=True)
|
||||
@@ -157,6 +194,7 @@ class AprilFools(commands.Cog):
|
||||
@app_commands.command(name="unjohnify", description="Revert the Johnification and restore all names")
|
||||
@ModerationBase.is_admin()
|
||||
async def unjohnify(self, interaction: discord.Interaction):
|
||||
"""Restore all original channel and role names from the backup (admin only)."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok, msg = await self._do_revert()
|
||||
await interaction.followup.send(msg, ephemeral=True)
|
||||
|
||||
@@ -14,21 +14,35 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ArchipelagoMonitor(commands.Cog):
|
||||
"""Monitors Archipelago multiworld log file and posts updates to Discord."""
|
||||
|
||||
"""Cog that monitors an Archipelago multiworld server log and posts live updates to Discord.
|
||||
|
||||
Polls the most recently modified ``Server_*.txt`` log file in the configured
|
||||
log directory every 2 seconds. New lines are matched against regex patterns
|
||||
for item sends, player joins/leaves, DeathLink tag changes, and server starts.
|
||||
Matching events are formatted as Discord embeds and sent to ARCHIPELAGO_CHANNEL_ID.
|
||||
|
||||
Configuration is read from environment variables:
|
||||
- ``ARCHIPELAGO_ENABLED`` — set to ``true`` to enable (default: disabled).
|
||||
- ``ARCHIPELAGO_CHANNEL_ID`` — Discord channel to post events to.
|
||||
- ``ARCHIPELAGO_LOG_DIR`` — directory containing ``Server_*.txt`` files.
|
||||
|
||||
A ``seen_lines`` set prevents duplicate notifications if the monitor lags
|
||||
behind or re-reads a file from position 0 after a file switch.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
|
||||
# Get configuration from environment variables
|
||||
self.channel_id = int(os.getenv("ARCHIPELAGO_CHANNEL_ID", "0"))
|
||||
self.log_directory = os.getenv("ARCHIPELAGO_LOG_DIR", "/home/lilacrose/Archipelago/logs")
|
||||
self.enabled = os.getenv("ARCHIPELAGO_ENABLED", "false").lower() == "true"
|
||||
|
||||
|
||||
self.notification_channel: Optional[discord.TextChannel] = None
|
||||
self.current_log_file: Optional[Path] = None
|
||||
self.last_position = 0 # Track where we last read in the file
|
||||
self.seen_lines: Set[str] = set() # Track lines we've already processed
|
||||
|
||||
self.seen_lines: Set[str] = set() # Track lines we've already processed to avoid duplicates
|
||||
|
||||
# Regex patterns for parsing log messages
|
||||
self.patterns = {
|
||||
'item_send': re.compile(r'\(Team #\d+\) (.+?) sent (.+?) to (.+?) \((.+?)\)'),
|
||||
@@ -40,6 +54,7 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
|
||||
logger.info(f"Initializing log monitor - Enabled: {self.enabled}, Channel: {self.channel_id}, Log dir: {self.log_directory}")
|
||||
|
||||
# Only start the polling loop if both the feature flag and channel ID are configured
|
||||
if self.enabled and self.channel_id:
|
||||
logger.info("Monitor will start when bot is ready")
|
||||
self.monitor_log.start()
|
||||
@@ -50,7 +65,7 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.info("Monitor DISABLED: ARCHIPELAGO_CHANNEL_ID is not set")
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Called when the cog is unloaded."""
|
||||
"""Stop the polling loop when the cog unloads."""
|
||||
self.monitor_log.cancel()
|
||||
|
||||
def get_most_recent_log_file(self) -> Optional[Path]:
|
||||
@@ -79,7 +94,13 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
|
||||
@tasks.loop(seconds=2)
|
||||
async def monitor_log(self):
|
||||
"""Monitor the Archipelago log file for new events."""
|
||||
"""Poll the log file every 2 seconds and process any newly written lines.
|
||||
|
||||
Switches to a newer file automatically if a more recently modified
|
||||
``Server_*.txt`` appears (e.g. after a server restart). Uses a byte
|
||||
offset (``last_position``) to read only lines appended since the last
|
||||
tick, rather than scanning from the beginning each time.
|
||||
"""
|
||||
try:
|
||||
# Check if we need to find a new log file (first run or file changed)
|
||||
most_recent = self.get_most_recent_log_file()
|
||||
@@ -126,9 +147,10 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
|
||||
self.seen_lines.add(line)
|
||||
|
||||
# Keep seen_lines from growing too large
|
||||
# Bound memory usage: keep only the 500 most recently seen lines.
|
||||
# Retaining some history prevents re-processing lines still in the
|
||||
# rolling window if the set was cleared entirely.
|
||||
if len(self.seen_lines) > 1000:
|
||||
# Remove oldest half
|
||||
self.seen_lines = set(list(self.seen_lines)[-500:])
|
||||
|
||||
# Try to parse and handle the line
|
||||
@@ -139,9 +161,14 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
|
||||
@monitor_log.before_loop
|
||||
async def before_monitor(self):
|
||||
"""Wait until the bot is ready before starting the monitor."""
|
||||
"""Wait until the bot is ready, resolve the notification channel, and seek to EOF.
|
||||
|
||||
Seeking to the end of the log file on startup prevents the bot from
|
||||
replaying old events each time it restarts. Posts a startup message to
|
||||
the notification channel confirming which log file is being monitored.
|
||||
"""
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
# Get the notification channel
|
||||
self.notification_channel = self.bot.get_channel(self.channel_id)
|
||||
if self.notification_channel:
|
||||
@@ -172,7 +199,17 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Could not find channel with ID {self.channel_id}")
|
||||
|
||||
async def process_log_line(self, line: str):
|
||||
"""Process a single log line and send notifications if needed."""
|
||||
"""Match a single log line against all known patterns and dispatch to the handler.
|
||||
|
||||
Tries patterns in priority order: item_send → join → leave → tag_change →
|
||||
server_start. Stops at the first match. Unrecognised lines are silently
|
||||
ignored (not every log line corresponds to a Discord-worthy event).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
line:
|
||||
A single stripped line from the Archipelago server log.
|
||||
"""
|
||||
if not self.notification_channel:
|
||||
return
|
||||
|
||||
@@ -223,7 +260,22 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
async def handle_item_send(self, sender: str, item: str, receiver: str, location: str):
|
||||
"""Handle an item send notification."""
|
||||
"""Post a Discord embed for an item being sent between players.
|
||||
|
||||
Replaces underscores with spaces in the item and location names to make
|
||||
auto-generated game identifiers more human-readable.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sender:
|
||||
The player who sent the item.
|
||||
item:
|
||||
The item name (raw from the log).
|
||||
receiver:
|
||||
The player who received the item.
|
||||
location:
|
||||
The in-game location the item was found at.
|
||||
"""
|
||||
# Clean up item name (remove underscores, make readable)
|
||||
item_display = item.replace('_', ' ')
|
||||
location_display = location.replace('_', ' ')
|
||||
@@ -245,7 +297,15 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Failed to send Discord message: {e}")
|
||||
|
||||
async def handle_join(self, player: str, game: str):
|
||||
"""Handle a player join notification."""
|
||||
"""Post a join notification embed when a player connects to the session.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
player:
|
||||
The connecting player's name.
|
||||
game:
|
||||
The game title they are playing (underscores replaced with spaces).
|
||||
"""
|
||||
# Clean up game name
|
||||
game_display = game.replace('_', ' ')
|
||||
|
||||
@@ -261,7 +321,13 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Failed to send Discord message: {e}")
|
||||
|
||||
async def handle_leave(self, player: str):
|
||||
"""Handle a player disconnect notification."""
|
||||
"""Post a leave notification embed when a player disconnects.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
player:
|
||||
The disconnecting player's name.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
description=f"👋 **{player}** left the game",
|
||||
color=discord.Color.greyple()
|
||||
@@ -274,7 +340,18 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Failed to send Discord message: {e}")
|
||||
|
||||
async def handle_tag_change(self, player: str, tags: str):
|
||||
"""Handle a tag change notification (like enabling DeathLink)."""
|
||||
"""Post a notification embed for interesting tag changes (currently only DeathLink).
|
||||
|
||||
Only fires when the new tag set contains ``DeathLink``; all other tag
|
||||
changes are silently ignored.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
player:
|
||||
The player who changed their tags.
|
||||
tags:
|
||||
The full new tag string from the log (comma-separated).
|
||||
"""
|
||||
# Only notify for interesting tags
|
||||
if 'DeathLink' in tags:
|
||||
embed = discord.Embed(
|
||||
@@ -289,7 +366,18 @@ class ArchipelagoMonitor(commands.Cog):
|
||||
logger.error(f"Failed to send Discord message: {e}")
|
||||
|
||||
async def handle_server_start(self, address: str, password: str):
|
||||
"""Handle server start notification."""
|
||||
"""Post a server-start embed including the connection address and password.
|
||||
|
||||
The password field is omitted when it equals ``"None"`` (the literal
|
||||
string Archipelago uses when no password was set).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
address:
|
||||
The server's host:port string.
|
||||
password:
|
||||
The server password, or ``"None"`` if no password was set.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
title="🎮 Archipelago Server Started",
|
||||
color=discord.Color.green()
|
||||
|
||||
@@ -5,17 +5,34 @@ from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Role whose assignment triggers the bot-trap check
|
||||
ROLE_ID_TO_BAN = 1439354601672282335
|
||||
# Channel for logging trap-role events
|
||||
LOG_CHANNEL_ID = 1440055015711703242
|
||||
# Fallback channel to ping the user if DMs are disabled
|
||||
FALLBACK_CHANNEL_ID = 876772600704020533
|
||||
# Members newer than this threshold are assumed to be bots and auto-banned
|
||||
NEW_MEMBER_THRESHOLD_DAYS = 1
|
||||
|
||||
|
||||
class AutoBanOnRole(commands.Cog):
|
||||
"""Cog that automatically bans or warns users who receive the bot-trap role.
|
||||
|
||||
When the trap role is assigned, the response depends on how long the
|
||||
member has been in the server:
|
||||
|
||||
- New members (< 1 day): assumed to be bots — banned immediately with a
|
||||
7-day message purge and logged to LOG_CHANNEL_ID.
|
||||
- Established members (>= 1 day): the role is removed and they are warned
|
||||
via DM (or fallback channel ping if DMs are disabled).
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
"""Watch for the trap role being added and take the appropriate action."""
|
||||
# Role was just added
|
||||
before_roles = set(before.roles)
|
||||
after_roles = set(after.roles)
|
||||
@@ -26,10 +43,10 @@ class AutoBanOnRole(commands.Cog):
|
||||
if role.id == ROLE_ID_TO_BAN:
|
||||
trap_role = role
|
||||
break
|
||||
|
||||
|
||||
if trap_role is None:
|
||||
return
|
||||
|
||||
|
||||
guild = after.guild
|
||||
|
||||
# Check how long they've been in the server
|
||||
@@ -38,15 +55,16 @@ class AutoBanOnRole(commands.Cog):
|
||||
is_new_member = server_join_age < timedelta(days=NEW_MEMBER_THRESHOLD_DAYS)
|
||||
|
||||
if is_new_member:
|
||||
try:
|
||||
try:
|
||||
await guild.ban(
|
||||
after,
|
||||
reason="Bot automatically banned due to receiving bot trap role",
|
||||
delete_message_days=7
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-ban bot trap user {after.id}: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
|
||||
channel = guild.get_channel(LOG_CHANNEL_ID)
|
||||
if channel is not None and isinstance(channel, discord.abc.Messageable):
|
||||
embed = discord.Embed(
|
||||
@@ -61,6 +79,7 @@ class AutoBanOnRole(commands.Cog):
|
||||
embed.set_thumbnail(url=after.display_avatar.url)
|
||||
await channel.send(embed=embed)
|
||||
else:
|
||||
# Established member — warn and remove the trap role instead of banning
|
||||
warning_message = (
|
||||
f"**WARNING!** You were given a role that is designed to auto-ban bots. "
|
||||
f"Since you're an established member, the role has been removed instead. "
|
||||
@@ -75,8 +94,9 @@ class AutoBanOnRole(commands.Cog):
|
||||
pass
|
||||
|
||||
await after.remove_roles(trap_role, reason="Auto removed trap role from established member")
|
||||
|
||||
|
||||
if not dm_sent:
|
||||
# Fall back to pinging them in a public channel if DMs are closed
|
||||
fallback_channel = guild.get_channel(FALLBACK_CHANNEL_ID)
|
||||
if fallback_channel is not None and isinstance(fallback_channel, discord.abc.Messageable):
|
||||
await fallback_channel.send(f"{after.mention}\n{warning_message}")
|
||||
@@ -100,5 +120,6 @@ class AutoBanOnRole(commands.Cog):
|
||||
async def on_ready(self):
|
||||
logger.info("AutoBanOnRole cog loaded and ready!")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(AutoBanOnRole(bot))
|
||||
await bot.add_cog(AutoBanOnRole(bot))
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class ChainDetector(commands.Cog):
|
||||
"""Cog that detects message chains and mirrors them once a third user joins in.
|
||||
|
||||
A chain is defined as 3 different users in the same channel sending the
|
||||
exact same text (or the same sticker). When the third unique participant
|
||||
is detected, the bot echoes the message, then resets the chain state so
|
||||
subsequent posts don't trigger it again.
|
||||
|
||||
Messages with attachments or any kind of mention (user, role, channel,
|
||||
everyone) are excluded to prevent abuse.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
# Stores per-channel:
|
||||
# Stores per-channel state:
|
||||
# {channel_id: {"last_message": str, "users": [user_ids]}}
|
||||
self.cache = {}
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
# Ignore bot messages
|
||||
"""Track messages for chain detection and echo on the third participant."""
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
@@ -25,6 +37,7 @@ class ChainDetector(commands.Cog):
|
||||
if message.stickers:
|
||||
sticker = message.stickers[0]
|
||||
chain_key = f"sticker:{sticker.id}"
|
||||
|
||||
async def send_chain():
|
||||
await message.channel.send(stickers=[sticker])
|
||||
else:
|
||||
@@ -32,12 +45,13 @@ class ChainDetector(commands.Cog):
|
||||
if not content:
|
||||
return
|
||||
chain_key = content
|
||||
|
||||
async def send_chain():
|
||||
await message.channel.send(chain_key)
|
||||
|
||||
channel_id = message.channel.id
|
||||
|
||||
# Initialize cache for this channel
|
||||
# Initialize cache for this channel on first message
|
||||
if channel_id not in self.cache:
|
||||
self.cache[channel_id] = {
|
||||
"last_message": chain_key,
|
||||
@@ -47,22 +61,22 @@ class ChainDetector(commands.Cog):
|
||||
|
||||
chain = self.cache[channel_id]
|
||||
|
||||
# If message matches the chain message
|
||||
if chain_key == chain["last_message"]:
|
||||
# Only count if it's a DIFFERENT user
|
||||
# Only count if it's a DIFFERENT user — same user repeating doesn't advance the chain
|
||||
if message.author.id not in chain["users"]:
|
||||
chain["users"].append(message.author.id)
|
||||
else:
|
||||
# Reset chain
|
||||
# Different message breaks the chain — start fresh
|
||||
chain["last_message"] = chain_key
|
||||
chain["users"] = [message.author.id]
|
||||
|
||||
# If three different users said the same thing
|
||||
# Echo on exactly the third unique participant
|
||||
if len(chain["users"]) == 3:
|
||||
await send_chain()
|
||||
# Reset the chain completely
|
||||
# Reset so a fourth+ message doesn't echo again
|
||||
chain["last_message"] = ""
|
||||
chain["users"] = []
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(ChainDetector(bot))
|
||||
await bot.add_cog(ChainDetector(bot))
|
||||
|
||||
@@ -22,14 +22,31 @@ DAILY_POST_TIME = time(hour=10, minute=0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class DailyFractal(commands.Cog):
|
||||
"""Cog that posts a daily fractal image to a designated channel at 10:00 UTC.
|
||||
|
||||
Fetches fractal metadata and the image file from a local web service running
|
||||
at WEBSITE_BASE. After posting, it automatically unpins the previous day's
|
||||
fractal and pins the new one so only the latest post is pinned.
|
||||
|
||||
The ``!fractal`` command (owner only) triggers a manual post and silently
|
||||
deletes the command message.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.daily_fractal.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Cancel the background loop when the cog unloads."""
|
||||
self.daily_fractal.cancel()
|
||||
|
||||
async def _post_fractal(self):
|
||||
"""Fetch and post today's fractal.
|
||||
|
||||
Hits the local API for metadata and the image, builds an embed,
|
||||
replaces the pinned fractal from the previous day, then pins the
|
||||
new message.
|
||||
"""
|
||||
channel = self.bot.get_channel(FRACTAL_CHANNEL_ID)
|
||||
if channel is None or not isinstance(channel, discord.TextChannel):
|
||||
logger.error("Daily fractal: channel not found")
|
||||
@@ -60,6 +77,7 @@ class DailyFractal(commands.Cog):
|
||||
fractal_name = meta.get("name") or "Unknown"
|
||||
fractal_type = (meta.get("type") or "unknown").replace("_", " ").title()
|
||||
seed = meta.get("seed")
|
||||
# Derive palette from metadata, falling back to a deterministic seed-based lookup
|
||||
palette = meta.get("palette") or (PALETTE_NAMES[seed % len(PALETTE_NAMES)] if seed is not None else "Unknown")
|
||||
|
||||
embed = discord.Embed(
|
||||
@@ -75,6 +93,7 @@ class DailyFractal(commands.Cog):
|
||||
|
||||
file = discord.File(io.BytesIO(image_bytes), filename="fractal.png")
|
||||
|
||||
# Unpin the previous day's fractal so only the latest stays pinned
|
||||
try:
|
||||
pins = await channel.pins()
|
||||
for pin in pins:
|
||||
@@ -100,10 +119,12 @@ class DailyFractal(commands.Cog):
|
||||
|
||||
@tasks.loop(time=DAILY_POST_TIME)
|
||||
async def daily_fractal(self):
|
||||
"""Background task that fires once daily at DAILY_POST_TIME (10:00 UTC)."""
|
||||
await self._post_fractal()
|
||||
|
||||
@commands.command(name="fractal")
|
||||
async def fractal_command(self, ctx):
|
||||
"""Manually trigger a fractal post (owner only). Deletes the command message."""
|
||||
if ctx.author.id != OWNER_ID:
|
||||
return
|
||||
await ctx.message.delete()
|
||||
@@ -111,6 +132,7 @@ class DailyFractal(commands.Cog):
|
||||
|
||||
@daily_fractal.before_loop
|
||||
async def before_daily_fractal(self):
|
||||
"""Wait until the bot is ready before the daily loop starts."""
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
|
||||
@@ -11,13 +11,22 @@ load_dotenv()
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Channels that receive commit notifications
|
||||
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884]
|
||||
WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
|
||||
# Updated port to be 8000 to prevent conflicts (should work)
|
||||
# I think the port itself wants me dead
|
||||
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8000))
|
||||
|
||||
|
||||
class GitWebhook(commands.Cog):
|
||||
"""Cog that runs an aiohttp webhook server to receive GitHub/GitLab push events.
|
||||
|
||||
On cog load, starts a TCPSite on WEBHOOK_PORT (default 8000). Each push
|
||||
event is formatted into a Discord embed and broadcast to all COMMIT_CHANNEL_IDS.
|
||||
|
||||
Signature verification uses HMAC-SHA256 with GITHUB_WEBHOOK_SECRET. If the
|
||||
secret is empty, verification is skipped (useful for local testing).
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.app = web.Application()
|
||||
@@ -25,7 +34,7 @@ class GitWebhook(commands.Cog):
|
||||
self.app.router.add_get('/health', self.health_check)
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
|
||||
async def cog_load(self):
|
||||
"""Start the webhook server when cog loads."""
|
||||
self.runner = web.AppRunner(self.app)
|
||||
@@ -34,7 +43,7 @@ class GitWebhook(commands.Cog):
|
||||
await self.site.start()
|
||||
logger.info(f"Git webhook server started on port {WEBHOOK_PORT}")
|
||||
logger.info(f"Sending notifications to {len(COMMIT_CHANNEL_IDS)} channel(s)")
|
||||
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Stop the webhook server when cog unloads."""
|
||||
if self.site:
|
||||
@@ -42,12 +51,16 @@ class GitWebhook(commands.Cog):
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Git webhook server stopped")
|
||||
|
||||
|
||||
def verify_signature(self, payload_body, signature_header):
|
||||
"""Verify GitHub webhook signature for security."""
|
||||
"""Verify GitHub webhook signature for security.
|
||||
|
||||
Uses HMAC-SHA256 with the configured secret. Returns True immediately
|
||||
if no secret is configured (test/dev mode).
|
||||
"""
|
||||
if not WEBHOOK_SECRET:
|
||||
return True
|
||||
|
||||
|
||||
hash_object = hmac.new(
|
||||
WEBHOOK_SECRET.encode('utf-8'),
|
||||
msg=payload_body,
|
||||
@@ -55,13 +68,19 @@ class GitWebhook(commands.Cog):
|
||||
)
|
||||
expected_signature = "sha256=" + hash_object.hexdigest()
|
||||
return hmac.compare_digest(expected_signature, signature_header)
|
||||
|
||||
|
||||
async def health_check(self, request):
|
||||
"""Health check endpoint."""
|
||||
"""Health check endpoint — returns 200 OK so uptime monitors can probe the server."""
|
||||
return web.json_response({"status": "healthy"})
|
||||
|
||||
|
||||
async def handle_webhook(self, request):
|
||||
"""Handle incoming Git webhook from GitHub/GitLab."""
|
||||
"""Handle incoming Git webhook from GitHub or GitLab.
|
||||
|
||||
Detects the payload format by inspecting top-level keys:
|
||||
- GitHub ping: presence of ``zen`` and ``hook_id``.
|
||||
- GitHub push: presence of ``commits`` and ``repository``.
|
||||
- GitLab push: presence of ``commits`` and ``project``.
|
||||
"""
|
||||
try:
|
||||
# Verify signature if secret is configured
|
||||
if WEBHOOK_SECRET:
|
||||
@@ -72,7 +91,7 @@ class GitWebhook(commands.Cog):
|
||||
data = await request.json()
|
||||
else:
|
||||
data = await request.json()
|
||||
|
||||
|
||||
# Handle GitHub ping event (test from GitHub)
|
||||
if 'zen' in data and 'hook_id' in data:
|
||||
logger.info("Received GitHub ping event - webhook is configured correctly!")
|
||||
@@ -90,44 +109,47 @@ class GitWebhook(commands.Cog):
|
||||
if not channels:
|
||||
logger.error("No valid channels found!")
|
||||
return web.json_response({"error": "No channels found"}, status=500)
|
||||
|
||||
|
||||
# Handle GitHub push events
|
||||
if 'commits' in data and 'repository' in data:
|
||||
await self.handle_github_push(data, channels)
|
||||
return web.json_response({"status": "success"}, status=200)
|
||||
|
||||
|
||||
# Handle GitLab push events
|
||||
elif 'project' in data and 'commits' in data:
|
||||
await self.handle_gitlab_push(data, channels)
|
||||
return web.json_response({"status": "success"}, status=200)
|
||||
|
||||
|
||||
logger.warning(f"Unknown webhook format. Keys in data: {list(data.keys())}")
|
||||
return web.json_response({"error": "Unknown webhook format"}, status=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook error: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_github_push(self, data, channels):
|
||||
"""Handle GitHub push webhook."""
|
||||
"""Format a GitHub push payload as a Discord embed and broadcast it.
|
||||
|
||||
Single commits use field-based formatting for full message visibility.
|
||||
Multi-commit pushes use a description list capped at 10 entries.
|
||||
"""
|
||||
repo_name = data['repository']['full_name']
|
||||
repo_url = data['repository']['html_url']
|
||||
branch = data['ref'].split('/')[-1]
|
||||
pusher = data['pusher']['name']
|
||||
commits = data['commits']
|
||||
compare_url = data.get('compare', '')
|
||||
|
||||
|
||||
if not commits:
|
||||
return
|
||||
|
||||
# For single commit, use fields for better formatting
|
||||
|
||||
if len(commits) == 1:
|
||||
commit = commits[0]
|
||||
short_sha = commit['id'][:7]
|
||||
message = commit['message']
|
||||
author = commit['author']['name']
|
||||
url = commit['url']
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📝 [{repo_name}:{branch}] New commit",
|
||||
url=compare_url if compare_url else repo_url,
|
||||
@@ -151,46 +173,48 @@ class GitWebhook(commands.Cog):
|
||||
author = commit['author']['name']
|
||||
url = commit['url']
|
||||
commit_lines.append(f"[`{short_sha}`]({url}) {message} - {author}")
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📝 [{repo_name}:{branch}] {len(commits)} new commits",
|
||||
url=compare_url if compare_url else repo_url,
|
||||
description="\n".join(commit_lines),
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
|
||||
if len(commits) > 10:
|
||||
embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*"
|
||||
|
||||
|
||||
embed.set_author(name=pusher, icon_url="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png")
|
||||
embed.set_footer(text="GitHub")
|
||||
|
||||
# Send to all Discord channels
|
||||
|
||||
for channel in channels:
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send to channel {channel.id}: {e}")
|
||||
|
||||
|
||||
async def handle_gitlab_push(self, data, channels):
|
||||
"""Handle GitLab push webhook."""
|
||||
"""Format a GitLab push payload as a Discord embed and broadcast it.
|
||||
|
||||
Mirrors the GitHub handler format but uses GitLab orange as the embed
|
||||
colour and omits the GitHub icon from the author field.
|
||||
"""
|
||||
repo_name = data['project']['path_with_namespace']
|
||||
repo_url = data['project']['web_url']
|
||||
branch = data['ref'].split('/')[-1]
|
||||
pusher = data['user_name']
|
||||
commits = data['commits']
|
||||
|
||||
|
||||
if not commits:
|
||||
return
|
||||
|
||||
# For single commit, use fields for better formatting
|
||||
|
||||
if len(commits) == 1:
|
||||
commit = commits[0]
|
||||
short_sha = commit['id'][:7]
|
||||
message = commit['message']
|
||||
author = commit['author']['name']
|
||||
url = commit['url']
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📝 [{repo_name}:{branch}] New commit",
|
||||
url=repo_url,
|
||||
@@ -214,26 +238,26 @@ class GitWebhook(commands.Cog):
|
||||
author = commit['author']['name']
|
||||
url = commit['url']
|
||||
commit_lines.append(f"[`{short_sha}`]({url}) {message} - {author}")
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"📝 [{repo_name}:{branch}] {len(commits)} new commits",
|
||||
url=repo_url,
|
||||
description="\n".join(commit_lines),
|
||||
color=0xFC6D26 # GitLab orange
|
||||
)
|
||||
|
||||
|
||||
if len(commits) > 10:
|
||||
embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*"
|
||||
|
||||
|
||||
embed.set_author(name=pusher)
|
||||
embed.set_footer(text="GitLab")
|
||||
|
||||
# Send to all Discord channels
|
||||
|
||||
for channel in channels:
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send to channel {channel.id}: {e}")
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(GitWebhook(bot))
|
||||
await bot.add_cog(GitWebhook(bot))
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class GoodBotListener(commands.Cog):
|
||||
"""Responds to 'good bot' mentions."""
|
||||
"""Responds to 'good bot' mentions with a blush emote."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
# Ignore bot's own messages
|
||||
"""Reply with a blush emote when someone mentions the bot and says 'good bot'."""
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
# Check if bot is mentioned and message contains "good bot" (case insensitive)
|
||||
if self.bot.user and self.bot.user.mentioned_in(message) and "good bot" in message.content.lower():
|
||||
await message.reply("<:CatgirlLacieBlush:1283389963018440754>")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(GoodBotListener(bot))
|
||||
await bot.add_cog(GoodBotListener(bot))
|
||||
|
||||
@@ -4,19 +4,33 @@ from discord.ext import commands
|
||||
from pathlib import Path
|
||||
import aiosqlite
|
||||
|
||||
# Role assigned to users who have opted out of pings
|
||||
NO_PINGS_ROLE_ID = 1439583411517001819
|
||||
# Role assigned to users who have opted in to receiving pings
|
||||
PINGS_OK_ROLE_ID = 1439583327844827227
|
||||
|
||||
# Only this user gets ping tracking in the DB (everyone with NO_PINGS_ROLE gets the reply)
|
||||
PROTECTED_USER_ID = 252130669919076352 # only lilac gets ping tracking
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "ping_protect.db"
|
||||
|
||||
|
||||
class PingProtect(commands.GroupCog, name="noping"):
|
||||
"""GroupCog providing the /noping slash command group and ping detection.
|
||||
|
||||
Intercepts messages that mention a protected user (anyone with the no-pings
|
||||
role, or PROTECTED_USER_ID) and replies reminding the sender not to ping
|
||||
them, unless the sender is on that user's allowlist.
|
||||
|
||||
Slash commands (/noping allow, remove, list, permitted) let protected users
|
||||
manage their own allowlists.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
async def cog_load(self):
|
||||
"""Create DB tables on first load if they don't exist yet."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ping_counts (
|
||||
@@ -34,12 +48,18 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
await db.commit()
|
||||
|
||||
def _has_permission(self, member: discord.Member) -> bool:
|
||||
"""Return True if the member may manage their own ping allowlist.
|
||||
|
||||
Covers both PROTECTED_USER_ID (always permitted) and anyone who holds
|
||||
NO_PINGS_ROLE_ID.
|
||||
"""
|
||||
return (
|
||||
member.id == PROTECTED_USER_ID or
|
||||
any(r.id == NO_PINGS_ROLE_ID for r in member.roles)
|
||||
)
|
||||
|
||||
async def _is_allowed(self, protected_user_id: int, pinger_id: int) -> bool:
|
||||
"""Return True if pinger_id is on protected_user_id's allowlist."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT 1 FROM allowlists WHERE protected_user_id = ? AND allowed_user_id = ?",
|
||||
@@ -48,6 +68,7 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
def _get_subject_pronoun(self, member: discord.Member | None) -> str:
|
||||
"""Derive a subject pronoun for the member from their pronoun roles."""
|
||||
if not member:
|
||||
return "They"
|
||||
role_names = {r.name.lower() for r in member.roles}
|
||||
@@ -60,16 +81,19 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
return "They"
|
||||
|
||||
def _get_verb(self, pronoun: str) -> str:
|
||||
"""Return the correct conjugation of 'have' for the given pronoun."""
|
||||
return "have" if pronoun == "They" else "has"
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
"""Detect pings to protected users and reply with a reminder."""
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
for user in message.mentions:
|
||||
member = message.guild.get_member(user.id)
|
||||
|
||||
# Skip if the mentioned user has opted in to receiving pings
|
||||
if member and any(r.id == PINGS_OK_ROLE_ID for r in member.roles):
|
||||
continue
|
||||
|
||||
@@ -80,10 +104,12 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
if not is_protected:
|
||||
continue
|
||||
|
||||
# Allowlisted senders may always ping the protected user
|
||||
if await self._is_allowed(user.id, message.author.id):
|
||||
continue
|
||||
|
||||
if user.id == PROTECTED_USER_ID:
|
||||
# Track ping count for the specific protected user
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("""
|
||||
INSERT INTO ping_counts (user_id, count) VALUES (?, 1)
|
||||
@@ -102,6 +128,7 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
@app_commands.command(name="allow", description="Allow someone to ping you")
|
||||
@app_commands.describe(user="The user to allow")
|
||||
async def allow(self, interaction: discord.Interaction, user: discord.Member):
|
||||
"""Add a user to your ping allowlist."""
|
||||
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
|
||||
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
|
||||
return
|
||||
@@ -118,6 +145,7 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
@app_commands.command(name="remove", description="Remove someone from your ping allowlist")
|
||||
@app_commands.describe(user="The user to remove")
|
||||
async def remove(self, interaction: discord.Interaction, user: discord.Member):
|
||||
"""Remove a user from your ping allowlist."""
|
||||
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
|
||||
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
|
||||
return
|
||||
@@ -133,6 +161,7 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
|
||||
@app_commands.command(name="list", description="View your ping allowlist")
|
||||
async def list_allowed(self, interaction: discord.Interaction):
|
||||
"""List everyone on your ping allowlist."""
|
||||
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
|
||||
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
|
||||
return
|
||||
@@ -153,6 +182,7 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
|
||||
@app_commands.command(name="permitted", description="See who has given you permission to ping them")
|
||||
async def permitted(self, interaction: discord.Interaction):
|
||||
"""List all users who have added you to their allowlist."""
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT protected_user_id FROM allowlists WHERE allowed_user_id = ?",
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class Welcome(commands.Cog):
|
||||
"""Cog that sends a welcome DM and channel message when a new member completes onboarding.
|
||||
|
||||
Two triggers fire _send_welcome: receiving their first real role (excluding
|
||||
@everyone) or the Discord pending/membership-screening flag clearing. A
|
||||
deduplication set ensures the welcome fires at most once per member per
|
||||
bot session, even if both triggers fire for the same join.
|
||||
|
||||
Members holding the bot-trap role are silently skipped so bots never receive
|
||||
a welcome before being auto-banned.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
# In-memory dedup set — reset on restart, which is fine for a per-session guard
|
||||
self._welcomed: set[int] = set()
|
||||
|
||||
async def _send_welcome(self, member):
|
||||
"""Send the welcome DM and channel message for a newly onboarded member.
|
||||
|
||||
Skips silently if the member has already been welcomed this session or
|
||||
holds the bot-trap role. Falls back to a channel ping if the DM fails.
|
||||
"""
|
||||
if member.id in self._welcomed:
|
||||
return
|
||||
self._welcomed.add(member.id)
|
||||
|
||||
bot_trap_role_id = 1439354601672282335
|
||||
if any(role.id == bot_trap_role_id for role in member.roles):
|
||||
return
|
||||
@@ -46,6 +65,7 @@ class Welcome(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before, after):
|
||||
"""Fire the welcome on first real role assignment or onboarding completion."""
|
||||
# Trigger 1: member receives their first real role (onboarding assigned a role)
|
||||
before_roles = [r for r in before.roles if r.name != "@everyone"]
|
||||
after_roles = [r for r in after.roles if r.name != "@everyone"]
|
||||
@@ -57,5 +77,6 @@ class Welcome(commands.Cog):
|
||||
if first_role or completed_pending:
|
||||
await self._send_welcome(after)
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(Welcome(bot))
|
||||
await bot.add_cog(Welcome(bot))
|
||||
|
||||
@@ -68,6 +68,7 @@ class MinesweeperGame:
|
||||
self.game_over = False
|
||||
self.won = False
|
||||
self.first_move = True
|
||||
self.detonated_cell: Optional[tuple[int, int]] = None # the mine the player clicked on
|
||||
|
||||
# 11-13 use custom bot emojis
|
||||
self.col_emojis = [
|
||||
@@ -112,6 +113,7 @@ class MinesweeperGame:
|
||||
|
||||
if self.board[row][col] == -1:
|
||||
self.game_over = True
|
||||
self.detonated_cell = (row, col)
|
||||
return True
|
||||
|
||||
if self.board[row][col] == 0:
|
||||
@@ -167,6 +169,9 @@ class MinesweeperGame:
|
||||
|
||||
def get_cell_display(self, row: int, col: int, show_all: bool = False) -> str:
|
||||
if show_all and self.board[row][col] == -1:
|
||||
# The cell the player clicked shows 💥; all other mines show 💣
|
||||
if self.detonated_cell == (row, col):
|
||||
return "💥"
|
||||
return "💣"
|
||||
|
||||
if self.flags[row][col]:
|
||||
|
||||
@@ -3,12 +3,27 @@ from discord.ext import commands
|
||||
from discord.ui import View, Button
|
||||
from .loader import ModerationBase
|
||||
|
||||
|
||||
class BanCommand(ModerationBase):
|
||||
"""Cog providing the !ban prefix command."""
|
||||
|
||||
@commands.command(name="ban")
|
||||
@ModerationBase.is_admin()
|
||||
async def ban(self, ctx, user: discord.User | discord.Member | str, *, reason: str | None = None):
|
||||
"""Ban a user (even if not in the server) with confirmation and log infraction"""
|
||||
# Convert raw ID or mention to user object if needed
|
||||
"""Ban a user from the server with a confirmation prompt.
|
||||
|
||||
The user does not need to be in the server — a raw user ID or
|
||||
mention also works. A DM is sent to the user before banning and
|
||||
an infraction record is written to the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The user to ban (mention, ID, or User/Member object).
|
||||
reason:
|
||||
Optional reason for the ban.
|
||||
"""
|
||||
# Resolve a raw ID string or mention into a User object
|
||||
if isinstance(user, str):
|
||||
user_id = user.strip("<@!>")
|
||||
try:
|
||||
@@ -17,7 +32,6 @@ class BanCommand(ModerationBase):
|
||||
await ctx.send("Could not find that user. Please provide a valid mention or ID.")
|
||||
return
|
||||
|
||||
# Ask for confirmation
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
@@ -44,8 +58,9 @@ class BanCommand(ModerationBase):
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
user_ref = user.mention if hasattr(user, "mention") else str(user)
|
||||
await ctx.send(
|
||||
f"Are you sure you want to ban {user.mention if hasattr(user, 'mention') else user}? "
|
||||
f"Are you sure you want to ban {user_ref}? "
|
||||
f"Reason: {reason or 'No reason provided'}",
|
||||
view=view
|
||||
)
|
||||
@@ -57,32 +72,29 @@ class BanCommand(ModerationBase):
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
# Attempt to DM user
|
||||
# Attempt to DM the user before banning so they can still receive the message
|
||||
try:
|
||||
if isinstance(user, discord.User):
|
||||
await user.send(
|
||||
f"You have been **banned** from **{ctx.guild.name}**.\n"
|
||||
f"Reason: {reason or 'No reason provided'}\n\n"
|
||||
f"If you believe this ban was unfiair and would like to appeal, join here: https://discord.gg/FYpfBzpjvq"
|
||||
)
|
||||
await user.send(
|
||||
f"You have been **banned** from **{ctx.guild.name}**.\n"
|
||||
f"Reason: {reason or 'No reason provided'}\n\n"
|
||||
f"If you believe this ban was unfair and would like to appeal, join here: https://discord.gg/FYpfBzpjvq"
|
||||
)
|
||||
except Exception:
|
||||
await ctx.send("Could not DM the user.")
|
||||
|
||||
# Perform the ban
|
||||
try:
|
||||
await ctx.guild.ban(discord.Object(id=user.id), reason=reason)
|
||||
await ctx.send(f"{user.mention if hasattr(user, 'mention') else user} has been banned.")
|
||||
await ctx.send(f"{user_ref} has been banned.")
|
||||
except Exception as e:
|
||||
await ctx.send(f"Failed to ban user: `{e}`")
|
||||
return
|
||||
|
||||
# Log infraction
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "ban", reason)
|
||||
|
||||
# Log to logging system if available
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(ctx.guild.id, "ban", user, ctx.author, reason)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(BanCommand(bot))
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import io
|
||||
import discord
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from discord.ext import commands
|
||||
from discord.ui import View, Button
|
||||
from .loader import ModerationBase
|
||||
|
||||
|
||||
class CleanBanCommand(ModerationBase):
|
||||
"""Cog providing the !cleanban prefix command."""
|
||||
|
||||
@commands.command(name="cleanban")
|
||||
@ModerationBase.is_admin()
|
||||
async def cleanban(self, ctx, user: discord.User | discord.Member | str, days: int = 1, *, reason: str | None = None):
|
||||
"""Ban a user and delete their messages from past specified days (1-7)"""
|
||||
async def cleanban(
|
||||
self,
|
||||
ctx,
|
||||
user: discord.User | discord.Member | str,
|
||||
days: int = 1,
|
||||
*,
|
||||
reason: str | None = None
|
||||
):
|
||||
"""Ban a user and delete their recent messages.
|
||||
|
||||
# Discord only supports 1-7 for delete_message_days
|
||||
Before banning, this command iterates all text channels and collects
|
||||
the user's messages from the past `days` days for the audit log.
|
||||
Discord then purges those messages via the delete_message_days parameter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The user to ban (mention, ID, or User/Member object).
|
||||
days:
|
||||
Number of days of message history to delete (1–7, default 1).
|
||||
reason:
|
||||
Optional reason for the ban.
|
||||
"""
|
||||
# Discord's ban API only supports 1–7 days of message deletion
|
||||
if days < 1 or days > 7:
|
||||
await ctx.send("Days must be between 1 and 7.")
|
||||
return
|
||||
|
||||
# Convert raw ID or mention to user object if needed
|
||||
# Resolve a raw ID string or mention into a User object
|
||||
if isinstance(user, str):
|
||||
user_id = user.strip("<@!>")
|
||||
try:
|
||||
@@ -23,7 +48,6 @@ class CleanBanCommand(ModerationBase):
|
||||
await ctx.send("Could not find that user. Please provide a valid mention or ID.")
|
||||
return
|
||||
|
||||
# Ask for confirmation — extra warning since this also wipes their message history
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
@@ -50,8 +74,9 @@ class CleanBanCommand(ModerationBase):
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
user_ref = user.mention if hasattr(user, "mention") else str(user)
|
||||
await ctx.send(
|
||||
f"Are you sure you want to cleanban {user.mention if hasattr(user, 'mention') else user}?\n"
|
||||
f"Are you sure you want to cleanban {user_ref}?\n"
|
||||
f"**This will delete their messages from the past {days} day(s) and ban them.**\n"
|
||||
f"Reason: {reason or 'No reason provided'}",
|
||||
view=view
|
||||
@@ -64,18 +89,42 @@ class CleanBanCommand(ModerationBase):
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
# DM before banning so they receive it before we delete them from the server
|
||||
# Collect the user's messages BEFORE banning so they can be preserved in the audit log
|
||||
collect_status = await ctx.send("📋 Collecting message history for audit log before banning...")
|
||||
deleted_messages = []
|
||||
cutoff_dt = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
for channel in ctx.guild.text_channels:
|
||||
if not channel.permissions_for(ctx.guild.me).read_message_history:
|
||||
continue
|
||||
try:
|
||||
async for msg in channel.history(limit=None, after=cutoff_dt, oldest_first=True):
|
||||
if msg.author.id == user.id:
|
||||
deleted_messages.append({
|
||||
"channel": channel.name,
|
||||
"channel_id": channel.id,
|
||||
"message_id": msg.id,
|
||||
"timestamp": msg.created_at.strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
"content": msg.content or "",
|
||||
"attachments": [a.url for a in msg.attachments],
|
||||
})
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
pass
|
||||
|
||||
try:
|
||||
if isinstance(user, discord.User):
|
||||
await user.send(
|
||||
f"You have been **banned** from **{ctx.guild.name}**.\n"
|
||||
f"Messages from the past {days} day(s) have been deleted.\n"
|
||||
f"Reason: {reason or 'No reason provided'}\n\n"
|
||||
)
|
||||
await collect_status.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# DM before banning so the message is delivered while the user is still in the server
|
||||
try:
|
||||
await user.send(
|
||||
f"You have been **banned** from **{ctx.guild.name}**.\n"
|
||||
f"Messages from the past {days} day(s) have been deleted.\n"
|
||||
f"Reason: {reason or 'No reason provided'}"
|
||||
)
|
||||
except Exception:
|
||||
await ctx.send("Could not DM the user.")
|
||||
|
||||
# Perform the ban with message deletion
|
||||
try:
|
||||
await ctx.guild.ban(
|
||||
discord.Object(id=user.id),
|
||||
@@ -83,20 +132,21 @@ class CleanBanCommand(ModerationBase):
|
||||
delete_message_days=days
|
||||
)
|
||||
await ctx.send(
|
||||
f"{user.mention if hasattr(user, 'mention') else user} has been banned.\n"
|
||||
f"{user_ref} has been banned.\n"
|
||||
f"Messages from the past {days} day(s) have been deleted."
|
||||
+ (f" ({len(deleted_messages)} message(s) logged)" if deleted_messages else "")
|
||||
)
|
||||
except Exception as e:
|
||||
await ctx.send(f"Failed to ban user: `{e}`")
|
||||
return
|
||||
|
||||
# Log infraction
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "cleanban", reason)
|
||||
|
||||
# Log to logging system if available
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(ctx.guild.id, "cleanban", user, ctx.author, reason)
|
||||
await logger.log_ban_messages(ctx.guild.id, user, deleted_messages, days)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(CleanBanCommand(bot))
|
||||
|
||||
@@ -6,10 +6,26 @@ from utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class InfractionCommand(ModerationBase):
|
||||
"""Cog providing the !inf command for querying and managing infractions.
|
||||
|
||||
Subcommands:
|
||||
- ``search <user_id>`` — list active infractions for a user.
|
||||
- ``search_full <user_id>`` — full history including removed infractions.
|
||||
- ``list`` — all active infractions in the server.
|
||||
- ``delete <id>`` — permanently delete an infraction with notify/silent choice.
|
||||
- ``resend <id>`` — re-send the auto-removal approval embed for an infraction.
|
||||
|
||||
Also manages an automatic infraction removal pipeline: every 24 hours,
|
||||
users whose most recent infraction is 4+ months old and who have stayed
|
||||
in the server without re-offending are sent to an approval channel for
|
||||
staff review. Persistent views are re-registered on restart via cog_load.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
super().__init__(bot)
|
||||
# Channel where auto-removal approval embeds are sent for staff review
|
||||
self.approval_channel_id = 1467901790333960377
|
||||
self.migrate_existing_infractions()
|
||||
self.check_auto_removals.start()
|
||||
@@ -20,7 +36,12 @@ class InfractionCommand(ModerationBase):
|
||||
await super().cog_unload()
|
||||
|
||||
async def cog_load(self):
|
||||
"""Re-register persistent views for all pending approval messages."""
|
||||
"""Re-register persistent views for all pending approval messages.
|
||||
|
||||
Called automatically by discord.py when the cog loads. Queries the DB
|
||||
for infractions that were already sent to the approval channel so their
|
||||
buttons remain functional after a bot restart.
|
||||
"""
|
||||
self.c.execute("""
|
||||
SELECT id, user_id, guild_id, type, reason, timestamp, moderator_id, approval_message_id
|
||||
FROM infractions
|
||||
@@ -37,31 +58,35 @@ class InfractionCommand(ModerationBase):
|
||||
self.bot.add_view(view, message_id=msg_id)
|
||||
|
||||
def migrate_existing_infractions(self):
|
||||
"""Add new columns to existing infractions table for auto-removal system."""
|
||||
"""Add new columns to existing infractions table for auto-removal system.
|
||||
|
||||
Uses ALTER TABLE ... ADD COLUMN and silently catches errors for columns
|
||||
that already exist, making this safe to run on every startup.
|
||||
"""
|
||||
try:
|
||||
# Add removed column (0 = active, 1 = removed)
|
||||
self.c.execute("ALTER TABLE infractions ADD COLUMN removed INTEGER DEFAULT 0")
|
||||
except Exception:
|
||||
pass # Column already exists
|
||||
|
||||
|
||||
try:
|
||||
# Add removed_date column
|
||||
self.c.execute("ALTER TABLE infractions ADD COLUMN removed_date TEXT")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
# Add removed_by column (moderator who approved removal)
|
||||
self.c.execute("ALTER TABLE infractions ADD COLUMN removed_by INTEGER")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
# Add skip_auto_removal column (1 = staff chose to keep it, skip future checks)
|
||||
self.c.execute("ALTER TABLE infractions ADD COLUMN skip_auto_removal INTEGER DEFAULT 0")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
# Add pending_approval column (1 = approval request already sent, waiting for staff decision)
|
||||
self.c.execute("ALTER TABLE infractions ADD COLUMN pending_approval INTEGER DEFAULT 0")
|
||||
@@ -78,15 +103,19 @@ class InfractionCommand(ModerationBase):
|
||||
|
||||
@tasks.loop(hours=24)
|
||||
async def check_auto_removals(self):
|
||||
"""Background task that runs every 24 hours to check for eligible infraction removals."""
|
||||
"""Background task that runs every 24 hours to check for eligible infraction removals.
|
||||
|
||||
Iterates every guild and user with active infractions. For each user,
|
||||
delegates to check_user_eligibility which applies the 4-month rule.
|
||||
"""
|
||||
try:
|
||||
self.c.execute("SELECT DISTINCT guild_id FROM infractions WHERE removed=0 AND skip_auto_removal=0 AND pending_approval=0")
|
||||
guilds = [row[0] for row in self.c.fetchall()]
|
||||
|
||||
for guild_id in guilds:
|
||||
self.c.execute("""
|
||||
SELECT DISTINCT user_id
|
||||
FROM infractions
|
||||
SELECT DISTINCT user_id
|
||||
FROM infractions
|
||||
WHERE guild_id=? AND removed=0 AND skip_auto_removal=0 AND pending_approval=0
|
||||
""", (guild_id,))
|
||||
users = [row[0] for row in self.c.fetchall()]
|
||||
@@ -103,13 +132,27 @@ class InfractionCommand(ModerationBase):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
async def check_user_eligibility(self, guild_id: int, user_id: int):
|
||||
"""Check if a user's most recent infraction is eligible for removal."""
|
||||
"""Check if a user's most recent infraction is eligible for auto-removal.
|
||||
|
||||
Eligibility criteria (all must be true):
|
||||
- User is not banned.
|
||||
- User is currently in the server.
|
||||
- Most recent infraction is more than 4 months (120 days) old.
|
||||
- User has not left and rejoined since the infraction (clock resets on rejoin).
|
||||
- User has no newer infractions of any kind.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild_id:
|
||||
Guild to check eligibility within.
|
||||
user_id:
|
||||
User to check.
|
||||
"""
|
||||
try:
|
||||
# Get guild
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
|
||||
|
||||
# Check if user is banned
|
||||
try:
|
||||
ban = await guild.fetch_ban(discord.Object(id=user_id))
|
||||
@@ -119,7 +162,7 @@ class InfractionCommand(ModerationBase):
|
||||
pass # User is not banned, continue
|
||||
except Exception:
|
||||
pass # Error checking ban, continue anyway
|
||||
|
||||
|
||||
# Check if user is in the server
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
@@ -133,11 +176,11 @@ class InfractionCommand(ModerationBase):
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
""", (user_id, guild_id))
|
||||
|
||||
|
||||
most_recent = self.c.fetchone()
|
||||
if not most_recent:
|
||||
return # No active infractions
|
||||
|
||||
|
||||
inf_id, timestamp_str, inf_type, reason, mod_id = most_recent
|
||||
infraction_date = datetime.fromisoformat(timestamp_str)
|
||||
|
||||
@@ -153,52 +196,59 @@ class InfractionCommand(ModerationBase):
|
||||
joined_naive = joined_at.replace(tzinfo=None)
|
||||
if joined_naive > infraction_date:
|
||||
return # Left and rejoined after infraction, not eligible
|
||||
|
||||
|
||||
# Check if user got ANY infractions after this one
|
||||
self.c.execute("""
|
||||
SELECT COUNT(*)
|
||||
SELECT COUNT(*)
|
||||
FROM infractions
|
||||
WHERE user_id=? AND guild_id=? AND timestamp > ?
|
||||
""", (user_id, guild_id, timestamp_str))
|
||||
|
||||
|
||||
newer_infractions = self.c.fetchone()[0]
|
||||
|
||||
|
||||
if newer_infractions > 0:
|
||||
return # User got infractions after this one, not eligible
|
||||
|
||||
# User is eligible! Send to staff for approval
|
||||
|
||||
# User is eligible — send to staff for approval
|
||||
await self.send_removal_approval(guild_id, user_id, inf_id, inf_type, reason, timestamp_str, mod_id)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking eligibility for user {user_id} in guild {guild_id}: {e}", exc_info=True)
|
||||
|
||||
async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int,
|
||||
async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int,
|
||||
inf_type: str, reason: str, timestamp: str, mod_id: int):
|
||||
"""Send an infraction removal request to the approval channel."""
|
||||
"""Send an infraction removal request to the approval channel.
|
||||
|
||||
Also marks the infraction as pending_approval and stores the alert
|
||||
message ID so the view can be re-registered on the next restart.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild_id, user_id, inf_id, inf_type, reason, timestamp, mod_id:
|
||||
Infraction details used to build the approval embed.
|
||||
"""
|
||||
try:
|
||||
approval_channel = self.bot.get_channel(self.approval_channel_id)
|
||||
if not approval_channel or not isinstance(approval_channel, discord.abc.Messageable):
|
||||
logger.error(f"Approval channel {self.approval_channel_id} not found")
|
||||
return
|
||||
|
||||
# Get guild, user, and moderator info
|
||||
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
user = await self.bot.fetch_user(user_id)
|
||||
user_tag = f"{user.name}#{user.discriminator}"
|
||||
except Exception:
|
||||
user_tag = f"Unknown User ({user_id})"
|
||||
|
||||
|
||||
try:
|
||||
moderator = await self.bot.fetch_user(mod_id)
|
||||
mod_tag = f"{moderator.name}#{moderator.discriminator}"
|
||||
except Exception:
|
||||
mod_tag = f"Unknown Mod ({mod_id})"
|
||||
|
||||
# Create approval embed
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🔔 Infraction Eligible for Auto-Removal",
|
||||
description=f"This user has stayed clean for 4 months. Should this infraction be removed?",
|
||||
@@ -212,10 +262,9 @@ class InfractionCommand(ModerationBase):
|
||||
embed.add_field(name="Original Date", value=timestamp.replace("T", " ")[:19], inline=True)
|
||||
embed.add_field(name="Original Moderator", value=mod_tag, inline=True)
|
||||
embed.set_footer(text=f"User ID: {user_id} | Guild ID: {guild_id}")
|
||||
|
||||
# Create approval view
|
||||
|
||||
view = InfractionRemovalView(self, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp)
|
||||
|
||||
|
||||
msg = await approval_channel.send(embed=embed, view=view)
|
||||
|
||||
# Mark infraction as pending and store message ID for view re-registration on restart
|
||||
@@ -225,13 +274,22 @@ class InfractionCommand(ModerationBase):
|
||||
WHERE id=?
|
||||
""", (msg.id, inf_id))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending removal approval: {e}", exc_info=True)
|
||||
|
||||
@commands.command(name="inf")
|
||||
@ModerationBase.is_admin()
|
||||
async def inf(self, ctx, action: str, *args):
|
||||
"""Query and manage infraction records.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
action:
|
||||
One of: ``search``, ``search_full``, ``list``, ``delete``, ``resend``.
|
||||
args:
|
||||
Additional arguments depending on the action (user_id or infraction_id).
|
||||
"""
|
||||
action = action.lower()
|
||||
|
||||
if action == "search":
|
||||
@@ -307,11 +365,10 @@ class InfractionCommand(ModerationBase):
|
||||
mod_tag = f"{moderator.name}#{moderator.discriminator}"
|
||||
timestamp = row[5].replace("T", " ")[:19]
|
||||
reason = row[3] or "None"
|
||||
|
||||
# Check if removed
|
||||
|
||||
is_removed = row[6] if len(row) > 6 else 0
|
||||
removed_date = row[7] if len(row) > 7 and row[7] else ""
|
||||
|
||||
|
||||
if is_removed:
|
||||
status = f"Removed ({removed_date.replace('T', ' ')[:19]})" if removed_date else "Removed"
|
||||
else:
|
||||
@@ -560,6 +617,13 @@ class InfractionDeleteView(discord.ui.View):
|
||||
self.guild = guild
|
||||
|
||||
async def _do_delete(self, interaction: discord.Interaction, notify: bool):
|
||||
"""Perform the deletion, optionally DM-ing the user, and log to the mod audit trail.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
notify:
|
||||
If True, attempt to send the user a DM with infraction details.
|
||||
"""
|
||||
try:
|
||||
self.cog.c.execute("DELETE FROM infractions WHERE id=? AND guild_id=?", (self.inf_id, self.guild.id))
|
||||
self.cog.conn.commit()
|
||||
@@ -593,22 +657,47 @@ class InfractionDeleteView(discord.ui.View):
|
||||
|
||||
await interaction.response.edit_message(content=result_text, embed=None, view=None)
|
||||
|
||||
# Log the deletion to the mod log for audit trail
|
||||
log_cog = self.cog.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
audit_embed = discord.Embed(
|
||||
title="Infraction Manually Deleted",
|
||||
color=discord.Color.orange(),
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
|
||||
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
|
||||
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
|
||||
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
|
||||
audit_embed.add_field(name="Deleted By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
|
||||
audit_embed.add_field(name="User Notified", value="Yes" if notify else "No", inline=True)
|
||||
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
|
||||
await log_cog.send_log(self.guild.id, "infraction_modify", audit_embed)
|
||||
|
||||
@discord.ui.button(label="Delete & Notify", style=discord.ButtonStyle.green)
|
||||
async def delete_notify(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Delete the infraction and DM the user."""
|
||||
await self._do_delete(interaction, notify=True)
|
||||
|
||||
@discord.ui.button(label="Delete Silently", style=discord.ButtonStyle.grey)
|
||||
async def delete_silent(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Delete the infraction without notifying the user."""
|
||||
await self._do_delete(interaction, notify=False)
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
|
||||
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Abort the deletion."""
|
||||
await interaction.response.edit_message(content="Deletion cancelled.", embed=None, view=None)
|
||||
|
||||
|
||||
class InfractionRemovalView(discord.ui.View):
|
||||
"""View for approving or denying infraction auto-removals."""
|
||||
|
||||
"""View for approving or denying infraction auto-removals.
|
||||
|
||||
Uses timeout=None so the view stays active indefinitely after restarts
|
||||
(it is manually re-registered via cog_load using the stored message ID).
|
||||
"""
|
||||
|
||||
def __init__(self, cog, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp):
|
||||
super().__init__(timeout=None)
|
||||
self.cog = cog
|
||||
@@ -619,10 +708,10 @@ class InfractionRemovalView(discord.ui.View):
|
||||
self.inf_type = inf_type
|
||||
self.reason = reason
|
||||
self.timestamp = timestamp
|
||||
|
||||
|
||||
@discord.ui.button(label="Remove Infraction", style=discord.ButtonStyle.green, custom_id="approve_removal")
|
||||
async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Approve the removal - mark infraction as removed."""
|
||||
"""Approve the removal — mark infraction as removed and notify the user."""
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(self.cog.db_path)
|
||||
@@ -635,8 +724,7 @@ class InfractionRemovalView(discord.ui.View):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Update embed
|
||||
|
||||
embed = discord.Embed(
|
||||
title="✅ Infraction Removed",
|
||||
description="This infraction has been removed from the user's active record.",
|
||||
@@ -649,10 +737,27 @@ class InfractionRemovalView(discord.ui.View):
|
||||
embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
embed.add_field(name="Removed By", value=interaction.user.mention, inline=True)
|
||||
embed.add_field(name="Removed At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True)
|
||||
|
||||
|
||||
await interaction.response.edit_message(embed=embed, view=None)
|
||||
|
||||
# Notify user
|
||||
|
||||
# Log to mod audit trail
|
||||
log_cog = self.cog.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
audit_embed = discord.Embed(
|
||||
title="Infraction Auto-Removal Approved",
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
|
||||
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
|
||||
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
|
||||
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
|
||||
audit_embed.add_field(name="Approved By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
|
||||
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
|
||||
await log_cog.send_log(self.guild_id, "infraction_modify", audit_embed)
|
||||
|
||||
# Notify user of the good news
|
||||
try:
|
||||
user = await self.cog.bot.fetch_user(self.user_id)
|
||||
guild = self.cog.bot.get_guild(self.guild_id)
|
||||
@@ -669,17 +774,17 @@ class InfractionRemovalView(discord.ui.View):
|
||||
notify_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
|
||||
notify_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
notify_embed.set_footer(text=f"You stayed clean for 4 months! Keep up the good behavior.")
|
||||
|
||||
|
||||
await user.send(embed=notify_embed)
|
||||
except Exception:
|
||||
pass # User has DMs disabled or bot can't reach them
|
||||
|
||||
|
||||
except Exception as e:
|
||||
await interaction.response.send_message(f"Error removing infraction: {e}", ephemeral=True)
|
||||
|
||||
|
||||
@discord.ui.button(label="Keep Infraction", style=discord.ButtonStyle.red, custom_id="deny_removal")
|
||||
async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||
"""Deny the removal - mark to skip future auto-removal checks."""
|
||||
"""Deny the removal — mark to skip future auto-removal checks for this infraction."""
|
||||
try:
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(self.cog.db_path)
|
||||
@@ -692,8 +797,7 @@ class InfractionRemovalView(discord.ui.View):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Update embed
|
||||
|
||||
embed = discord.Embed(
|
||||
title="❌ Removal Denied",
|
||||
description="This infraction will remain active and will not be checked for auto-removal again.",
|
||||
@@ -706,12 +810,29 @@ class InfractionRemovalView(discord.ui.View):
|
||||
embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
embed.add_field(name="Decision By", value=interaction.user.mention, inline=True)
|
||||
embed.add_field(name="Decision At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True)
|
||||
|
||||
|
||||
await interaction.response.edit_message(embed=embed, view=None)
|
||||
|
||||
|
||||
# Log to mod audit trail
|
||||
log_cog = self.cog.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
audit_embed = discord.Embed(
|
||||
title="Infraction Auto-Removal Denied",
|
||||
color=discord.Color.red(),
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
|
||||
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
|
||||
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
|
||||
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
|
||||
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
|
||||
audit_embed.add_field(name="Denied By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
|
||||
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
|
||||
await log_cog.send_log(self.guild_id, "infraction_modify", audit_embed)
|
||||
|
||||
except Exception as e:
|
||||
await interaction.response.send_message(f"Error denying removal: {e}", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(InfractionCommand(bot))
|
||||
await bot.add_cog(InfractionCommand(bot))
|
||||
|
||||
@@ -3,11 +3,25 @@ from discord.ext import commands
|
||||
from discord.ui import View, Button
|
||||
from .loader import ModerationBase
|
||||
|
||||
|
||||
class KickCommand(ModerationBase):
|
||||
"""Cog providing the !kick prefix command."""
|
||||
|
||||
@commands.command(name="kick")
|
||||
@ModerationBase.is_admin()
|
||||
async def kick(self, ctx, user: discord.Member, *, reason: str | None = None):
|
||||
"""Kick a user with confirmation and log infraction"""
|
||||
"""Kick a member from the server with a confirmation prompt.
|
||||
|
||||
Sends a DM to the user before kicking and writes an infraction record
|
||||
to the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The server member to kick.
|
||||
reason:
|
||||
Optional reason for the kick.
|
||||
"""
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
@@ -34,7 +48,10 @@ class KickCommand(ModerationBase):
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
await ctx.send(f"Are you sure you want to kick {user.mention}? Reason: {reason or 'No reason provided'}", view=view)
|
||||
await ctx.send(
|
||||
f"Are you sure you want to kick {user.mention}? Reason: {reason or 'No reason provided'}",
|
||||
view=view
|
||||
)
|
||||
await view.wait()
|
||||
if not confirmed["value"]:
|
||||
return
|
||||
@@ -43,20 +60,21 @@ class KickCommand(ModerationBase):
|
||||
return
|
||||
|
||||
try:
|
||||
await user.send(f"You have been **kicked** from **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}")
|
||||
except Exception:
|
||||
await user.send(
|
||||
f"You have been **kicked** from **{ctx.guild.name}**.\n"
|
||||
f"Reason: {reason or 'No reason provided'}"
|
||||
)
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
await ctx.send("Could not DM the user.")
|
||||
|
||||
await ctx.guild.kick(user, reason=reason)
|
||||
await ctx.send(f"{user.mention} has been kicked.")
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "kick", reason)
|
||||
|
||||
# Log to logging system
|
||||
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(
|
||||
ctx.guild.id, "kick", user, ctx.author, reason
|
||||
)
|
||||
await logger.log_moderation_action(ctx.guild.id, "kick", user, ctx.author, reason)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(KickCommand(bot))
|
||||
await bot.add_cog(KickCommand(bot))
|
||||
|
||||
@@ -8,30 +8,37 @@ from utils.constants import LILAC_ID
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Load multiple admin role IDs from env (comma-separated)
|
||||
# Load admin role IDs from env (comma-separated list of integer IDs)
|
||||
ADMIN_ROLE_IDS = {
|
||||
int(role_id.strip())
|
||||
for role_id in os.getenv("ADMIN_ROLE_IDS", "").split(",")
|
||||
if role_id.strip().isdigit()
|
||||
}
|
||||
|
||||
|
||||
class ModerationBase(commands.Cog):
|
||||
"""Base cog for moderation commands with shared DB and utilities"""
|
||||
"""Base cog for all moderation commands.
|
||||
|
||||
Provides a shared SQLite connection, the infractions table schema,
|
||||
the is_admin() permission check decorator, and log_infraction().
|
||||
All moderation cogs inherit from this class.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
|
||||
# one shared connection per cog instance — closed in cog_unload
|
||||
# One shared connection per cog instance — closed in cog_unload
|
||||
self.conn = sqlite3.connect(self.db_path)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.c = self.conn.cursor()
|
||||
self.initialize_db()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Ensure database connection closes when the cog unloads."""
|
||||
"""Close the database connection when the cog unloads."""
|
||||
self.conn.close()
|
||||
|
||||
def initialize_db(self):
|
||||
"""Create the infractions table if it does not already exist."""
|
||||
self.c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS infractions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -47,13 +54,18 @@ class ModerationBase(commands.Cog):
|
||||
|
||||
@staticmethod
|
||||
def is_admin():
|
||||
"""Decorator that works for both prefix and slash commands."""
|
||||
"""Permission check decorator that works for both prefix and slash commands.
|
||||
|
||||
Checks whether the invoking user has an admin role (from ADMIN_ROLE_IDS)
|
||||
or is the bot owner (LILAC_ID). Sends an error message and raises
|
||||
CheckFailure if the check fails.
|
||||
"""
|
||||
async def predicate(target):
|
||||
# target is either a Context (prefix) or Interaction (slash)
|
||||
# target is either a Context (prefix command) or Interaction (slash command)
|
||||
user = getattr(target, "author", None) or getattr(target, "user", None)
|
||||
is_interaction = hasattr(target, "response")
|
||||
|
||||
# unified send helper so we don't have to branch everywhere
|
||||
# Unified send helper so we don't have to branch on interaction type everywhere
|
||||
async def send_message(msg, ephemeral=False):
|
||||
if is_interaction:
|
||||
try:
|
||||
@@ -73,7 +85,7 @@ class ModerationBase(commands.Cog):
|
||||
await send_message("Unable to check permissions in this context.", ephemeral=is_interaction)
|
||||
return False
|
||||
|
||||
is_lilac = user.id == LILAC_ID # owner bypass
|
||||
is_lilac = user.id == LILAC_ID # owner always passes
|
||||
|
||||
has_admin_role = any(
|
||||
role.id in ADMIN_ROLE_IDS
|
||||
@@ -90,7 +102,7 @@ class ModerationBase(commands.Cog):
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
# apply both checks so it works regardless of command type
|
||||
# Apply both checks so the decorator works regardless of command type
|
||||
def decorator(func):
|
||||
func = commands.check(predicate)(func)
|
||||
func = app_commands.check(predicate)(func)
|
||||
@@ -106,12 +118,27 @@ class ModerationBase(commands.Cog):
|
||||
type_: str,
|
||||
reason: str | None
|
||||
):
|
||||
"""Log an infraction to the database."""
|
||||
"""Insert an infraction record into the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild_id:
|
||||
The guild the infraction occurred in.
|
||||
user_id:
|
||||
The user who received the infraction.
|
||||
mod_id:
|
||||
The moderator who issued the infraction.
|
||||
type_:
|
||||
The infraction type (e.g. 'ban', 'warn', 'kick').
|
||||
reason:
|
||||
Optional reason text.
|
||||
"""
|
||||
self.c.execute("""
|
||||
INSERT INTO infractions (user_id, guild_id, type, reason, moderator_id, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (user_id, guild_id, type_, reason, mod_id, datetime.utcnow().isoformat()))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(ModerationBase(bot))
|
||||
|
||||
@@ -2,6 +2,7 @@ import discord
|
||||
from discord.ext import commands
|
||||
from .loader import ModerationBase
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
import sqlite3
|
||||
import json
|
||||
import asyncio
|
||||
@@ -13,9 +14,22 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LockCog(commands.Cog):
|
||||
"""Cog providing the !lock, !unlock, and !checkperms prefix commands.
|
||||
|
||||
Before locking a channel, the original permission overwrites are serialised
|
||||
to JSON and stored in the `locked_channels` table so they can be restored
|
||||
exactly on !unlock. All database operations run in a thread pool executor
|
||||
to avoid blocking the event loop with synchronous sqlite3 calls.
|
||||
|
||||
The Ritual Member role (hardcoded ID) is the only role granted send access
|
||||
during a lock — everyone else is denied.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
|
||||
# Persistent connection shared across all DB helpers; check_same_thread=False
|
||||
# is safe here because all writes are serialised through _run_in_executor.
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.c = self.conn.cursor()
|
||||
@@ -36,7 +50,10 @@ class LockCog(commands.Cog):
|
||||
self.conn.commit()
|
||||
|
||||
async def _run_in_executor(self, func, *args):
|
||||
"""Run a blocking function in a thread pool executor."""
|
||||
"""Run a blocking function in a thread pool executor.
|
||||
|
||||
Used to offload all sqlite3 calls so they don't block the async event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, partial(func, *args))
|
||||
|
||||
@@ -44,16 +61,20 @@ class LockCog(commands.Cog):
|
||||
"""Synchronous database write operation."""
|
||||
overwrites_json = json.dumps(overwrites_data)
|
||||
self.c.execute("""
|
||||
INSERT OR REPLACE INTO locked_channels
|
||||
INSERT OR REPLACE INTO locked_channels
|
||||
(channel_id, overwrites_json)
|
||||
VALUES (?, ?)
|
||||
""", (channel_id, overwrites_json))
|
||||
self.conn.commit()
|
||||
|
||||
async def store_permissions(self, channel):
|
||||
"""Store all channel permission overwrites in the database as JSON BEFORE locking."""
|
||||
"""Store all channel permission overwrites in the database as JSON BEFORE locking.
|
||||
|
||||
Captures allow/deny bitmask values plus role/member type so they can be
|
||||
reconstructed into PermissionOverwrite objects on unlock.
|
||||
"""
|
||||
overwrites_data = {}
|
||||
|
||||
|
||||
for target, overwrite in channel.overwrites.items():
|
||||
allow, deny = overwrite.pair()
|
||||
overwrites_data[str(target.id)] = {
|
||||
@@ -62,7 +83,7 @@ class LockCog(commands.Cog):
|
||||
'allow': allow.value,
|
||||
'deny': deny.value
|
||||
}
|
||||
|
||||
|
||||
# Run database operation in thread pool
|
||||
await self._run_in_executor(self._store_permissions_sync, channel.id, overwrites_data)
|
||||
|
||||
@@ -73,38 +94,42 @@ class LockCog(commands.Cog):
|
||||
FROM locked_channels WHERE channel_id = ?
|
||||
""", (channel_id,))
|
||||
row = self.c.fetchone()
|
||||
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
|
||||
return json.loads(row['overwrites_json'])
|
||||
|
||||
async def get_stored_permissions(self, channel):
|
||||
"""Retrieve stored permissions from the database."""
|
||||
"""Retrieve stored permissions from the database and reconstruct overwrite objects.
|
||||
|
||||
Targets that can no longer be resolved (e.g., deleted roles) are silently
|
||||
skipped rather than causing an error on unlock.
|
||||
"""
|
||||
# Run database operation in thread pool
|
||||
overwrites_data = await self._run_in_executor(self._get_stored_permissions_sync, channel.id)
|
||||
|
||||
|
||||
if not overwrites_data:
|
||||
return None
|
||||
|
||||
|
||||
restored_overwrites = {}
|
||||
|
||||
|
||||
for target_id, data in overwrites_data.items():
|
||||
target_id = int(target_id)
|
||||
|
||||
|
||||
# Get the target (role or member)
|
||||
if data['type'] == 'role':
|
||||
target = channel.guild.get_role(target_id)
|
||||
else:
|
||||
target = channel.guild.get_member(target_id)
|
||||
|
||||
|
||||
if target:
|
||||
overwrite = discord.PermissionOverwrite.from_pair(
|
||||
discord.Permissions(data['allow']),
|
||||
discord.Permissions(data['deny'])
|
||||
)
|
||||
restored_overwrites[target] = overwrite
|
||||
|
||||
|
||||
return restored_overwrites
|
||||
|
||||
def _remove_stored_permissions_sync(self, channel_id):
|
||||
@@ -119,7 +144,16 @@ class LockCog(commands.Cog):
|
||||
@commands.command(name="checkperms")
|
||||
@ModerationBase.is_admin()
|
||||
async def checkperms(self, ctx, channel: Optional[discord.TextChannel] = None):
|
||||
"""Check what permissions the bot has in a channel."""
|
||||
"""Check what permissions the bot has in a channel.
|
||||
|
||||
Also displays the role hierarchy positions for @everyone and the Ritual
|
||||
Member role, so you can diagnose why !lock may be failing.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The channel to inspect. Defaults to the current channel.
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
@@ -128,18 +162,18 @@ class LockCog(commands.Cog):
|
||||
|
||||
perms = channel.permissions_for(ctx.guild.me)
|
||||
bot_top_role = ctx.guild.me.top_role
|
||||
|
||||
|
||||
msg = f"Bot permissions in #{channel.name}:\n"
|
||||
msg += f"Manage Channels: {perms.manage_channels}\n"
|
||||
msg += f"Manage Roles: {perms.manage_roles}\n"
|
||||
msg += f"Administrator: {perms.administrator}\n"
|
||||
msg += f"\nBot's highest role: {bot_top_role.name} (position {bot_top_role.position})\n"
|
||||
|
||||
|
||||
# Check role hierarchy for everyone and ritualist roles
|
||||
everyone_role = ctx.guild.default_role
|
||||
ritual_member_id = 952560403970416722
|
||||
ritual_member_role = ctx.guild.get_role(ritual_member_id)
|
||||
|
||||
|
||||
msg += f"\nRole Hierarchy Check:\n"
|
||||
msg += f"- everyone position: {everyone_role.position}\n"
|
||||
if ritual_member_role:
|
||||
@@ -147,19 +181,29 @@ class LockCog(commands.Cog):
|
||||
msg += f"- Can bot manage Ritual Member role? {bot_top_role.position > ritual_member_role.position}\n"
|
||||
else:
|
||||
msg += f"- Ritual Member role (ID: {ritual_member_id}): NOT FOUND\n"
|
||||
|
||||
|
||||
msg += f"\nChannel overwrites:\n"
|
||||
for target, overwrite in channel.overwrites.items():
|
||||
if len(msg) < 1900:
|
||||
allow, deny = overwrite.pair()
|
||||
msg += f"- {target.name} (ID: {target.id}, Pos: {target.position if hasattr(target, 'position') else 'N/A'}): Allow={allow.value}, Deny={deny.value}\n"
|
||||
|
||||
|
||||
await ctx.send(msg, allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
@commands.command(name="lock")
|
||||
@ModerationBase.is_admin()
|
||||
async def lock(self, ctx, channel: Optional[discord.TextChannel] = None):
|
||||
"""Lock a channel so only Ritual Members can talk."""
|
||||
"""Lock a channel so only Ritual Members can talk.
|
||||
|
||||
Saves the existing overwrites to the DB first, clears them all, then
|
||||
sets two new overwrites: deny @everyone send_messages, allow Ritual
|
||||
Member send_messages. Reacts with ✅/❌ to signal success/failure.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The channel to lock. Defaults to the current channel.
|
||||
"""
|
||||
try:
|
||||
if not ctx.guild:
|
||||
return
|
||||
@@ -169,32 +213,32 @@ class LockCog(commands.Cog):
|
||||
|
||||
perms = channel.permissions_for(ctx.guild.me)
|
||||
bot_top_role = ctx.guild.me.top_role
|
||||
|
||||
|
||||
if not perms.manage_roles:
|
||||
await ctx.message.add_reaction("❌")
|
||||
return await ctx.send(f"I need the 'Manage Roles' permission!", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
everyone_role = ctx.guild.default_role
|
||||
|
||||
|
||||
# Get Ritual Member role by ID
|
||||
ritual_member_id = 952560403970416722
|
||||
ritual_member_role = ctx.guild.get_role(ritual_member_id)
|
||||
|
||||
|
||||
if not ritual_member_role:
|
||||
await ctx.message.add_reaction("❌")
|
||||
# List available roles to help debug
|
||||
role_list = "\n".join([f"- {role.name} (ID: {role.id})" for role in ctx.guild.roles[:10]])
|
||||
return await ctx.send(f"Could not find Ritual Member role with ID {ritual_member_id}!\n\nFirst 10 roles in server:\n{role_list}", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
# Check role hierarchy - bot's role must be HIGHER than the roles it's trying to modify
|
||||
# Note: We don't check 'everyone' since it's always position 0 and manageable
|
||||
if bot_top_role.position <= ritual_member_role.position:
|
||||
await ctx.message.add_reaction("❌")
|
||||
return await ctx.send(f"My role ({bot_top_role.name}) must be HIGHER than {ritual_member_role.name} role to manage permissions!", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
# Store ALL original permissions BEFORE making any changes (runs in thread pool)
|
||||
await self.store_permissions(channel)
|
||||
|
||||
|
||||
# Try to clear overwrites one by one with error handling
|
||||
failed_targets = []
|
||||
for target in list(channel.overwrites.keys()):
|
||||
@@ -202,10 +246,10 @@ class LockCog(commands.Cog):
|
||||
await channel.set_permissions(target, overwrite=None)
|
||||
except discord.Forbidden:
|
||||
failed_targets.append(target.name)
|
||||
|
||||
|
||||
if failed_targets:
|
||||
logger.warning(f"Could not clear permissions for: {', '.join(failed_targets)}")
|
||||
|
||||
|
||||
# Deny everyone from talking
|
||||
try:
|
||||
everyone_overwrite = discord.PermissionOverwrite()
|
||||
@@ -214,7 +258,7 @@ class LockCog(commands.Cog):
|
||||
except discord.Forbidden as e:
|
||||
await ctx.message.add_reaction("❌")
|
||||
return await ctx.send(f"Cannot modify 'everyone' role permissions! Missing permissions.", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
# Allow Ritual Member role to talk
|
||||
try:
|
||||
ritual_member_overwrite = discord.PermissionOverwrite()
|
||||
@@ -228,16 +272,29 @@ class LockCog(commands.Cog):
|
||||
pass
|
||||
await ctx.message.add_reaction("❌")
|
||||
return await ctx.send(f"Cannot modify {ritual_member_role.name} permissions! My role needs to be higher than that role.", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
# React with checkmark
|
||||
await ctx.message.add_reaction("✅")
|
||||
|
||||
|
||||
# Try to send in channel
|
||||
try:
|
||||
await ctx.send(f"#{channel.name} has been locked! Only {ritual_member_role.name} can talk.", allowed_mentions=discord.AllowedMentions.none())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Log to mod log
|
||||
log_cog = self.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
embed = discord.Embed(
|
||||
title="Channel Locked",
|
||||
color=discord.Color.orange(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Channel", value=f"{channel.mention} (`{channel.name}`)", inline=False)
|
||||
embed.add_field(name="Locked By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
|
||||
embed.set_footer(text=f"Mod ID: {ctx.author.id} | Channel ID: {channel.id}")
|
||||
await log_cog.send_log(ctx.guild.id, "channel_lock", embed)
|
||||
|
||||
except discord.Forbidden as e:
|
||||
await ctx.message.add_reaction("❌")
|
||||
try:
|
||||
@@ -256,21 +313,30 @@ class LockCog(commands.Cog):
|
||||
@commands.command(name="unlock")
|
||||
@ModerationBase.is_admin()
|
||||
async def unlock(self, ctx, channel: Optional[discord.TextChannel] = None):
|
||||
"""Unlock a channel."""
|
||||
"""Unlock a channel and restore its original permission overwrites.
|
||||
|
||||
Looks up the saved overwrites from the DB and restores them in a single
|
||||
channel.edit() call. Fails if the channel was not locked via !lock.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The channel to unlock. Defaults to the current channel.
|
||||
"""
|
||||
try:
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
if channel is None:
|
||||
channel = ctx.channel
|
||||
|
||||
|
||||
# Check if we have stored permissions for this channel (runs in thread pool)
|
||||
stored_overwrites = await self.get_stored_permissions(channel)
|
||||
|
||||
|
||||
if stored_overwrites is None:
|
||||
await ctx.message.add_reaction("❌")
|
||||
return await ctx.send("This channel wasn't locked with !lock, so I can't restore its permissions.", allowed_mentions=discord.AllowedMentions.none())
|
||||
|
||||
|
||||
# Restore all original overwrites in a single API call
|
||||
failed_targets = []
|
||||
try:
|
||||
@@ -280,10 +346,10 @@ class LockCog(commands.Cog):
|
||||
|
||||
# Remove from database (runs in thread pool)
|
||||
await self.remove_stored_permissions(channel.id)
|
||||
|
||||
|
||||
# React with checkmark
|
||||
await ctx.message.add_reaction("✅")
|
||||
|
||||
|
||||
# Try to send in channel
|
||||
try:
|
||||
msg = f"#{channel.name} has been unlocked!"
|
||||
@@ -292,7 +358,20 @@ class LockCog(commands.Cog):
|
||||
await ctx.send(msg, allowed_mentions=discord.AllowedMentions.none())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Log to mod log
|
||||
log_cog = self.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
embed = discord.Embed(
|
||||
title="Channel Unlocked",
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Channel", value=f"{channel.mention} (`{channel.name}`)", inline=False)
|
||||
embed.add_field(name="Unlocked By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
|
||||
embed.set_footer(text=f"Mod ID: {ctx.author.id} | Channel ID: {channel.id}")
|
||||
await log_cog.send_log(ctx.guild.id, "channel_lock", embed)
|
||||
|
||||
except discord.Forbidden as e:
|
||||
await ctx.message.add_reaction("❌")
|
||||
try:
|
||||
@@ -310,4 +389,4 @@ class LockCog(commands.Cog):
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(LockCog(bot))
|
||||
await bot.add_cog(LockCog(bot))
|
||||
|
||||
@@ -2,7 +2,7 @@ import discord
|
||||
from discord.ext import commands
|
||||
from .loader import ModerationBase
|
||||
|
||||
# All available log types
|
||||
# All log type keys recognised by the logging system
|
||||
LOG_TYPES = [
|
||||
"message_delete",
|
||||
"message_edit",
|
||||
@@ -17,6 +17,7 @@ LOG_TYPES = [
|
||||
"unmute",
|
||||
"kick",
|
||||
"ban",
|
||||
"cleanban",
|
||||
"unban",
|
||||
"role_add",
|
||||
"role_remove",
|
||||
@@ -24,6 +25,8 @@ LOG_TYPES = [
|
||||
"username_change",
|
||||
"timeout",
|
||||
"timeout_remove",
|
||||
"channel_lock",
|
||||
"infraction_modify",
|
||||
"voice_join",
|
||||
"voice_leave",
|
||||
"voice_move",
|
||||
@@ -33,14 +36,19 @@ LOG_TYPES = [
|
||||
"role_create",
|
||||
"role_delete",
|
||||
"role_update",
|
||||
"server_update"
|
||||
"server_update",
|
||||
]
|
||||
|
||||
|
||||
class LogConfig(ModerationBase):
|
||||
"""Commands to configure logging settings"""
|
||||
|
||||
"""Admin commands for configuring which Discord channel each log type
|
||||
is routed to, and for managing channel exclusions.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
# Ensure the exclusion table exists; this is also created in logger.py
|
||||
# but we create it here defensively since LogConfig may be loaded first
|
||||
self.c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS log_excluded_channels (
|
||||
guild_id INTEGER NOT NULL,
|
||||
@@ -49,22 +57,24 @@ class LogConfig(ModerationBase):
|
||||
)
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
@commands.group(name="log", invoke_without_command=True)
|
||||
@ModerationBase.is_admin()
|
||||
async def log(self, ctx):
|
||||
"""Logging configuration commands"""
|
||||
"""Logging configuration command group.
|
||||
|
||||
Use a subcommand: set, list, types, exclude, unexclude, excluded, remove, clear.
|
||||
"""
|
||||
await ctx.send("Use `!log set`, `!log list`, `!log exclude`, or `!log remove` to configure logging.")
|
||||
|
||||
|
||||
@log.command(name="set")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_set(self, ctx, channel: discord.TextChannel, log_type: str):
|
||||
"""
|
||||
Set a log type to a specific channel.
|
||||
|
||||
Usage: !log set #channel log_type
|
||||
"""Route a log type to a Discord channel.
|
||||
|
||||
Usage: !log set #channel <log_type>
|
||||
Example: !log set #mod-logs message_delete
|
||||
|
||||
|
||||
Use !log types to see all available log types.
|
||||
"""
|
||||
log_type = log_type.lower()
|
||||
@@ -80,26 +90,26 @@ class LogConfig(ModerationBase):
|
||||
if not permissions.send_messages or not permissions.embed_links:
|
||||
await ctx.send(f"❌ I don't have permission to send messages and embeds in {channel.mention}!")
|
||||
return
|
||||
|
||||
|
||||
# Upsert — update channel if already configured, otherwise insert
|
||||
self.c.execute("""
|
||||
INSERT INTO log_config (guild_id, log_type, channel_id)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(guild_id, log_type) DO UPDATE SET channel_id = ?
|
||||
""", (ctx.guild.id, log_type, channel.id, channel.id))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
await ctx.send(f"✅ Set `{log_type}` logging to {channel.mention}")
|
||||
|
||||
|
||||
@log.command(name="exclude")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_exclude(self, ctx, channel_id: int):
|
||||
"""
|
||||
Exclude a channel from being logged.
|
||||
"""Exclude a channel from being logged.
|
||||
|
||||
Events in excluded channels will not appear in any log, regardless of
|
||||
the log type configuration.
|
||||
|
||||
Usage: !log exclude <channel_id>
|
||||
Example: !log exclude 123456789012345678
|
||||
|
||||
Events from this channel will not be logged.
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
@@ -108,34 +118,38 @@ class LogConfig(ModerationBase):
|
||||
if not channel:
|
||||
await ctx.send(f"❌ Channel with ID `{channel_id}` not found in this server.")
|
||||
return
|
||||
|
||||
self.c.execute("SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(ctx.guild.id, channel_id))
|
||||
|
||||
self.c.execute(
|
||||
"SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(ctx.guild.id, channel_id)
|
||||
)
|
||||
if self.c.fetchone():
|
||||
await ctx.send(f"❌ {channel.mention} (`{channel_id}`) is already excluded from logging.")
|
||||
return
|
||||
|
||||
self.c.execute("INSERT INTO log_excluded_channels (guild_id, channel_id) VALUES (?, ?)",
|
||||
(ctx.guild.id, channel_id))
|
||||
|
||||
self.c.execute(
|
||||
"INSERT INTO log_excluded_channels (guild_id, channel_id) VALUES (?, ?)",
|
||||
(ctx.guild.id, channel_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
await ctx.send(f"✅ Excluded {channel.mention} (`{channel_id}`) from logging.")
|
||||
|
||||
|
||||
@log.command(name="unexclude")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_unexclude(self, ctx, channel_id: int):
|
||||
"""
|
||||
Remove a channel from the exclusion list.
|
||||
"""Remove a channel from the log exclusion list.
|
||||
|
||||
Usage: !log unexclude <channel_id>
|
||||
Example: !log unexclude 123456789012345678
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
self.c.execute("DELETE FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(ctx.guild.id, channel_id))
|
||||
|
||||
self.c.execute(
|
||||
"DELETE FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(ctx.guild.id, channel_id)
|
||||
)
|
||||
|
||||
if self.c.rowcount == 0:
|
||||
await ctx.send(f"❌ Channel ID `{channel_id}` is not in the exclusion list.")
|
||||
else:
|
||||
@@ -143,94 +157,96 @@ class LogConfig(ModerationBase):
|
||||
channel = ctx.guild.get_channel(channel_id)
|
||||
channel_name = channel.mention if channel else f"Channel ID `{channel_id}`"
|
||||
await ctx.send(f"✅ Removed {channel_name} from the exclusion list.")
|
||||
|
||||
|
||||
@log.command(name="excluded")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_excluded(self, ctx):
|
||||
"""List all excluded channels"""
|
||||
"""List all channels currently excluded from logging."""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
self.c.execute("SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? ORDER BY channel_id",
|
||||
(ctx.guild.id,))
|
||||
self.c.execute(
|
||||
"SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? ORDER BY channel_id",
|
||||
(ctx.guild.id,)
|
||||
)
|
||||
results = self.c.fetchall()
|
||||
|
||||
|
||||
if not results:
|
||||
await ctx.send("❌ No channels are excluded from logging.")
|
||||
return
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Excluded Channels - {ctx.guild.name}",
|
||||
description="Events from these channels will not be logged.",
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
|
||||
|
||||
channels_list = []
|
||||
for (channel_id,) in results:
|
||||
channel = ctx.guild.get_channel(channel_id)
|
||||
if channel:
|
||||
channels_list.append(f"{channel.mention} (`{channel_id}`)")
|
||||
for (ch_id,) in results:
|
||||
ch = ctx.guild.get_channel(ch_id)
|
||||
if ch:
|
||||
channels_list.append(f"{ch.mention} (`{ch_id}`)")
|
||||
else:
|
||||
channels_list.append(f"Deleted Channel (`{channel_id}`)")
|
||||
|
||||
channels_list.append(f"Deleted Channel (`{ch_id}`)")
|
||||
|
||||
embed.add_field(
|
||||
name=f"Excluded Channels ({len(channels_list)})",
|
||||
value="\n".join(channels_list) if channels_list else "None",
|
||||
inline=False
|
||||
)
|
||||
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
@log.command(name="remove")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_remove(self, ctx, log_type: str):
|
||||
"""
|
||||
Remove a log type configuration.
|
||||
"""Remove the channel assignment for a log type.
|
||||
|
||||
Usage: !log remove log_type
|
||||
Example: !log remove message_delete
|
||||
Usage: !log remove <log_type>
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
log_type = log_type.lower()
|
||||
|
||||
self.c.execute("DELETE FROM log_config WHERE guild_id = ? AND log_type = ?",
|
||||
(ctx.guild.id, log_type))
|
||||
|
||||
self.c.execute(
|
||||
"DELETE FROM log_config WHERE guild_id = ? AND log_type = ?",
|
||||
(ctx.guild.id, log_type)
|
||||
)
|
||||
|
||||
if self.c.rowcount == 0:
|
||||
await ctx.send(f"❌ No logging configured for `{log_type}`.")
|
||||
else:
|
||||
self.conn.commit()
|
||||
await ctx.send(f"✅ Removed `{log_type}` logging.")
|
||||
|
||||
|
||||
@log.command(name="list")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_list(self, ctx):
|
||||
"""List all configured logging for this server"""
|
||||
"""Show all configured log types and their channels for this server."""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
self.c.execute("SELECT log_type, channel_id FROM log_config WHERE guild_id = ? ORDER BY log_type",
|
||||
(ctx.guild.id,))
|
||||
self.c.execute(
|
||||
"SELECT log_type, channel_id FROM log_config WHERE guild_id = ? ORDER BY log_type",
|
||||
(ctx.guild.id,)
|
||||
)
|
||||
results = self.c.fetchall()
|
||||
|
||||
|
||||
if not results:
|
||||
await ctx.send("❌ No logging configured for this server.\nUse `!log set #channel log_type` to set up logging.")
|
||||
return
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Logging Configuration - {ctx.guild.name}",
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
# Group by channel
|
||||
|
||||
# Group log types by their target channel for a cleaner display
|
||||
channel_logs = {}
|
||||
for log_type, channel_id in results:
|
||||
if channel_id not in channel_logs:
|
||||
channel_logs[channel_id] = []
|
||||
channel_logs[channel_id].append(log_type)
|
||||
|
||||
channel_logs.setdefault(channel_id, []).append(log_type)
|
||||
|
||||
for channel_id, log_types in channel_logs.items():
|
||||
channel = ctx.guild.get_channel(channel_id)
|
||||
channel_name = channel.mention if channel else f"Deleted Channel ({channel_id})"
|
||||
@@ -239,24 +255,24 @@ class LogConfig(ModerationBase):
|
||||
value=f"```{', '.join(sorted(log_types))}```",
|
||||
inline=False
|
||||
)
|
||||
|
||||
|
||||
embed.set_footer(text=f"Total: {len(results)} log types configured")
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
@log.command(name="types")
|
||||
async def log_types(self, ctx):
|
||||
"""Show all available log types"""
|
||||
"""Show all available log type keys, grouped by category."""
|
||||
embed = discord.Embed(
|
||||
title="Available Log Types",
|
||||
description="Use these with `!log set #channel <type>`",
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
|
||||
categories = {
|
||||
"Message Events": [
|
||||
"message_delete",
|
||||
"message_edit",
|
||||
"message_bulk_delete"
|
||||
"message_bulk_delete",
|
||||
],
|
||||
"Member Events": [
|
||||
"member_join",
|
||||
@@ -264,7 +280,7 @@ class LogConfig(ModerationBase):
|
||||
"member_ban",
|
||||
"member_unban",
|
||||
"nickname_change",
|
||||
"username_change"
|
||||
"username_change",
|
||||
],
|
||||
"Moderation Actions": [
|
||||
"warn",
|
||||
@@ -272,60 +288,63 @@ class LogConfig(ModerationBase):
|
||||
"unmute",
|
||||
"kick",
|
||||
"ban",
|
||||
"cleanban",
|
||||
"unban",
|
||||
"timeout",
|
||||
"timeout_remove"
|
||||
"timeout_remove",
|
||||
"channel_lock",
|
||||
"infraction_modify",
|
||||
],
|
||||
"Role Events": [
|
||||
"role_add",
|
||||
"role_remove",
|
||||
"role_create",
|
||||
"role_delete",
|
||||
"role_update"
|
||||
"role_update",
|
||||
],
|
||||
"Voice Events": [
|
||||
"voice_join",
|
||||
"voice_leave",
|
||||
"voice_move"
|
||||
"voice_move",
|
||||
],
|
||||
"Channel Events": [
|
||||
"channel_create",
|
||||
"channel_delete",
|
||||
"channel_update"
|
||||
"channel_update",
|
||||
],
|
||||
"Server Events": [
|
||||
"server_update"
|
||||
]
|
||||
"server_update",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
for category, types in categories.items():
|
||||
embed.add_field(
|
||||
name=category,
|
||||
value=f"```{', '.join(types)}```",
|
||||
inline=False
|
||||
)
|
||||
|
||||
|
||||
embed.set_footer(text=f"Total: {len(LOG_TYPES)} log types available")
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
|
||||
@log.command(name="clear")
|
||||
@ModerationBase.is_admin()
|
||||
async def log_clear(self, ctx):
|
||||
"""Remove ALL logging configurations for this server"""
|
||||
"""Remove all logging configurations for this server (requires confirmation)."""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
self.c.execute("SELECT COUNT(*) FROM log_config WHERE guild_id = ?", (ctx.guild.id,))
|
||||
count = self.c.fetchone()[0]
|
||||
|
||||
|
||||
if count == 0:
|
||||
await ctx.send("❌ No logging configured to clear.")
|
||||
return
|
||||
|
||||
|
||||
from discord.ui import View, Button
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
|
||||
async def yes_callback(interaction: discord.Interaction):
|
||||
if interaction.user != ctx.author:
|
||||
await interaction.response.send_message("You can't confirm this action.", ephemeral=True)
|
||||
@@ -333,7 +352,7 @@ class LogConfig(ModerationBase):
|
||||
confirmed["value"] = True
|
||||
await interaction.response.edit_message(content="✅ Confirmed.", view=None)
|
||||
view.stop()
|
||||
|
||||
|
||||
async def no_callback(interaction: discord.Interaction):
|
||||
if interaction.user != ctx.author:
|
||||
await interaction.response.send_message("You can't cancel this action.", ephemeral=True)
|
||||
@@ -341,24 +360,28 @@ class LogConfig(ModerationBase):
|
||||
confirmed["value"] = False
|
||||
await interaction.response.edit_message(content="❌ Cancelled.", view=None)
|
||||
view.stop()
|
||||
|
||||
|
||||
yes_button = Button(label="Yes", style=discord.ButtonStyle.danger)
|
||||
no_button = Button(label="No", style=discord.ButtonStyle.secondary)
|
||||
yes_button.callback = yes_callback
|
||||
no_button.callback = no_callback
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
await ctx.send(f"⚠️ Are you sure you want to remove **all {count}** logging configurations?", view=view)
|
||||
|
||||
await ctx.send(
|
||||
f"⚠️ Are you sure you want to remove **all {count}** logging configurations?",
|
||||
view=view
|
||||
)
|
||||
await view.wait()
|
||||
|
||||
|
||||
if not confirmed["value"]:
|
||||
return
|
||||
|
||||
|
||||
self.c.execute("DELETE FROM log_config WHERE guild_id = ?", (ctx.guild.id,))
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
await ctx.send(f"✅ Cleared all logging configurations ({count} removed).")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(LogConfig(bot))
|
||||
await bot.add_cog(LogConfig(bot))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import io
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import sqlite3
|
||||
@@ -5,21 +6,26 @@ from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from utils.logger import get_logger
|
||||
from utils.constants import GUILD_ID
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Logger(commands.Cog):
|
||||
"""Core logging system that listens to Discord events and logs them"""
|
||||
|
||||
"""Core logging cog that listens to Discord gateway events and forwards
|
||||
structured embed logs to configured channels.
|
||||
|
||||
Channel routing is stored per-guild in the log_config table. Individual
|
||||
channels can be excluded from logging via log_excluded_channels.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
|
||||
self.initialize_db()
|
||||
# Cache for deleted messages (for bulk delete context)
|
||||
self.message_cache = {}
|
||||
|
||||
|
||||
def initialize_db(self):
|
||||
"""Create log_config and log_excluded_channels tables"""
|
||||
"""Create log_config and log_excluded_channels tables, and seed default routes."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
@@ -37,135 +43,160 @@ class Logger(commands.Cog):
|
||||
PRIMARY KEY (guild_id, channel_id)
|
||||
)
|
||||
""")
|
||||
# Seed defaults for log types that need a channel from day one.
|
||||
# INSERT OR IGNORE so this never overwrites an admin's custom routing.
|
||||
MOD_LOG_CHANNEL = 982644273960873994
|
||||
for log_type in ("channel_lock", "infraction_modify", "cleanban"):
|
||||
c.execute("""
|
||||
INSERT OR IGNORE INTO log_config (guild_id, log_type, channel_id)
|
||||
VALUES (?, ?, ?)
|
||||
""", (GUILD_ID, log_type, MOD_LOG_CHANNEL))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_channel_excluded(self, guild_id: int, channel_id: int) -> bool:
|
||||
"""Check if a channel is excluded from logging"""
|
||||
"""Return True if the given channel is excluded from logging."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT 1 FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(guild_id, channel_id))
|
||||
c.execute(
|
||||
"SELECT 1 FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
|
||||
(guild_id, channel_id)
|
||||
)
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
return result is not None
|
||||
|
||||
|
||||
def get_log_channel(self, guild_id: int, log_type: str) -> Optional[int]:
|
||||
"""Get the channel ID for a specific log type in a guild"""
|
||||
"""Return the configured channel ID for a log type, or None if not set."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT channel_id FROM log_config WHERE guild_id = ? AND log_type = ?",
|
||||
(guild_id, log_type))
|
||||
c.execute(
|
||||
"SELECT channel_id FROM log_config WHERE guild_id = ? AND log_type = ?",
|
||||
(guild_id, log_type)
|
||||
)
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else None
|
||||
|
||||
async def send_log(self, guild_id: int, log_type: str, embed: discord.Embed, source_channel_id: Optional[int] = None):
|
||||
"""Send a log embed to the configured channel (if source channel is not excluded)"""
|
||||
|
||||
# Check if the source channel is excluded
|
||||
|
||||
async def send_log(
|
||||
self,
|
||||
guild_id: int,
|
||||
log_type: str,
|
||||
embed: discord.Embed,
|
||||
source_channel_id: Optional[int] = None
|
||||
):
|
||||
"""Send a log embed to the channel configured for the given log type.
|
||||
|
||||
Does nothing if the source channel is excluded, the log type has no
|
||||
configured channel, or the channel cannot be found.
|
||||
"""
|
||||
if source_channel_id and self.is_channel_excluded(guild_id, source_channel_id):
|
||||
return
|
||||
|
||||
|
||||
channel_id = self.get_log_channel(guild_id, log_type)
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
|
||||
channel = self.bot.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, discord.abc.Messageable):
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.send(embed=embed)
|
||||
await channel.send(embed=embed)
|
||||
except discord.Forbidden as e:
|
||||
_logger.error(f"Missing permissions to send log in channel {channel_id}: {e}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Error sending log: {e}", exc_info=True)
|
||||
|
||||
|
||||
# ── Message events ────────────────────────────────────────────────────────
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message_delete(self, message: discord.Message):
|
||||
"""Log message deletions"""
|
||||
"""Log a single message deletion."""
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Message Deleted",
|
||||
color=discord.Color.red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Author", value=f"{message.author.mention} ({message.author})", inline=False)
|
||||
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
|
||||
embed.add_field(name="Message ID", value=message.id, inline=True)
|
||||
|
||||
|
||||
content = message.content[:1024] if message.content else "*No text content*"
|
||||
embed.add_field(name="Content", value=content, inline=False)
|
||||
|
||||
|
||||
if message.attachments:
|
||||
attachment_list = "\n".join([f"[{a.filename}]({a.url})" for a in message.attachments])
|
||||
embed.add_field(name="Attachments", value=attachment_list, inline=False)
|
||||
|
||||
|
||||
embed.set_footer(text=f"User ID: {message.author.id}")
|
||||
|
||||
|
||||
await self.send_log(message.guild.id, "message_delete", embed, message.channel.id)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_bulk_message_delete(self, messages):
|
||||
"""Log bulk message deletions"""
|
||||
"""Log a bulk message deletion, showing a sample of the affected messages."""
|
||||
if not messages or not messages[0].guild:
|
||||
return
|
||||
|
||||
|
||||
guild = messages[0].guild
|
||||
channel = messages[0].channel
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Bulk Message Delete",
|
||||
description=f"**{len(messages)}** messages deleted in {channel.mention}",
|
||||
color=discord.Color.dark_red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Show sample of deleted messages
|
||||
|
||||
# Show up to 5 messages as a preview
|
||||
sample = []
|
||||
for msg in messages[:5]: # Show first 5
|
||||
for msg in messages[:5]:
|
||||
content = msg.content[:100] if msg.content else "*No content*"
|
||||
sample.append(f"**{msg.author}**: {content}")
|
||||
|
||||
|
||||
if sample:
|
||||
embed.add_field(name="Sample Messages", value="\n".join(sample), inline=False)
|
||||
|
||||
|
||||
if len(messages) > 5:
|
||||
embed.add_field(name="Note", value=f"Showing 5 of {len(messages)} deleted messages", inline=False)
|
||||
|
||||
|
||||
await self.send_log(guild.id, "message_bulk_delete", embed, channel.id)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message_edit(self, before: discord.Message, after: discord.Message):
|
||||
"""Log message edits"""
|
||||
"""Log a message edit (only fires when the content actually changes)."""
|
||||
if before.author.bot or not before.guild or before.content == after.content:
|
||||
return
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Message Edited",
|
||||
color=discord.Color.orange(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Author", value=f"{before.author.mention} ({before.author})", inline=False)
|
||||
embed.add_field(name="Channel", value=before.channel.mention, inline=True)
|
||||
embed.add_field(name="Message ID", value=before.id, inline=True)
|
||||
|
||||
|
||||
before_content = before.content[:1024] if before.content else "*No text content*"
|
||||
after_content = after.content[:1024] if after.content else "*No text content*"
|
||||
|
||||
|
||||
embed.add_field(name="Before", value=before_content, inline=False)
|
||||
embed.add_field(name="After", value=after_content, inline=False)
|
||||
embed.add_field(name="Jump to Message", value=f"[Click here]({after.jump_url})", inline=False)
|
||||
|
||||
|
||||
embed.set_footer(text=f"User ID: {before.author.id}")
|
||||
|
||||
|
||||
await self.send_log(before.guild.id, "message_edit", embed, before.channel.id)
|
||||
|
||||
|
||||
# ── Member events ─────────────────────────────────────────────────────────
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_join(self, member: discord.Member):
|
||||
async def on_member_join(self, member: discord.Member):
|
||||
"""Log a member join, including account age and member count."""
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
title="Member Joined",
|
||||
@@ -173,7 +204,7 @@ class Logger(commands.Cog):
|
||||
color=discord.Color.green(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
account_age = (datetime.now(timezone.utc) - member.created_at).days
|
||||
embed.add_field(
|
||||
name="Account Created",
|
||||
@@ -181,17 +212,16 @@ class Logger(commands.Cog):
|
||||
inline=False
|
||||
)
|
||||
embed.add_field(name="Member Count", value=member.guild.member_count, inline=True)
|
||||
|
||||
embed.set_thumbnail(url=member.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
|
||||
|
||||
await self.send_log(member.guild.id, "member_join", embed)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_text = f"⚠️ **Error while logging member join:**\n`{type(e).__name__}: {e}`"
|
||||
_logger.error(f"Error logging member join: {e}", exc_info=True)
|
||||
|
||||
# Attempt to send the error to the designated debug channel
|
||||
# Attempt to report the error to the designated debug channel
|
||||
try:
|
||||
channel = member.guild.get_channel(1424145004976275617)
|
||||
if channel and isinstance(channel, discord.abc.Messageable):
|
||||
@@ -200,284 +230,333 @@ class Logger(commands.Cog):
|
||||
_logger.error("Could not find error logging channel (1424145004976275617).")
|
||||
except Exception as send_err:
|
||||
_logger.error(f"Failed to send error message to debug channel: {send_err}")
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_remove(self, member: discord.Member):
|
||||
"""Log member leaves/kicks"""
|
||||
"""Log a member leave or kick, including their roles and join date."""
|
||||
embed = discord.Embed(
|
||||
title="Member Left",
|
||||
description=f"{member.mention} {member}",
|
||||
color=discord.Color.light_gray(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
join_date = member.joined_at.strftime('%Y-%m-%d %H:%M:%S UTC') if member.joined_at else "Unknown"
|
||||
embed.add_field(name="Joined Server", value=join_date, inline=False)
|
||||
|
||||
|
||||
roles = [role.mention for role in member.roles if role.name != "@everyone"]
|
||||
if roles:
|
||||
embed.add_field(name="Roles", value=", ".join(roles), inline=False)
|
||||
|
||||
|
||||
embed.add_field(name="Member Count", value=member.guild.member_count, inline=True)
|
||||
|
||||
embed.set_thumbnail(url=member.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
|
||||
|
||||
await self.send_log(member.guild.id, "member_leave", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_ban(self, guild: discord.Guild, user: discord.User):
|
||||
"""Log member bans"""
|
||||
"""Log a member ban, fetching the reason from the audit log when possible."""
|
||||
embed = discord.Embed(
|
||||
title="Member Banned",
|
||||
description=f"{user.mention} {user}",
|
||||
color=discord.Color.dark_red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Try to get ban reason from audit log
|
||||
|
||||
try:
|
||||
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.ban):
|
||||
if entry.target and entry.target.id == user.id:
|
||||
embed.add_field(name="Banned By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
|
||||
embed.add_field(
|
||||
name="Banned By",
|
||||
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
|
||||
inline=False
|
||||
)
|
||||
if entry.reason:
|
||||
embed.add_field(name="Reason", value=entry.reason, inline=False)
|
||||
break
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
pass # Missing audit log access — omit the moderator field
|
||||
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {user.id}")
|
||||
|
||||
|
||||
await self.send_log(guild.id, "member_ban", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_unban(self, guild: discord.Guild, user: discord.User):
|
||||
"""Log member unbans"""
|
||||
"""Log a member unban, fetching the moderator from the audit log when possible."""
|
||||
embed = discord.Embed(
|
||||
title="Member Unbanned",
|
||||
description=f"{user.mention} {user}",
|
||||
color=discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# Try to get unban info from audit log
|
||||
|
||||
try:
|
||||
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.unban):
|
||||
if entry.target and entry.target.id == user.id:
|
||||
embed.add_field(name="Unbanned By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
|
||||
embed.add_field(
|
||||
name="Unbanned By",
|
||||
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
|
||||
inline=False
|
||||
)
|
||||
if entry.reason:
|
||||
embed.add_field(name="Reason", value=entry.reason, inline=False)
|
||||
break
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {user.id}")
|
||||
|
||||
|
||||
await self.send_log(guild.id, "member_unban", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_member_update(self, before: discord.Member, after: discord.Member):
|
||||
"""Log nickname changes, role changes, and timeouts"""
|
||||
"""Log nickname changes, role additions/removals, and timeout changes."""
|
||||
# Nickname change
|
||||
if before.nick != after.nick:
|
||||
embed = discord.Embed(
|
||||
title="Nickname Changed",
|
||||
color=discord.Color.blue(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
|
||||
embed.add_field(name="Before", value=before.nick or "*No nickname*", inline=True)
|
||||
embed.add_field(name="After", value=after.nick or "*No nickname*", inline=True)
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
|
||||
|
||||
await self.send_log(after.guild.id, "nickname_change", embed)
|
||||
|
||||
# Role changes
|
||||
|
||||
# Role additions and removals
|
||||
before_roles = set(before.roles)
|
||||
after_roles = set(after.roles)
|
||||
|
||||
added_roles = after_roles - before_roles
|
||||
removed_roles = before_roles - after_roles
|
||||
|
||||
|
||||
if added_roles:
|
||||
embed = discord.Embed(
|
||||
title="Role Added",
|
||||
color=discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
|
||||
embed.add_field(name="Roles Added", value=", ".join([r.mention for r in added_roles]), inline=False)
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
|
||||
|
||||
await self.send_log(after.guild.id, "role_add", embed)
|
||||
|
||||
|
||||
if removed_roles:
|
||||
embed = discord.Embed(
|
||||
title="Role Removed",
|
||||
color=discord.Color.red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
|
||||
embed.add_field(name="Roles Removed", value=", ".join([r.mention for r in removed_roles]), inline=False)
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
|
||||
|
||||
await self.send_log(after.guild.id, "role_remove", embed)
|
||||
|
||||
|
||||
# Timeout changes
|
||||
if before.timed_out_until != after.timed_out_until:
|
||||
if after.timed_out_until:
|
||||
# Member was timed out
|
||||
embed = discord.Embed(
|
||||
title="Member Timed Out",
|
||||
color=discord.Color.dark_orange(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
|
||||
embed.add_field(name="Until", value=f"<t:{int(after.timed_out_until.timestamp())}:F>", inline=False)
|
||||
|
||||
# Try to get timeout reason from audit log
|
||||
|
||||
# Try to fetch the moderator who issued the timeout from the audit log
|
||||
try:
|
||||
async for entry in after.guild.audit_logs(limit=5, action=discord.AuditLogAction.member_update):
|
||||
if entry.target and entry.target.id == after.id and entry.after.timed_out_until:
|
||||
embed.add_field(name="Timed Out By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
|
||||
embed.add_field(
|
||||
name="Timed Out By",
|
||||
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
|
||||
inline=False
|
||||
)
|
||||
if entry.reason:
|
||||
embed.add_field(name="Reason", value=entry.reason, inline=False)
|
||||
break
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
await self.send_log(after.guild.id, "timeout", embed)
|
||||
else:
|
||||
# Timeout was removed
|
||||
# Timeout was lifted
|
||||
embed = discord.Embed(
|
||||
title="Timeout Removed",
|
||||
color=discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
await self.send_log(after.guild.id, "timeout_remove", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_user_update(self, before: discord.User, after: discord.User):
|
||||
"""Log username changes"""
|
||||
"""Log username or discriminator changes across all guilds the user shares with the bot."""
|
||||
if before.name != after.name or before.discriminator != after.discriminator:
|
||||
embed = discord.Embed(
|
||||
title="Username Changed",
|
||||
color=discord.Color.blue(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="User", value=f"{after.mention}", inline=False)
|
||||
embed.add_field(name="Before", value=str(before), inline=True)
|
||||
embed.add_field(name="After", value=str(after), inline=True)
|
||||
embed.set_thumbnail(url=after.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {after.id}")
|
||||
|
||||
# Send to all guilds the user is in
|
||||
|
||||
# Fan the log out to every guild this user is a member of
|
||||
for guild in self.bot.guilds:
|
||||
if guild.get_member(after.id):
|
||||
await self.send_log(guild.id, "username_change", embed)
|
||||
|
||||
|
||||
# ── Voice events ──────────────────────────────────────────────────────────
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
|
||||
"""Log voice channel activities"""
|
||||
# Member joined a voice channel
|
||||
async def on_voice_state_update(
|
||||
self,
|
||||
member: discord.Member,
|
||||
before: discord.VoiceState,
|
||||
after: discord.VoiceState
|
||||
):
|
||||
"""Log voice channel joins, leaves, and moves."""
|
||||
if before.channel is None and after.channel is not None:
|
||||
# Member joined a voice channel
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Join",
|
||||
description=f"{member.mention} joined {after.channel.mention}",
|
||||
color=discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
await self.send_log(member.guild.id, "voice_join", embed, after.channel.id)
|
||||
|
||||
# Member left a voice channel
|
||||
|
||||
elif before.channel is not None and after.channel is None:
|
||||
# Member left a voice channel
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Leave",
|
||||
description=f"{member.mention} left {before.channel.mention}",
|
||||
color=discord.Color.red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
await self.send_log(member.guild.id, "voice_leave", embed, before.channel.id)
|
||||
|
||||
# Member moved between voice channels
|
||||
elif before.channel is not None and after.channel is not None and before.channel != after.channel:
|
||||
|
||||
elif (
|
||||
before.channel is not None
|
||||
and after.channel is not None
|
||||
and before.channel != after.channel
|
||||
):
|
||||
# Member moved between voice channels
|
||||
embed = discord.Embed(
|
||||
title="Voice Channel Move",
|
||||
description=f"{member.mention} moved from {before.channel.mention} to {after.channel.mention}",
|
||||
color=discord.Color.blue(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.set_footer(text=f"User ID: {member.id}")
|
||||
# For moves, check if either channel is excluded
|
||||
if not self.is_channel_excluded(member.guild.id, before.channel.id) and not self.is_channel_excluded(member.guild.id, after.channel.id):
|
||||
# Only log the move if neither of the involved channels is excluded
|
||||
if not self.is_channel_excluded(member.guild.id, before.channel.id) \
|
||||
and not self.is_channel_excluded(member.guild.id, after.channel.id):
|
||||
await self.send_log(member.guild.id, "voice_move", embed)
|
||||
|
||||
|
||||
# ── Channel / role events ─────────────────────────────────────────────────
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_channel_create(self, channel):
|
||||
"""Log channel creation"""
|
||||
"""Log the creation of a guild channel."""
|
||||
embed = discord.Embed(
|
||||
title="Channel Created",
|
||||
description=f"{channel.mention} (`{channel.name}`)",
|
||||
color=discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Type", value=str(channel.type).title(), inline=True)
|
||||
embed.add_field(name="Channel ID", value=channel.id, inline=True)
|
||||
|
||||
|
||||
await self.send_log(channel.guild.id, "channel_create", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_channel_delete(self, channel):
|
||||
"""Log channel deletion"""
|
||||
"""Log the deletion of a guild channel."""
|
||||
embed = discord.Embed(
|
||||
title="Channel Deleted",
|
||||
description=f"`{channel.name}`",
|
||||
color=discord.Color.red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Type", value=str(channel.type).title(), inline=True)
|
||||
embed.add_field(name="Channel ID", value=channel.id, inline=True)
|
||||
|
||||
|
||||
await self.send_log(channel.guild.id, "channel_delete", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_role_create(self, role: discord.Role):
|
||||
"""Log role creation"""
|
||||
"""Log the creation of a guild role."""
|
||||
embed = discord.Embed(
|
||||
title="Role Created",
|
||||
description=f"{role.mention} (`{role.name}`)",
|
||||
color=role.color if role.color != discord.Color.default() else discord.Color.green(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Role ID", value=role.id, inline=True)
|
||||
embed.add_field(name="Color", value=str(role.color), inline=True)
|
||||
embed.add_field(name="Hoisted", value="Yes" if role.hoist else "No", inline=True)
|
||||
|
||||
|
||||
await self.send_log(role.guild.id, "role_create", embed)
|
||||
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_role_delete(self, role: discord.Role):
|
||||
"""Log role deletion"""
|
||||
"""Log the deletion of a guild role."""
|
||||
embed = discord.Embed(
|
||||
title="Role Deleted",
|
||||
description=f"`{role.name}`",
|
||||
color=discord.Color.red(),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Role ID", value=role.id, inline=True)
|
||||
|
||||
|
||||
await self.send_log(role.guild.id, "role_delete", embed)
|
||||
|
||||
async def log_moderation_action(self, guild_id: int, action_type: str, user: discord.User, moderator: discord.User, reason: Optional[str] = None, duration: Optional[str] = None):
|
||||
"""
|
||||
Public method to log moderation actions from commands.
|
||||
|
||||
# ── Public API for other cogs ─────────────────────────────────────────────
|
||||
|
||||
async def log_moderation_action(
|
||||
self,
|
||||
guild_id: int,
|
||||
action_type: str,
|
||||
user: discord.User,
|
||||
moderator: discord.User,
|
||||
reason: Optional[str] = None,
|
||||
duration: Optional[str] = None
|
||||
):
|
||||
"""Build and send a moderation action embed to the appropriate log channel.
|
||||
|
||||
Called by moderation commands (ban, kick, warn, mute, etc.) after they
|
||||
execute their action.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild_id:
|
||||
The guild the action was taken in.
|
||||
action_type:
|
||||
The action key (e.g. 'ban', 'warn', 'mute'). Controls embed color
|
||||
and is used to look up the target log channel.
|
||||
user:
|
||||
The user the action was taken against.
|
||||
moderator:
|
||||
The moderator who performed the action.
|
||||
reason:
|
||||
Optional reason text.
|
||||
duration:
|
||||
Optional duration string for timed actions like mutes.
|
||||
"""
|
||||
color_map = {
|
||||
"warn": discord.Color.yellow(),
|
||||
@@ -485,30 +564,108 @@ class Logger(commands.Cog):
|
||||
"unmute": discord.Color.green(),
|
||||
"kick": discord.Color.red(),
|
||||
"ban": discord.Color.dark_red(),
|
||||
"unban": discord.Color.green()
|
||||
"cleanban": discord.Color.dark_red(),
|
||||
"unban": discord.Color.green(),
|
||||
"timeout": discord.Color.dark_orange(),
|
||||
"untimeout": discord.Color.green(),
|
||||
"lock": discord.Color.orange(),
|
||||
"unlock": discord.Color.green(),
|
||||
}
|
||||
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Moderation: {action_type.title()}",
|
||||
color=color_map.get(action_type, discord.Color.blue()),
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
embed.add_field(name="User", value=f"{user.mention} ({user})", inline=False)
|
||||
embed.add_field(name="Moderator", value=f"{moderator.mention} ({moderator})", inline=False)
|
||||
|
||||
|
||||
if duration:
|
||||
embed.add_field(name="Duration", value=duration, inline=True)
|
||||
|
||||
|
||||
if reason:
|
||||
embed.add_field(name="Reason", value=reason, inline=False)
|
||||
else:
|
||||
embed.add_field(name="Reason", value="*No reason provided*", inline=False)
|
||||
|
||||
|
||||
embed.set_thumbnail(url=user.display_avatar.url)
|
||||
embed.set_footer(text=f"User ID: {user.id} | Mod ID: {moderator.id}")
|
||||
|
||||
|
||||
await self.send_log(guild_id, action_type, embed)
|
||||
|
||||
async def log_ban_messages(
|
||||
self,
|
||||
guild_id: int,
|
||||
user: discord.User,
|
||||
deleted_messages: list,
|
||||
days: int
|
||||
):
|
||||
"""Send a text-file attachment to the ban log channel containing all
|
||||
messages that were deleted during a cleanban.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
guild_id:
|
||||
The guild the cleanban occurred in.
|
||||
user:
|
||||
The banned user.
|
||||
deleted_messages:
|
||||
List of dicts with keys: channel, channel_id, message_id,
|
||||
timestamp, content, attachments.
|
||||
days:
|
||||
The message history window (in days) that was scanned.
|
||||
"""
|
||||
channel_id = self.get_log_channel(guild_id, "member_ban")
|
||||
if not channel_id:
|
||||
return
|
||||
channel = self.bot.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, discord.abc.Messageable):
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Cleanban Deleted Messages — {user}",
|
||||
color=discord.Color.dark_red(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="User", value=f"{user.mention} ({user})", inline=False)
|
||||
embed.add_field(name="Messages Deleted", value=str(len(deleted_messages)), inline=True)
|
||||
embed.add_field(name="Window", value=f"Past {days} day(s)", inline=True)
|
||||
embed.set_footer(text=f"User ID: {user.id}")
|
||||
|
||||
if not deleted_messages:
|
||||
embed.description = "*No messages found in accessible channels for this time window.*"
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
except Exception as e:
|
||||
_logger.error(f"Error sending ban message log: {e}")
|
||||
return
|
||||
|
||||
# Build a human-readable plaintext log attached as a file
|
||||
lines = [
|
||||
f"Deleted messages for {user} (ID: {user.id})",
|
||||
f"Window: past {days} day(s)",
|
||||
f"Total: {len(deleted_messages)} message(s)",
|
||||
"=" * 70,
|
||||
"",
|
||||
]
|
||||
for msg in deleted_messages:
|
||||
line = f"[{msg['timestamp']}] #{msg['channel']} (msg {msg['message_id']}): {msg['content']}"
|
||||
if msg["attachments"]:
|
||||
line += f"\n Attachments: {', '.join(msg['attachments'])}"
|
||||
lines.append(line)
|
||||
|
||||
content = "\n".join(lines)
|
||||
file = discord.File(
|
||||
io.BytesIO(content.encode("utf-8")),
|
||||
filename=f"deleted_messages_{user.id}.txt"
|
||||
)
|
||||
|
||||
try:
|
||||
await channel.send(embed=embed, file=file)
|
||||
except Exception as e:
|
||||
_logger.error(f"Error sending ban message log: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Logger(bot))
|
||||
await bot.add_cog(Logger(bot))
|
||||
|
||||
@@ -4,12 +4,19 @@ from .loader import ModerationBase
|
||||
|
||||
|
||||
class ModHelp(ModerationBase):
|
||||
"""Cog providing the !modhelp command.
|
||||
|
||||
Sends five embed pages covering every moderation command available to staff.
|
||||
"""
|
||||
|
||||
@commands.command(name="modhelp")
|
||||
@ModerationBase.is_admin()
|
||||
async def modhelp(self, ctx):
|
||||
"""Send a multi-embed reference covering all moderation commands."""
|
||||
embeds = []
|
||||
|
||||
def embed(title, color=discord.Color.blurple()):
|
||||
"""Create a titled Discord embed with the given colour."""
|
||||
e = discord.Embed(title=title, color=color)
|
||||
return e
|
||||
|
||||
|
||||
@@ -7,28 +7,55 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
from datetime import timedelta, datetime
|
||||
from .loader import ModerationBase
|
||||
from utils.logger import get_logger
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db"
|
||||
|
||||
# Role ID for the server's muted role
|
||||
MUTE_ROLE_ID = 982702037517090836
|
||||
|
||||
|
||||
class MuteCommand(ModerationBase):
|
||||
"""Cog providing the !mute prefix command with automatic expiry.
|
||||
|
||||
Mutes are tracked in a persistent `mutes` table so they survive bot
|
||||
restarts. A background task checks for expired mutes every minute.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
self.bot = bot
|
||||
self.check_mutes.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Cancel the background task and close the DB when the cog unloads."""
|
||||
self.check_mutes.cancel()
|
||||
await super().cog_unload()
|
||||
|
||||
@commands.command(name="mute")
|
||||
@ModerationBase.is_admin()
|
||||
async def mute(self, ctx, user: discord.Member, duration: str, *, reason: str | None = None):
|
||||
"""Mute a member for a specified duration.
|
||||
|
||||
Applies the mute role and schedules automatic removal. The mute is
|
||||
persisted to the database so the bot can restore it after a restart.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The server member to mute.
|
||||
duration:
|
||||
Duration string using suffix notation: w (weeks), d (days),
|
||||
h (hours), m (minutes). Example: 1w, 5d, 12h, 30m.
|
||||
reason:
|
||||
Optional reason for the mute.
|
||||
"""
|
||||
match = re.match(r"(\d+)([wdhm])", duration.lower())
|
||||
if not match:
|
||||
await ctx.send("Invalid duration format. Use **1w**, **5d**, **12h**, **30m**, etc.")
|
||||
return
|
||||
|
||||
value, unit = match.groups()
|
||||
value = int(value)
|
||||
if unit == "w":
|
||||
@@ -66,7 +93,11 @@ class MuteCommand(ModerationBase):
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
await ctx.send(f"Are you sure you want to mute {user.mention} for **{duration}**? Reason: {reason or 'No reason provided'}", view=view)
|
||||
await ctx.send(
|
||||
f"Are you sure you want to mute {user.mention} for **{duration}**? "
|
||||
f"Reason: {reason or 'No reason provided'}",
|
||||
view=view
|
||||
)
|
||||
await view.wait()
|
||||
if not confirmed["value"]:
|
||||
return
|
||||
@@ -81,13 +112,17 @@ class MuteCommand(ModerationBase):
|
||||
|
||||
await user.add_roles(mute_role, reason=reason)
|
||||
try:
|
||||
await user.send(f"You have been muted in **{ctx.guild.name}** for **{duration}**.\nReason: {reason or 'No reason provided'}")
|
||||
await user.send(
|
||||
f"You have been muted in **{ctx.guild.name}** for **{duration}**.\n"
|
||||
f"Reason: {reason or 'No reason provided'}"
|
||||
)
|
||||
except Exception:
|
||||
await ctx.send("Could not DM the user.")
|
||||
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "mute", reason)
|
||||
await ctx.send(f"{user.mention} has been muted for **{duration}**.")
|
||||
|
||||
# Persist the mute so it survives a restart
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
@@ -99,32 +134,40 @@ class MuteCommand(ModerationBase):
|
||||
)
|
||||
""")
|
||||
unmute_time = (datetime.utcnow() + delta).isoformat()
|
||||
c.execute("INSERT INTO mutes (user_id, guild_id, channel_id, unmute_time) VALUES (?, ?, ?, ?)",
|
||||
(user.id, ctx.guild.id, ctx.channel.id, unmute_time))
|
||||
c.execute(
|
||||
"INSERT INTO mutes (user_id, guild_id, channel_id, unmute_time) VALUES (?, ?, ?, ?)",
|
||||
(user.id, ctx.guild.id, ctx.channel.id, unmute_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Log to logging system
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(
|
||||
ctx.guild.id, "mute", user, ctx.author, reason, duration
|
||||
)
|
||||
await logger.log_moderation_action(ctx.guild.id, "mute", user, ctx.author, reason, duration)
|
||||
|
||||
# Schedule the automatic unmute as a fire-and-forget coroutine
|
||||
asyncio.create_task(self.schedule_unmute(user.id, ctx.guild.id, ctx.channel.id, delta.total_seconds()))
|
||||
|
||||
async def schedule_unmute(self, user_id, guild_id, channel_id, delay):
|
||||
"""Wait for the mute duration to expire, then remove the mute role.
|
||||
|
||||
Also handles the case where the mute was manually removed before
|
||||
expiry — if the DB entry is already gone, this exits without action.
|
||||
"""
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT 1 FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
|
||||
exists = c.fetchone()
|
||||
if not exists:
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT 1 FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
|
||||
if not c.fetchone():
|
||||
# Mute was manually removed before expiry
|
||||
return
|
||||
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return
|
||||
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
@@ -134,28 +177,41 @@ class MuteCommand(ModerationBase):
|
||||
mute_role = guild.get_role(MUTE_ROLE_ID)
|
||||
if not mute_role:
|
||||
return
|
||||
|
||||
try:
|
||||
await member.remove_roles(mute_role, reason="Mute duration expired")
|
||||
|
||||
# Log automatic unmute
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(
|
||||
|
||||
log_cog = self.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
await log_cog.log_moderation_action(
|
||||
guild_id, "unmute", member, self.bot.user, "Mute duration expired"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to remove mute role from {user_id}: {e}", exc_info=True)
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_mutes(self):
|
||||
"""Background task: check for expired mutes every minute and remove them.
|
||||
|
||||
This ensures mutes are cleaned up even if the bot was restarted and
|
||||
the scheduled_unmute task was never created for a given entry.
|
||||
"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
now = datetime.utcnow().isoformat()
|
||||
c.execute("SELECT user_id, guild_id, channel_id, unmute_time FROM mutes WHERE unmute_time <= ?", (now,))
|
||||
expired = c.fetchall()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
now = datetime.utcnow().isoformat()
|
||||
c.execute(
|
||||
"SELECT user_id, guild_id, channel_id, unmute_time FROM mutes WHERE unmute_time <= ?",
|
||||
(now,)
|
||||
)
|
||||
expired = c.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
for user_id, guild_id, channel_id, _ in expired:
|
||||
# Delay of 0 so they run immediately on the next event loop iteration
|
||||
asyncio.create_task(self.schedule_unmute(user_id, guild_id, channel_id, 0))
|
||||
conn.close()
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(MuteCommand(bot))
|
||||
await bot.add_cog(MuteCommand(bot))
|
||||
|
||||
@@ -1,44 +1,140 @@
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from .loader import ModerationBase
|
||||
from utils.constants import LILAC_ID
|
||||
|
||||
SCAN_WORKERS = 5 # Parallel time-window workers per channel
|
||||
PROGRESS_EVERY = 500 # Call on_progress every N messages scanned
|
||||
|
||||
|
||||
async def safe_delete_user_messages(channel: discord.TextChannel, user_id: int, timeout: int = 20):
|
||||
"""Safely delete all messages from a user in one channel with a timeout."""
|
||||
async def safe_delete_user_messages(channel: discord.TextChannel, user_id: int, on_progress=None):
|
||||
"""Delete all messages from a user in one channel.
|
||||
|
||||
Uses a two-phase approach to handle Discord's 14-day bulk-delete limit:
|
||||
|
||||
Phase 1: bulk-delete recent messages (< 14 days) via channel.purge().
|
||||
Phase 2: split the remaining history into parallel time windows so the
|
||||
sequential fetch bottleneck is reduced by ~SCAN_WORKERS times.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The text channel to scan.
|
||||
user_id:
|
||||
ID of the user whose messages should be deleted.
|
||||
on_progress:
|
||||
Optional callback ``on_progress(scanned, deleted)`` called every
|
||||
PROGRESS_EVERY messages scanned, so the caller can update a status
|
||||
message without spamming Discord's API.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[int, str | None]
|
||||
``(deleted_count, error_string)`` — error_string is None on success.
|
||||
"""
|
||||
deleted_count = 0
|
||||
scanned_count = 0
|
||||
count_lock = asyncio.Lock()
|
||||
# Cap concurrent individual deletes at 2 to stay under rate limits
|
||||
delete_sem = asyncio.Semaphore(2)
|
||||
|
||||
async def do_purge():
|
||||
nonlocal deleted_count
|
||||
async for message in channel.history(limit=None, oldest_first=False):
|
||||
if message.author.id == user_id:
|
||||
async def scan_and_delete_window(after_dt: datetime, before_dt: datetime):
|
||||
nonlocal deleted_count, scanned_count
|
||||
async for message in channel.history(
|
||||
limit=None, after=after_dt, before=before_dt, oldest_first=True
|
||||
):
|
||||
async with count_lock:
|
||||
scanned_count += 1
|
||||
sc, dc = scanned_count, deleted_count
|
||||
|
||||
if sc % PROGRESS_EVERY == 0 and on_progress:
|
||||
on_progress(sc, dc)
|
||||
|
||||
if message.author.id != user_id:
|
||||
continue
|
||||
|
||||
async with delete_sem:
|
||||
try:
|
||||
await message.delete()
|
||||
deleted_count += 1
|
||||
await asyncio.sleep(0.2)
|
||||
except discord.Forbidden:
|
||||
raise PermissionError("Missing permissions")
|
||||
async with count_lock:
|
||||
deleted_count += 1
|
||||
await asyncio.sleep(0.5)
|
||||
except discord.NotFound:
|
||||
pass
|
||||
except discord.HTTPException as e:
|
||||
if "rate limit" in str(e).lower():
|
||||
await asyncio.sleep(1)
|
||||
if e.status == 429:
|
||||
retry_after = float(e.response.headers.get("Retry-After", 1))
|
||||
await asyncio.sleep(retry_after)
|
||||
else:
|
||||
raise e
|
||||
raise
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(do_purge(), timeout=timeout)
|
||||
# Phase 1: bulk-delete messages newer than 14 days
|
||||
deleted = await channel.purge(limit=None, check=lambda m: m.author.id == user_id)
|
||||
deleted_count += len(deleted)
|
||||
if on_progress:
|
||||
on_progress(0, deleted_count)
|
||||
|
||||
# Phase 2: scan older history in parallel time windows
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=14)
|
||||
start = channel.created_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if start < cutoff:
|
||||
total_seconds = (cutoff - start).total_seconds()
|
||||
chunk = total_seconds / SCAN_WORKERS
|
||||
windows = [
|
||||
(
|
||||
start + timedelta(seconds=i * chunk),
|
||||
start + timedelta(seconds=(i + 1) * chunk),
|
||||
)
|
||||
for i in range(SCAN_WORKERS)
|
||||
]
|
||||
await asyncio.gather(*(scan_and_delete_window(a, b) for a, b in windows))
|
||||
|
||||
if on_progress:
|
||||
on_progress(scanned_count, deleted_count)
|
||||
|
||||
return deleted_count, None
|
||||
except asyncio.TimeoutError:
|
||||
return deleted_count, f"Timeout after {timeout}s"
|
||||
except PermissionError:
|
||||
except discord.Forbidden:
|
||||
return deleted_count, "No permissions"
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return deleted_count, str(e)
|
||||
|
||||
|
||||
class Purge(ModerationBase):
|
||||
"""Commands for purging messages"""
|
||||
"""Commands for purging messages.
|
||||
|
||||
Standard purge commands delete messages in the current channel up to a
|
||||
given message ID anchor. The !purgememberall command is destructive — it
|
||||
scans every channel in a set of allowed categories and deletes all messages
|
||||
from a specific user. It is restricted to the server owner and runs as a
|
||||
cancellable background task with a live progress ticker.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
# Tracks active purgememberall tasks so duplicates and !cancelpurge can be managed
|
||||
self._active_purges: dict[int, asyncio.Task] = {} # guild_id -> task
|
||||
|
||||
async def fetch_after_message(self, ctx, message_id: int):
|
||||
"""Fetch a message by ID from the current channel, sending an error on failure.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
Discord message ID to anchor the purge range.
|
||||
|
||||
Returns
|
||||
-------
|
||||
discord.Message | None
|
||||
The fetched message, or None if not found.
|
||||
"""
|
||||
try:
|
||||
msg = await ctx.channel.fetch_message(message_id)
|
||||
return msg
|
||||
@@ -50,6 +146,21 @@ class Purge(ModerationBase):
|
||||
return None
|
||||
|
||||
async def purge_messages(self, ctx, check=None, after_message=None, limit: int = 100):
|
||||
"""Bulk-delete messages in the current channel up to a limit.
|
||||
|
||||
Sends a status message that is edited in-place with the outcome.
|
||||
Logs the operation to the mod log on success.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
check:
|
||||
Optional filter function passed to channel.purge().
|
||||
after_message:
|
||||
If provided, only messages after this one are deleted. The anchor
|
||||
message itself is also deleted.
|
||||
limit:
|
||||
Maximum number of messages to delete (capped at 1000).
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
if not ctx.channel.permissions_for(ctx.guild.me).manage_messages:
|
||||
@@ -83,6 +194,22 @@ class Purge(ModerationBase):
|
||||
total_deleted = len(deleted) + (1 if after_message else 0)
|
||||
await status_msg.edit(content=f"✅ Purge complete! Deleted **{total_deleted}** message(s).")
|
||||
|
||||
# Log the purge operation to the mod log
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger and ctx.guild:
|
||||
embed = discord.Embed(
|
||||
title="Purge Executed",
|
||||
color=discord.Color.orange(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Moderator", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
|
||||
embed.add_field(name="Channel", value=ctx.channel.mention, inline=True)
|
||||
embed.add_field(name="Messages Deleted", value=str(total_deleted), inline=True)
|
||||
if after_message:
|
||||
embed.add_field(name="After Message ID", value=str(after_message.id), inline=True)
|
||||
embed.set_footer(text=f"Mod ID: {ctx.author.id}")
|
||||
await logger.send_log(ctx.guild.id, "message_bulk_delete", embed, ctx.channel.id)
|
||||
|
||||
except discord.Forbidden:
|
||||
await status_msg.edit(content="❌ Forbidden: I don't have permission to delete messages!")
|
||||
except discord.HTTPException as e:
|
||||
@@ -93,6 +220,13 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purge")
|
||||
@ModerationBase.is_admin()
|
||||
async def purge(self, ctx, message_id: int):
|
||||
"""Delete all messages in this channel from the given message ID to now.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
ID of the oldest message to include in the purge range.
|
||||
"""
|
||||
status_msg = await ctx.send(f"🗑️ Purge command received for message ID {message_id}...")
|
||||
after_message = await self.fetch_after_message(ctx, message_id)
|
||||
if not after_message:
|
||||
@@ -103,6 +237,15 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purgemember", aliases=["purgeuser", "purgeu", "purgem"])
|
||||
@ModerationBase.is_admin()
|
||||
async def purge_member(self, ctx, member: discord.Member, message_id: int):
|
||||
"""Delete messages from a specific member from the given message ID to now.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
member:
|
||||
The member whose messages to delete.
|
||||
message_id:
|
||||
ID of the oldest message to include in the purge range.
|
||||
"""
|
||||
status_msg = await ctx.send(f"🗑️ Purge command received for member {member} up to message ID {message_id}...")
|
||||
after_message = await self.fetch_after_message(ctx, message_id)
|
||||
if not after_message:
|
||||
@@ -113,6 +256,13 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purgebot", aliases=["purgebots", "purgeb"])
|
||||
@ModerationBase.is_admin()
|
||||
async def purge_bots(self, ctx, message_id: int):
|
||||
"""Delete bot messages from the given message ID to now.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
ID of the oldest message to include in the purge range.
|
||||
"""
|
||||
status_msg = await ctx.send(f"🗑️ Purge command received for bots up to message ID {message_id}...")
|
||||
after_message = await self.fetch_after_message(ctx, message_id)
|
||||
if not after_message:
|
||||
@@ -123,6 +273,15 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purgecontains", aliases=["purgec", "purgetext"])
|
||||
@ModerationBase.is_admin()
|
||||
async def purge_contains(self, ctx, message_id: int, *, text: str):
|
||||
"""Delete messages containing specific text from the given message ID to now.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
ID of the oldest message to include in the purge range.
|
||||
text:
|
||||
Case-insensitive substring to match against message content.
|
||||
"""
|
||||
status_msg = await ctx.send(f"🗑️ Purge command received for messages containing '{text}' up to message ID {message_id}...")
|
||||
after_message = await self.fetch_after_message(ctx, message_id)
|
||||
if not after_message:
|
||||
@@ -133,6 +292,13 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purgeembeds", aliases=["purgee", "purgeembed"])
|
||||
@ModerationBase.is_admin()
|
||||
async def purge_embeds(self, ctx, message_id: int):
|
||||
"""Delete messages that contain embeds from the given message ID to now.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
message_id:
|
||||
ID of the oldest message to include in the purge range.
|
||||
"""
|
||||
status_msg = await ctx.send(f"🗑️ Purge command received for messages with embeds up to message ID {message_id}...")
|
||||
after_message = await self.fetch_after_message(ctx, message_id)
|
||||
if not after_message:
|
||||
@@ -143,10 +309,25 @@ class Purge(ModerationBase):
|
||||
@commands.command(name="purgememberall", aliases=["purgeuserall", "purgeua", "purgeallm"])
|
||||
@ModerationBase.is_admin()
|
||||
async def purge_member_all(self, ctx, user_id: int):
|
||||
"""Delete all messages from a user across all text channels in the server (requires confirmation)."""
|
||||
"""Delete all messages from a user across all text channels in the server (owner only, requires confirmation).
|
||||
|
||||
Only the server owner (LILAC_ID) may run this command — it is too
|
||||
destructive for general staff use. Runs as a cancellable background task
|
||||
with a live progress embed that updates every 3 seconds to avoid hitting
|
||||
Discord's edit rate limit.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id:
|
||||
Discord user ID of the target whose messages will be wiped.
|
||||
"""
|
||||
if not ctx.guild:
|
||||
return
|
||||
|
||||
if ctx.author.id != LILAC_ID:
|
||||
await ctx.send("❌ This command can only be used by the server owner — it's too destructive for general staff use.")
|
||||
return
|
||||
|
||||
member = ctx.guild.get_member(user_id)
|
||||
user_display = str(member) if member else f"User ID {user_id}"
|
||||
|
||||
@@ -158,7 +339,7 @@ class Purge(ModerationBase):
|
||||
|
||||
async def interaction_check(self, interaction: discord.Interaction):
|
||||
if interaction.user.id != self.author.id:
|
||||
await interaction.response.send_message("❌ You can’t confirm someone else’s purge command.", ephemeral=True)
|
||||
await interaction.response.send_message("❌ You can't confirm someone else's purge command.", ephemeral=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -190,43 +371,155 @@ class Purge(ModerationBase):
|
||||
if view.value is False:
|
||||
return
|
||||
|
||||
# Run purge in background task so it doesn’t block bot
|
||||
asyncio.create_task(self._purge_user_messages(ctx, user_id, user_display, msg))
|
||||
if ctx.guild.id in self._active_purges and not self._active_purges[ctx.guild.id].done():
|
||||
await msg.edit(content="❌ A purge is already running. Use `cancelpurge` to stop it first.")
|
||||
return
|
||||
|
||||
task = asyncio.create_task(self._purge_user_messages(ctx, user_id, user_display, msg))
|
||||
self._active_purges[ctx.guild.id] = task
|
||||
# Auto-clean the registry when the task completes
|
||||
task.add_done_callback(lambda _: self._active_purges.pop(ctx.guild.id, None))
|
||||
|
||||
@commands.command(name="cancelpurge", aliases=["stoppurge"])
|
||||
@ModerationBase.is_admin()
|
||||
async def cancel_purge(self, ctx):
|
||||
"""Cancel an in-progress purgememberall."""
|
||||
task = self._active_purges.get(ctx.guild.id)
|
||||
if not task or task.done():
|
||||
await ctx.send("❌ No purge is currently running.")
|
||||
return
|
||||
task.cancel()
|
||||
await ctx.send("🛑 Purge cancellation requested.")
|
||||
|
||||
async def _purge_user_messages(self, ctx, user_id: int, user_display: str, msg: discord.Message):
|
||||
"""Background purge task with timeout, per-channel progress, and async safety."""
|
||||
total_deleted = 0
|
||||
failed_channels = []
|
||||
processed = 0
|
||||
total_channels = len(ctx.guild.text_channels)
|
||||
"""Background purge task — processes channels concurrently with per-channel progress.
|
||||
|
||||
await msg.edit(content=f"🧹 Starting purge for **{user_display}**...\nTotal channels: {total_channels}")
|
||||
Restricts scanning to a hardcoded set of category IDs to avoid touching
|
||||
bot-internal or announcement channels. Uses a semaphore to cap concurrency
|
||||
at 5 channels at once. A ticker coroutine edits the status message every 3
|
||||
seconds so staff can watch progress without hammering the API.
|
||||
|
||||
for channel in ctx.guild.text_channels:
|
||||
processed += 1
|
||||
channel_name = channel.name
|
||||
Parameters
|
||||
----------
|
||||
user_id:
|
||||
ID of the user whose messages are being deleted.
|
||||
user_display:
|
||||
Human-readable display name used in status messages.
|
||||
msg:
|
||||
The Discord message to edit in-place with live progress.
|
||||
"""
|
||||
CATEGORY_IDS = {
|
||||
962737280735408218,
|
||||
1229700264848789594,
|
||||
876772600704020531,
|
||||
1087353136198451241,
|
||||
876772600704020532,
|
||||
}
|
||||
|
||||
try:
|
||||
await msg.edit(content=(
|
||||
f"🧹 Working on **#{channel_name}** ({processed}/{total_channels})...\n"
|
||||
f"Deleted so far: **{total_deleted}**"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
channels = [
|
||||
ch for ch in ctx.guild.text_channels
|
||||
if ch.category_id in CATEGORY_IDS
|
||||
]
|
||||
|
||||
total_channels = len(channels)
|
||||
lock = asyncio.Lock()
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
# Per-channel stats: scanned, deleted, done, error
|
||||
stats: dict[str, dict] = {
|
||||
ch.name: {"scanned": 0, "deleted": 0, "done": False, "error": None}
|
||||
for ch in channels
|
||||
}
|
||||
failed_channels: list[str] = []
|
||||
|
||||
def build_status() -> str:
|
||||
done_channels = [n for n, s in stats.items() if s["done"]]
|
||||
active = [n for n, s in stats.items() if not s["done"] and s["scanned"] > 0]
|
||||
total_deleted = sum(s["deleted"] for s in stats.values())
|
||||
completed = len(done_channels)
|
||||
|
||||
lines = [
|
||||
f"🧹 **Purging {user_display}** — {completed}/{total_channels} channels done · {total_deleted:,} deleted",
|
||||
"",
|
||||
]
|
||||
|
||||
if active:
|
||||
lines.append("**Scanning:**")
|
||||
for name in active:
|
||||
s = stats[name]
|
||||
lines.append(f"› **#{name}** — {s['scanned']:,} scanned · {s['deleted']:,} deleted")
|
||||
lines.append("")
|
||||
|
||||
if done_channels:
|
||||
# Show last 5 completed to keep message short
|
||||
shown = done_channels[-5:]
|
||||
summary_parts = [f"#{n} ({stats[n]['deleted']:,})" for n in shown]
|
||||
prefix = f"**Done ({completed}):** " + " · ".join(summary_parts)
|
||||
if completed > 5:
|
||||
prefix += f" · *(+{completed - 5} more)*"
|
||||
lines.append(prefix)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# Ticker: edit the Discord message every 3s so we don't get rate limited
|
||||
stop_ticker = asyncio.Event()
|
||||
last_edit_time = [0.0]
|
||||
|
||||
async def ticker():
|
||||
while not stop_ticker.is_set():
|
||||
await asyncio.sleep(3)
|
||||
if stop_ticker.is_set():
|
||||
break
|
||||
try:
|
||||
await msg.edit(content=build_status())
|
||||
last_edit_time[0] = time.monotonic()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticker_task = asyncio.create_task(ticker())
|
||||
|
||||
async def purge_channel(channel: discord.TextChannel):
|
||||
if not channel.permissions_for(ctx.guild.me).manage_messages:
|
||||
failed_channels.append(f"#{channel_name}: No perms")
|
||||
continue
|
||||
async with lock:
|
||||
stats[channel.name]["done"] = True
|
||||
stats[channel.name]["error"] = "No perms"
|
||||
failed_channels.append(f"#{channel.name}: No perms")
|
||||
return
|
||||
|
||||
deleted_count, error = await safe_delete_user_messages(channel, user_id)
|
||||
total_deleted += deleted_count
|
||||
def on_progress(scanned: int, deleted: int):
|
||||
stats[channel.name]["scanned"] = scanned
|
||||
stats[channel.name]["deleted"] = deleted
|
||||
|
||||
if error:
|
||||
failed_channels.append(f"#{channel_name}: {error}")
|
||||
async with semaphore:
|
||||
deleted_count, error = await safe_delete_user_messages(channel, user_id, on_progress)
|
||||
|
||||
async with lock:
|
||||
stats[channel.name]["scanned"] = max(stats[channel.name]["scanned"], deleted_count)
|
||||
stats[channel.name]["deleted"] = deleted_count
|
||||
stats[channel.name]["done"] = True
|
||||
if error:
|
||||
stats[channel.name]["error"] = error
|
||||
failed_channels.append(f"#{channel.name}: {error}")
|
||||
|
||||
try:
|
||||
await asyncio.gather(*(purge_channel(ch) for ch in channels))
|
||||
except asyncio.CancelledError:
|
||||
stop_ticker.set()
|
||||
ticker_task.cancel()
|
||||
total_deleted = sum(s["deleted"] for s in stats.values())
|
||||
await msg.edit(content=(
|
||||
f"🛑 **Purge cancelled.**\n"
|
||||
f"🗑️ Deleted **{total_deleted:,}** message(s) before stopping."
|
||||
))
|
||||
return
|
||||
|
||||
stop_ticker.set()
|
||||
ticker_task.cancel()
|
||||
|
||||
total_deleted = sum(s["deleted"] for s in stats.values())
|
||||
summary = (
|
||||
f"✅ Finished purging **{user_display}**.\n"
|
||||
f"🗑️ Deleted **{total_deleted}** message(s)."
|
||||
f"✅ **Finished purging {user_display}.**\n"
|
||||
f"🗑️ Deleted **{total_deleted:,}** message(s) across {total_channels} channels."
|
||||
)
|
||||
if failed_channels:
|
||||
summary += f"\n⚠️ Skipped/Errored: {', '.join(failed_channels[:10])}"
|
||||
@@ -235,6 +528,24 @@ class Purge(ModerationBase):
|
||||
|
||||
await msg.edit(content=summary)
|
||||
|
||||
# Log the purgememberall operation to the mod log
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger and ctx.guild:
|
||||
embed = discord.Embed(
|
||||
title="Purge All — User Messages Wiped",
|
||||
color=discord.Color.dark_red(),
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
embed.add_field(name="Executed By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
|
||||
embed.add_field(name="Target", value=user_display, inline=True)
|
||||
embed.add_field(name="Target ID", value=str(user_id), inline=True)
|
||||
embed.add_field(name="Messages Deleted", value=f"{total_deleted:,}", inline=True)
|
||||
embed.add_field(name="Channels Scanned", value=str(total_channels), inline=True)
|
||||
if failed_channels:
|
||||
embed.add_field(name="Skipped Channels", value=", ".join(failed_channels[:10]), inline=False)
|
||||
embed.set_footer(text=f"Mod ID: {ctx.author.id}")
|
||||
await logger.send_log(ctx.guild.id, "message_bulk_delete", embed)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(Purge(bot))
|
||||
|
||||
@@ -8,12 +8,29 @@ from .loader import ModerationBase
|
||||
|
||||
|
||||
class SendEmbedCommand(ModerationBase):
|
||||
"""Cog providing the !send_embed command.
|
||||
|
||||
Accepts an embed string produced by the embed builder tool — a base64url-
|
||||
encoded, optionally zlib-compressed JSON payload — decodes it, previews the
|
||||
resulting embed(s) in the mod channel, and sends them to the target channel
|
||||
after confirmation.
|
||||
"""
|
||||
|
||||
@commands.command(name="send_embed")
|
||||
@ModerationBase.is_admin()
|
||||
async def send_embed(self, ctx, channel: discord.TextChannel, *, embed_string: str):
|
||||
"""
|
||||
Send a Discord embed from the embed builder to a specified channel.
|
||||
Usage: !send_embed <channel> <embed_string>
|
||||
"""Send a Discord embed from the embed builder to a specified channel.
|
||||
|
||||
The embed_string is base64url-decoded and optionally zlib-decompressed,
|
||||
then parsed as JSON. It may be a single embed object or a list of objects
|
||||
(for embed chains). A preview is shown before sending.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
The target channel to send the embed(s) to.
|
||||
embed_string:
|
||||
The encoded embed payload produced by the embed builder.
|
||||
"""
|
||||
# Decode the embed string
|
||||
try:
|
||||
@@ -23,30 +40,31 @@ class SendEmbedCommand(ModerationBase):
|
||||
raw = zlib.decompress(raw)
|
||||
except zlib.error:
|
||||
pass # Legacy uncompressed string
|
||||
|
||||
embed_data = json.loads(raw.decode())
|
||||
|
||||
|
||||
# Ensure embed_data is a list (for embed chains)
|
||||
if not isinstance(embed_data, list):
|
||||
embed_data = [embed_data]
|
||||
|
||||
|
||||
# Convert embed data to Discord embeds
|
||||
embeds = await self._build_embeds(embed_data)
|
||||
|
||||
|
||||
if not embeds:
|
||||
await ctx.send("❌ No valid embeds found in the provided data.")
|
||||
return
|
||||
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
await ctx.send(f"❌ Invalid embed string. Could not decode JSON: `{e}`")
|
||||
return
|
||||
except Exception as e:
|
||||
await ctx.send(f"❌ Failed to build embeds: `{e}`")
|
||||
return
|
||||
|
||||
|
||||
# Confirm before sending
|
||||
view = View(timeout=60)
|
||||
confirmed = {"value": False}
|
||||
|
||||
|
||||
async def yes_callback(interaction: discord.Interaction):
|
||||
if interaction.user != ctx.author:
|
||||
await interaction.response.send_message("You can't confirm this action.", ephemeral=True)
|
||||
@@ -54,7 +72,7 @@ class SendEmbedCommand(ModerationBase):
|
||||
confirmed["value"] = True
|
||||
await interaction.response.edit_message(content="✅ Confirmed. Sending embed...", view=None)
|
||||
view.stop()
|
||||
|
||||
|
||||
async def no_callback(interaction: discord.Interaction):
|
||||
if interaction.user != ctx.author:
|
||||
await interaction.response.send_message("You can't cancel this action.", ephemeral=True)
|
||||
@@ -62,56 +80,70 @@ class SendEmbedCommand(ModerationBase):
|
||||
confirmed["value"] = False
|
||||
await interaction.response.edit_message(content="❌ Cancelled.", view=None)
|
||||
view.stop()
|
||||
|
||||
|
||||
yes_button = Button(label="Yes", style=discord.ButtonStyle.green)
|
||||
no_button = Button(label="No", style=discord.ButtonStyle.red)
|
||||
yes_button.callback = yes_callback
|
||||
no_button.callback = no_callback
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
|
||||
# Send preview
|
||||
embed_count = len(embeds)
|
||||
preview_text = f"Send {'this embed' if embed_count == 1 else f'{embed_count} embeds'} to {channel.mention}?"
|
||||
|
||||
|
||||
try:
|
||||
await ctx.send(
|
||||
preview_text,
|
||||
embeds=embeds[:10] if len(embeds) <= 10 else embeds[:1], # Preview max 10 embeds or just first one
|
||||
view=view
|
||||
)
|
||||
|
||||
|
||||
if len(embeds) > 10:
|
||||
await ctx.send(f"⚠️ Preview shows only the first embed. Total embeds to send: {embed_count}")
|
||||
except Exception as e:
|
||||
await ctx.send(f"❌ Failed to send preview: `{e}`")
|
||||
return
|
||||
|
||||
|
||||
await view.wait()
|
||||
|
||||
|
||||
if not confirmed["value"]:
|
||||
return
|
||||
|
||||
# Send the embeds to the target channel
|
||||
|
||||
# Send the embeds to the target channel in batches of 10 (Discord API limit per message)
|
||||
try:
|
||||
# Discord allows max 10 embeds per message
|
||||
for i in range(0, len(embeds), 10):
|
||||
chunk = embeds[i:i+10]
|
||||
await channel.send(embeds=chunk)
|
||||
|
||||
|
||||
await ctx.send(f"✅ Successfully sent {'embed' if embed_count == 1 else f'{embed_count} embeds'} to {channel.mention}")
|
||||
|
||||
|
||||
except discord.Forbidden:
|
||||
await ctx.send(f"❌ I don't have permission to send messages in {channel.mention}")
|
||||
except Exception as e:
|
||||
await ctx.send(f"❌ Failed to send embeds: `{e}`")
|
||||
|
||||
|
||||
async def _build_embeds(self, embed_data: list) -> list[discord.Embed]:
|
||||
"""Build Discord embed objects from embed data"""
|
||||
"""Build Discord embed objects from a list of embed data dicts.
|
||||
|
||||
Supports two embed types controlled by the ``type`` field:
|
||||
- ``"image"`` — creates an embed containing only a single image.
|
||||
- ``"full"`` (default) — creates a complete embed with all available fields.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
embed_data:
|
||||
List of embed dict payloads as decoded from the embed builder JSON.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[discord.Embed]
|
||||
The constructed Discord embed objects, ready to send.
|
||||
"""
|
||||
embeds = []
|
||||
for data in embed_data:
|
||||
embed_type = data.get("type", "full")
|
||||
|
||||
|
||||
# Handle image-only embeds
|
||||
if embed_type == "image":
|
||||
if data.get("image") and data["image"].get("url"):
|
||||
@@ -119,10 +151,10 @@ class SendEmbedCommand(ModerationBase):
|
||||
embed.set_image(url=data["image"]["url"])
|
||||
embeds.append(embed)
|
||||
continue
|
||||
|
||||
|
||||
# Handle full embeds
|
||||
embed = discord.Embed()
|
||||
|
||||
|
||||
# Basic properties
|
||||
if data.get("title"):
|
||||
embed.title = data["title"]
|
||||
@@ -132,7 +164,7 @@ class SendEmbedCommand(ModerationBase):
|
||||
embed.url = data["url"]
|
||||
if data.get("color"):
|
||||
embed.color = data["color"]
|
||||
|
||||
|
||||
# Author
|
||||
if data.get("author"):
|
||||
author = data["author"]
|
||||
@@ -141,7 +173,7 @@ class SendEmbedCommand(ModerationBase):
|
||||
url=author.get("url"),
|
||||
icon_url=author.get("icon_url")
|
||||
)
|
||||
|
||||
|
||||
# Footer
|
||||
if data.get("footer"):
|
||||
footer = data["footer"]
|
||||
@@ -149,13 +181,12 @@ class SendEmbedCommand(ModerationBase):
|
||||
text=footer.get("text", ""),
|
||||
icon_url=footer.get("icon_url")
|
||||
)
|
||||
|
||||
|
||||
# Timestamp
|
||||
if data.get("timestamp"):
|
||||
# Discord.py will handle the timestamp if we set it
|
||||
from datetime import datetime
|
||||
embed.timestamp = datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00"))
|
||||
|
||||
|
||||
# Fields
|
||||
if data.get("fields"):
|
||||
for field in data["fields"]:
|
||||
@@ -165,19 +196,19 @@ class SendEmbedCommand(ModerationBase):
|
||||
value=field.get("value", "\u200b"),
|
||||
inline=field.get("inline", False)
|
||||
)
|
||||
|
||||
|
||||
# Image
|
||||
if data.get("image") and data["image"].get("url"):
|
||||
embed.set_image(url=data["image"]["url"])
|
||||
|
||||
|
||||
# Thumbnail
|
||||
if data.get("thumbnail") and data["thumbnail"].get("url"):
|
||||
embed.set_thumbnail(url=data["thumbnail"]["url"])
|
||||
|
||||
|
||||
embeds.append(embed)
|
||||
|
||||
|
||||
return embeds
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(SendEmbedCommand(bot))
|
||||
await bot.add_cog(SendEmbedCommand(bot))
|
||||
|
||||
@@ -28,8 +28,23 @@ INVITE_PATTERN = re.compile(
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
class SpamProtection(commands.Cog):
|
||||
"""Automatic spam detection and prevention system"""
|
||||
"""Automatic spam detection and prevention system.
|
||||
|
||||
Detects three spam patterns and applies a 1-hour Discord timeout automatically:
|
||||
|
||||
1. Invite link spam — any non-whitelisted discord.gg link.
|
||||
2. Cross-channel spam — messages in 3+ different channels within 10 seconds.
|
||||
3. Same-channel flood — 10+ messages in the same channel within 5 seconds.
|
||||
|
||||
On detection, staff are notified in NOTIFICATIONS_CHANNEL_ID with an
|
||||
action view offering Undo/Extend/Ban buttons. If no action is taken within
|
||||
12 hours, a background task automatically extends the timeout to 24 hours.
|
||||
|
||||
Messages are processed through an asyncio queue so the on_message listener
|
||||
never blocks. A cleanup task prunes stale in-memory tracking every 5 minutes.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
@@ -38,7 +53,7 @@ class SpamProtection(commands.Cog):
|
||||
# user_id -> deque of (timestamp, channel_id, content)
|
||||
self.user_messages = defaultdict(lambda: deque(maxlen=50))
|
||||
|
||||
# Track users already flagged (to avoid duplicate reports)
|
||||
# Track users already flagged to avoid duplicate reports for the same burst
|
||||
self.flagged_users = set()
|
||||
|
||||
self.message_queue = asyncio.Queue()
|
||||
@@ -50,6 +65,11 @@ class SpamProtection(commands.Cog):
|
||||
self.initialize_db()
|
||||
|
||||
def initialize_db(self):
|
||||
"""Create the spam_actions table if it doesn't exist.
|
||||
|
||||
Stores pending moderation decisions so they survive a bot restart and
|
||||
so the check_pending_actions loop can escalate them after 12 hours.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
@@ -66,12 +86,19 @@ class SpamProtection(commands.Cog):
|
||||
conn.close()
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Cancel all background tasks when the cog unloads."""
|
||||
self.cleanup_tracking.cancel()
|
||||
self.check_pending_actions.cancel()
|
||||
self.process_message_queue.cancel()
|
||||
|
||||
@tasks.loop(minutes=5)
|
||||
async def cleanup_tracking(self):
|
||||
"""Prune stale per-user message history every 5 minutes.
|
||||
|
||||
Removes entries older than the longest detection window, then removes
|
||||
users with no remaining history from both the tracking dict and the
|
||||
flagged set to keep memory bounded.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(seconds=max(CROSS_CHANNEL_WINDOW_SECONDS, SAME_CHANNEL_WINDOW_SECONDS))
|
||||
|
||||
@@ -82,6 +109,7 @@ class SpamProtection(commands.Cog):
|
||||
if not messages:
|
||||
del self.user_messages[user_id]
|
||||
|
||||
# Keep only flagged users who still have tracked messages
|
||||
self.flagged_users = {
|
||||
uid for uid in self.flagged_users
|
||||
if uid in self.user_messages
|
||||
@@ -89,6 +117,12 @@ class SpamProtection(commands.Cog):
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
async def check_pending_actions(self):
|
||||
"""Escalate unresolved spam timeouts to 24 hours after 12 hours.
|
||||
|
||||
Checks the spam_actions table for rows whose expires_at has passed
|
||||
(meaning no staff member clicked a button on the alert), and applies
|
||||
the extended default action to each.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
@@ -107,7 +141,17 @@ class SpamProtection(commands.Cog):
|
||||
conn.close()
|
||||
|
||||
async def apply_default_action(self, user_id: int, guild_id: int, spam_type: str):
|
||||
"""Extend timeout to 24 hours when no staff response within 12 hours"""
|
||||
"""Extend timeout to 24 hours when no staff response within 12 hours.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id:
|
||||
ID of the member to extend the timeout for.
|
||||
guild_id:
|
||||
ID of the guild the member belongs to.
|
||||
spam_type:
|
||||
The spam pattern type string (for logging context).
|
||||
"""
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return
|
||||
@@ -140,9 +184,11 @@ class SpamProtection(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
"""Queue messages for spam analysis, skipping exempt users and channels."""
|
||||
if message.author.bot or not message.guild:
|
||||
return
|
||||
|
||||
# Ritual Member role is exempt from spam detection
|
||||
whitelisted_role = message.guild.get_role(WHITELISTED_ROLE_ID)
|
||||
if whitelisted_role and whitelisted_role in message.author.roles:
|
||||
return
|
||||
@@ -151,10 +197,11 @@ class SpamProtection(commands.Cog):
|
||||
if message.channel.category_id == WHITELISTED_CATEGORY_ID:
|
||||
return
|
||||
|
||||
# Skip if already timed out
|
||||
# Skip if already timed out — their messages can't cause further harm
|
||||
if message.author.timed_out_until and message.author.timed_out_until > datetime.now(timezone.utc):
|
||||
return
|
||||
|
||||
# Avoid re-reporting a user mid-burst if they're already being processed
|
||||
if message.author.id in self.flagged_users:
|
||||
return
|
||||
|
||||
@@ -162,6 +209,7 @@ class SpamProtection(commands.Cog):
|
||||
|
||||
@tasks.loop(seconds=0.1)
|
||||
async def process_message_queue(self):
|
||||
"""Drain up to 10 messages from the queue per tick to cap processing latency."""
|
||||
try:
|
||||
for _ in range(10):
|
||||
try:
|
||||
@@ -173,6 +221,7 @@ class SpamProtection(commands.Cog):
|
||||
logger.error(f"Error in message queue processing: {e}", exc_info=True)
|
||||
|
||||
async def _process_message(self, message: discord.Message):
|
||||
"""Record the message in the user's history and check for spam patterns."""
|
||||
now = datetime.now(timezone.utc)
|
||||
user_id = message.author.id
|
||||
|
||||
@@ -188,6 +237,12 @@ class SpamProtection(commands.Cog):
|
||||
await self.handle_spam(message.author, message.guild, spam_detected)
|
||||
|
||||
async def check_spam_patterns(self, member: discord.Member, guild: discord.Guild, content: str):
|
||||
"""Check the user's recent message history for any of the three spam patterns.
|
||||
|
||||
Returns a spam data dict on match, or None if no pattern is triggered.
|
||||
The invite check fires even for a single message because a non-whitelisted
|
||||
invite sent anywhere is always a violation regardless of channel count.
|
||||
"""
|
||||
messages = self.user_messages[member.id]
|
||||
|
||||
if not messages:
|
||||
@@ -245,9 +300,23 @@ class SpamProtection(commands.Cog):
|
||||
return None
|
||||
|
||||
async def handle_spam(self, member: discord.Member, guild: discord.Guild, spam_data: dict):
|
||||
"""Apply a 1-hour timeout and send an alert to staff.
|
||||
|
||||
Also writes a spam_actions row with a 12-hour expiry so the
|
||||
check_pending_actions loop can auto-escalate if no staff respond.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
member:
|
||||
The member who triggered spam detection.
|
||||
guild:
|
||||
The guild the spam occurred in.
|
||||
spam_data:
|
||||
Dict returned by check_spam_patterns describing the pattern matched.
|
||||
"""
|
||||
self.flagged_users.add(member.id)
|
||||
|
||||
# Apply 1-hour timeout
|
||||
# Apply 1-hour timeout immediately
|
||||
timeout_until = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
try:
|
||||
await member.timeout(timeout_until, reason="Automatic spam detection")
|
||||
@@ -356,6 +425,7 @@ class SpamProtection(commands.Cog):
|
||||
try:
|
||||
msg = await notif_channel.send(embed=embed, view=view)
|
||||
|
||||
# Store the alert so check_pending_actions can escalate after 12 hours
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat()
|
||||
@@ -380,6 +450,13 @@ class SpamProtection(commands.Cog):
|
||||
|
||||
|
||||
class SpamActionView(View):
|
||||
"""Staff action buttons for a spam alert.
|
||||
|
||||
Timeout is 12 hours — matching the DB expiry for escalation. When any
|
||||
button is successfully actioned, the alert row is removed from spam_actions
|
||||
so the auto-escalate loop doesn't also fire.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot, member: discord.Member, guild: discord.Guild, spam_data: dict, db_path: str):
|
||||
super().__init__(timeout=43200) # 12 hours
|
||||
self.bot = bot
|
||||
@@ -390,6 +467,7 @@ class SpamActionView(View):
|
||||
self.alert_message_id = None
|
||||
|
||||
async def _remove_from_pending(self):
|
||||
"""Delete the spam_actions row so the escalation loop won't fire."""
|
||||
if self.alert_message_id:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
@@ -398,12 +476,14 @@ class SpamActionView(View):
|
||||
conn.close()
|
||||
|
||||
def _check_mod(self, interaction: discord.Interaction) -> bool:
|
||||
"""Return True if the interacting user has moderate_members permission."""
|
||||
if not isinstance(interaction.user, discord.Member):
|
||||
return False
|
||||
return interaction.user.guild_permissions.moderate_members
|
||||
|
||||
@discord.ui.button(label="Undo Timeout", style=discord.ButtonStyle.green, emoji="✅")
|
||||
async def undo_timeout_button(self, interaction: discord.Interaction, button: Button):
|
||||
"""Remove the timeout — used when the detection was a false positive."""
|
||||
if not self._check_mod(interaction):
|
||||
await interaction.response.send_message("❌ You don't have permission to do that.", ephemeral=True)
|
||||
return
|
||||
@@ -441,6 +521,7 @@ class SpamActionView(View):
|
||||
|
||||
@discord.ui.button(label="Extend to 24h", style=discord.ButtonStyle.gray, emoji="⏱️")
|
||||
async def extend_timeout_button(self, interaction: discord.Interaction, button: Button):
|
||||
"""Extend the timeout to 24 hours — used when spam is confirmed."""
|
||||
if not self._check_mod(interaction):
|
||||
await interaction.response.send_message("❌ You don't have permission to do that.", ephemeral=True)
|
||||
return
|
||||
@@ -479,6 +560,7 @@ class SpamActionView(View):
|
||||
|
||||
@discord.ui.button(label="Ban User", style=discord.ButtonStyle.red, emoji="🔨")
|
||||
async def ban_button(self, interaction: discord.Interaction, button: Button):
|
||||
"""Ban the user outright — requires ban_members permission."""
|
||||
if not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.ban_members:
|
||||
await interaction.response.send_message("❌ You don't have permission to ban members.", ephemeral=True)
|
||||
return
|
||||
@@ -505,6 +587,16 @@ class SpamActionView(View):
|
||||
try:
|
||||
await self.guild.ban(self.member, reason=reason, delete_message_days=1)
|
||||
|
||||
# Write infraction to DB
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
INSERT INTO infractions (user_id, guild_id, type, reason, moderator_id, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (self.member.id, self.guild.id, "ban", reason, interaction.user.id, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
log_cog = self.bot.get_cog("Logger")
|
||||
if log_cog:
|
||||
await log_cog.log_moderation_action(
|
||||
@@ -524,6 +616,8 @@ class SpamActionView(View):
|
||||
|
||||
|
||||
class ConfirmView(View):
|
||||
"""Generic ephemeral confirm/cancel view for spam action buttons."""
|
||||
|
||||
def __init__(self, user: discord.User):
|
||||
super().__init__(timeout=30)
|
||||
self.user = user
|
||||
@@ -531,6 +625,7 @@ class ConfirmView(View):
|
||||
|
||||
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.green)
|
||||
async def confirm_button(self, interaction: discord.Interaction, button: Button):
|
||||
"""Only the moderator who invoked the parent action can confirm."""
|
||||
if interaction.user.id != self.user.id:
|
||||
await interaction.response.send_message("❌ Only the moderator who initiated this can confirm.", ephemeral=True)
|
||||
return
|
||||
@@ -540,6 +635,7 @@ class ConfirmView(View):
|
||||
|
||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.gray)
|
||||
async def cancel_button(self, interaction: discord.Interaction, button: Button):
|
||||
"""Only the moderator who invoked the parent action can cancel."""
|
||||
if interaction.user.id != self.user.id:
|
||||
await interaction.response.send_message("❌ Only the moderator who initiated this can cancel.", ephemeral=True)
|
||||
return
|
||||
|
||||
@@ -2,11 +2,26 @@ import discord
|
||||
from discord.ext import commands
|
||||
from .loader import ModerationBase
|
||||
|
||||
|
||||
class UnbanCommand(ModerationBase):
|
||||
"""Cog providing the !unban prefix command."""
|
||||
|
||||
@commands.command(name="unban")
|
||||
@ModerationBase.is_admin()
|
||||
async def unban(self, ctx, user: discord.User | str, *, reason: str | None = None):
|
||||
"""Unban a user by mention, ID, or name."""
|
||||
"""Unban a user by mention, ID, or name.
|
||||
|
||||
Resolves a raw mention or user ID string into a User object if needed,
|
||||
then removes the ban and logs the action.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The user to unban (mention, ID, or User object).
|
||||
reason:
|
||||
Optional reason for the unban.
|
||||
"""
|
||||
# Resolve a raw ID string or mention into a User object
|
||||
if isinstance(user, str):
|
||||
user_id = user.strip("<@!>")
|
||||
try:
|
||||
@@ -23,8 +38,10 @@ class UnbanCommand(ModerationBase):
|
||||
await ctx.send(f"✅ {user.mention if hasattr(user, 'mention') else user} has been unbanned.")
|
||||
except Exception as e:
|
||||
await ctx.send(f"❌ Failed to unban: `{e}`")
|
||||
return
|
||||
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "unban", reason)
|
||||
|
||||
# Log the action
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(ctx.guild.id, "unban", user, ctx.author, reason)
|
||||
|
||||
@@ -7,12 +7,31 @@ from .loader import ModerationBase
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db"
|
||||
|
||||
# Role ID for the server's muted role — must match mute.py
|
||||
MUTE_ROLE_ID = 982702037517090836
|
||||
|
||||
|
||||
class UnmuteCommand(ModerationBase):
|
||||
"""Cog providing the !unmute prefix command.
|
||||
|
||||
Removes the mute role from a member immediately and clears their entry from
|
||||
the persistent `mutes` table, preventing `mute.py`'s background task from
|
||||
trying to unmute them again when the timer expires.
|
||||
"""
|
||||
|
||||
@commands.command(name="unmute")
|
||||
@ModerationBase.is_admin()
|
||||
async def unmute(self, ctx, user: discord.Member):
|
||||
"""Manually remove a mute from a member with a confirmation prompt.
|
||||
|
||||
Removes the mute role, deletes the DB record so the scheduled unmute
|
||||
won't fire again, and sends a DM to the user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The server member to unmute.
|
||||
"""
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
@@ -51,11 +70,14 @@ class UnmuteCommand(ModerationBase):
|
||||
|
||||
if mute_role in user.roles:
|
||||
await user.remove_roles(mute_role, reason="Manual unmute issued")
|
||||
|
||||
# Remove from DB so the scheduled unmute task won't fire when the timer expires
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user.id, ctx.guild.id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
await user.send(f"You have been **unmuted** in **{ctx.guild.name}**.")
|
||||
except Exception:
|
||||
@@ -63,8 +85,7 @@ class UnmuteCommand(ModerationBase):
|
||||
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "unmute", "Manual unmute issued")
|
||||
await ctx.send(f"{user.mention} has been unmuted.")
|
||||
|
||||
# Log to logging system
|
||||
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(
|
||||
@@ -73,5 +94,6 @@ class UnmuteCommand(ModerationBase):
|
||||
else:
|
||||
await ctx.send(f"{user.mention} is not currently muted.")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(UnmuteCommand(bot))
|
||||
await bot.add_cog(UnmuteCommand(bot))
|
||||
|
||||
@@ -3,11 +3,25 @@ from discord.ext import commands
|
||||
from discord.ui import View, Button
|
||||
from .loader import ModerationBase
|
||||
|
||||
|
||||
class WarnCommand(ModerationBase):
|
||||
"""Cog providing the !warn prefix command."""
|
||||
|
||||
@commands.command(name="warn")
|
||||
@ModerationBase.is_admin()
|
||||
async def warn(self, ctx, user: discord.Member, *, reason: str | None = None):
|
||||
"""Warn a user with confirmation and log infraction"""
|
||||
"""Warn a member with a confirmation prompt.
|
||||
|
||||
Sends a DM to the user with the warning reason and writes an
|
||||
infraction record to the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user:
|
||||
The server member to warn.
|
||||
reason:
|
||||
Optional reason for the warning.
|
||||
"""
|
||||
view = View(timeout=30)
|
||||
confirmed = {"value": False}
|
||||
|
||||
@@ -34,7 +48,10 @@ class WarnCommand(ModerationBase):
|
||||
view.add_item(yes_button)
|
||||
view.add_item(no_button)
|
||||
|
||||
await ctx.send(f"Are you sure you want to warn {user.mention}? Reason: {reason or 'No reason provided'}", view=view)
|
||||
await ctx.send(
|
||||
f"Are you sure you want to warn {user.mention}? Reason: {reason or 'No reason provided'}",
|
||||
view=view
|
||||
)
|
||||
await view.wait()
|
||||
if not confirmed["value"]:
|
||||
return
|
||||
@@ -43,19 +60,20 @@ class WarnCommand(ModerationBase):
|
||||
return
|
||||
|
||||
try:
|
||||
await user.send(f"You have been **warned** in **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}")
|
||||
except Exception:
|
||||
await user.send(
|
||||
f"You have been **warned** in **{ctx.guild.name}**.\n"
|
||||
f"Reason: {reason or 'No reason provided'}"
|
||||
)
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
await ctx.send("Could not DM the user.")
|
||||
|
||||
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "warn", reason)
|
||||
await ctx.send(f"{user.mention} has been warned.")
|
||||
|
||||
# Log to logging system
|
||||
|
||||
logger = self.bot.get_cog("Logger")
|
||||
if logger:
|
||||
await logger.log_moderation_action(
|
||||
ctx.guild.id, "warn", user, ctx.author, reason
|
||||
)
|
||||
await logger.log_moderation_action(ctx.guild.id, "warn", user, ctx.author, reason)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(WarnCommand(bot))
|
||||
await bot.add_cog(WarnCommand(bot))
|
||||
|
||||
@@ -16,6 +16,7 @@ import traceback
|
||||
FONTS_PATH = Path(__file__).parent / "fonts"
|
||||
|
||||
def list_fonts():
|
||||
"""Return a list of font names (without extension) from the profiles/fonts directory."""
|
||||
fonts = []
|
||||
if os.path.exists(FONTS_PATH):
|
||||
for f in os.listdir(FONTS_PATH):
|
||||
@@ -23,7 +24,20 @@ def list_fonts():
|
||||
fonts.append(os.path.splitext(f)[0])
|
||||
return fonts
|
||||
|
||||
|
||||
def generate_gradient_image(colors, width, height):
|
||||
"""Generate a horizontal multi-stop gradient PIL image.
|
||||
|
||||
Splits the total width evenly between each consecutive color pair, renders
|
||||
each segment as a 2-D numpy gradient, and pastes them into a single image.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
colors:
|
||||
List of hex color strings (e.g. ["#FF0000", "#00FF00", "#0000FF"]).
|
||||
width, height:
|
||||
Pixel dimensions of the output image.
|
||||
"""
|
||||
try:
|
||||
# source: https://note.nkmk.me/en/python-numpy-generate-gradation-image/
|
||||
|
||||
@@ -68,6 +82,7 @@ async def font_name_autocomplete(
|
||||
interaction: discord.Interaction,
|
||||
current: str,
|
||||
):
|
||||
"""Autocomplete callback for the font_name parameter in /profile set."""
|
||||
fonts = list_fonts()
|
||||
return_array = [
|
||||
app_commands.Choice(name=font_name, value=font_name)
|
||||
@@ -77,7 +92,19 @@ async def font_name_autocomplete(
|
||||
|
||||
|
||||
class Profiles(commands.GroupCog, name="profile"):
|
||||
"""Profile commands"""
|
||||
"""GroupCog providing the /profile slash-command group.
|
||||
|
||||
Subcommands:
|
||||
- fonts — render a visual preview image of all available fonts.
|
||||
- set — update one or more profile fields (all optional, any subset).
|
||||
- view — render and return a 1000×550 PNG profile card.
|
||||
|
||||
The profile card (view) is fully rendered in PIL via asyncio.to_thread
|
||||
because image generation is CPU-bound. It features a two-panel layout:
|
||||
left panel with circular avatar and top roles, right panel with profile
|
||||
fields. The background supports solid colours or multi-stop gradients.
|
||||
Font choice is persisted per-user in profiles.db.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
@@ -85,6 +112,7 @@ class Profiles(commands.GroupCog, name="profile"):
|
||||
|
||||
@app_commands.command(name="fonts", description="List all available fonts with a visual preview.")
|
||||
async def list_fonts_cmd(self, interaction: discord.Interaction):
|
||||
"""Render and send a preview image showing every available font with a sample sentence."""
|
||||
await interaction.response.defer(thinking=True)
|
||||
|
||||
fonts = list_fonts()
|
||||
@@ -165,6 +193,12 @@ class Profiles(commands.GroupCog, name="profile"):
|
||||
)
|
||||
@app_commands.autocomplete(font_name=font_name_autocomplete)
|
||||
async def setprofile(self, interaction: discord.Interaction, pronouns: str = None, about_me: str = None, fav_color: str = None, bg_color: str = None, fav_game: str = None, fav_artist: str = None, birthday: str = None, font_name: str = None):
|
||||
"""Update profile fields for the caller.
|
||||
|
||||
Only provided (non-None) fields are written; existing values for
|
||||
omitted fields are preserved. bg_color accepts space-separated hex
|
||||
codes (up to 5) for gradient backgrounds.
|
||||
"""
|
||||
# Defer the response to show "thinking"
|
||||
await interaction.response.defer(thinking=True)
|
||||
|
||||
@@ -248,6 +282,7 @@ class Profiles(commands.GroupCog, name="profile"):
|
||||
|
||||
@app_commands.command(name="view", description="View your or another user's profile.")
|
||||
async def profile(self, interaction: discord.Interaction, member: discord.Member = None):
|
||||
"""Render and send the member's profile card as a PNG attachment."""
|
||||
# Defer immediately to show "thinking"
|
||||
await interaction.response.defer(thinking=True)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ KEY_PATH = Path(__file__).parent.parent / "data" / "reminder.key"
|
||||
|
||||
|
||||
def load_or_create_key() -> Fernet:
|
||||
"""Load the Fernet encryption key from disk, generating and saving a new one if absent."""
|
||||
if KEY_PATH.exists():
|
||||
key = KEY_PATH.read_bytes()
|
||||
else:
|
||||
@@ -29,10 +30,17 @@ fernet = load_or_create_key()
|
||||
|
||||
|
||||
def encrypt(text: str) -> str:
|
||||
"""Encrypt a reminder message string for storage in the database."""
|
||||
return fernet.encrypt(text.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(token: str) -> str:
|
||||
"""Decrypt a reminder message from the database.
|
||||
|
||||
Falls back to returning the raw value if decryption fails, which handles
|
||||
rows that were stored before encryption was introduced or if the key file
|
||||
was rotated. The warning log helps identify such legacy rows.
|
||||
"""
|
||||
try:
|
||||
return fernet.decrypt(token.strip().encode()).decode()
|
||||
except InvalidToken:
|
||||
@@ -105,11 +113,26 @@ def parse_datetime(when: str, tzname: str | None) -> datetime:
|
||||
|
||||
|
||||
class ReminderCog(commands.Cog):
|
||||
"""Cog providing the /reminder slash-command group.
|
||||
|
||||
Reminder messages are encrypted at rest using Fernet symmetric encryption
|
||||
(key stored in data/reminder.key). A background task fires every 60 seconds
|
||||
to deliver due reminders via DM and then delete them from the database.
|
||||
|
||||
Subcommands:
|
||||
- set — relative duration (e.g. 10m, 2h, 3d).
|
||||
- at — absolute date/time with optional IANA timezone.
|
||||
- list — view all active reminders with time-remaining summary.
|
||||
- remove — delete a specific reminder by ID.
|
||||
- clear — delete all reminders for the caller.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.db_path = Path(__file__).parent.parent / "data" / "reminders.db"
|
||||
|
||||
async def setup_database(self):
|
||||
"""Create the reminders table if it doesn't exist."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS reminders (
|
||||
@@ -122,11 +145,13 @@ class ReminderCog(commands.Cog):
|
||||
await db.commit()
|
||||
|
||||
async def cog_load(self):
|
||||
"""Set up the database and start the delivery loop."""
|
||||
await self.setup_database()
|
||||
if not self.check_reminders.is_running():
|
||||
self.check_reminders.start()
|
||||
|
||||
async def _insert_reminder(self, user_id: int, message: str, remind_at: datetime) -> int:
|
||||
"""Encrypt and insert a new reminder row, returning the auto-assigned ID."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO reminders (user_id, message, remind_at) VALUES (?, ?, ?)",
|
||||
@@ -144,6 +169,7 @@ class ReminderCog(commands.Cog):
|
||||
message="What to remind you about"
|
||||
)
|
||||
async def reminder_set(self, interaction: discord.Interaction, timeframe: str, message: str):
|
||||
"""Set a reminder at a relative duration from now."""
|
||||
try:
|
||||
try:
|
||||
delta = parse_timeframe(timeframe)
|
||||
@@ -170,6 +196,7 @@ class ReminderCog(commands.Cog):
|
||||
timezone="Your timezone, e.g. 'US/Eastern', 'UTC+5', 'Europe/London' (default: UTC)"
|
||||
)
|
||||
async def reminder_at(self, interaction: discord.Interaction, when: str, message: str, timezone: str | None = None):
|
||||
"""Set a reminder at a specific date/time, parsed via dateutil with optional IANA timezone."""
|
||||
try:
|
||||
try:
|
||||
remind_at = parse_datetime(when, timezone)
|
||||
@@ -190,6 +217,7 @@ class ReminderCog(commands.Cog):
|
||||
|
||||
@reminder_group.command(name="list", description="View your active reminders")
|
||||
async def reminder_list(self, interaction: discord.Interaction):
|
||||
"""List all active reminders with a human-readable time-remaining string."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT id, message, remind_at FROM reminders WHERE user_id = ? ORDER BY remind_at",
|
||||
@@ -242,6 +270,7 @@ class ReminderCog(commands.Cog):
|
||||
@reminder_group.command(name="remove", description="Remove a specific reminder by ID")
|
||||
@app_commands.describe(reminder_id="The ID of the reminder to remove (from /reminder list)")
|
||||
async def reminder_remove(self, interaction: discord.Interaction, reminder_id: int):
|
||||
"""Delete a single reminder, verifying it belongs to the caller before removing."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT message FROM reminders WHERE id = ? AND user_id = ?",
|
||||
@@ -270,6 +299,7 @@ class ReminderCog(commands.Cog):
|
||||
|
||||
@reminder_group.command(name="clear", description="Remove all your active reminders")
|
||||
async def reminder_clear(self, interaction: discord.Interaction):
|
||||
"""Delete all of the caller's reminders in a single operation."""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM reminders WHERE user_id = ?",
|
||||
@@ -291,6 +321,13 @@ class ReminderCog(commands.Cog):
|
||||
|
||||
@tasks.loop(seconds=60)
|
||||
async def check_reminders(self):
|
||||
"""Deliver all due reminders and delete them.
|
||||
|
||||
Runs every 60 seconds. Reminders whose fire time has passed are
|
||||
fetched in bulk, then delivered via DM. DM failures from Forbidden
|
||||
(user blocked DMs) are treated as delivered and removed so they don't
|
||||
pile up. Transient HTTPException errors are left for the next cycle.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
@@ -337,6 +374,7 @@ class ReminderCog(commands.Cog):
|
||||
|
||||
@check_reminders.before_loop
|
||||
async def before_check_reminders(self):
|
||||
"""Wait for the bot to be fully connected before starting the delivery loop."""
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
aiofiles==25.1.0
|
||||
mcrcon>=0.7.0
|
||||
redis>=5.0
|
||||
aiohttp==3.12.15
|
||||
aiosqlite==0.21.0
|
||||
|
||||
@@ -5,6 +5,19 @@ import time
|
||||
|
||||
|
||||
class Sparkle(commands.Cog):
|
||||
"""Cog that randomly awards sparkle reactions based on message ID trailing digits.
|
||||
|
||||
Sparkle probability is determined by inspecting the Discord snowflake ID of
|
||||
each incoming message:
|
||||
- Regular (✨): ID ends in "000" → ~1/1,000
|
||||
- Rare (🌟): ID ends in "0000" → ~1/10,000
|
||||
- Epic (💫): ID ends in "00000" → ~1/100,000
|
||||
|
||||
The longer suffix takes precedence (checked most-specific first).
|
||||
Both the per-user count and the timestamped event log are updated in the DB
|
||||
via asyncio.to_thread to avoid blocking the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.chances = {
|
||||
@@ -14,7 +27,7 @@ class Sparkle(commands.Cog):
|
||||
}
|
||||
|
||||
async def _add_sparkle(self, message, sparkle_type):
|
||||
"""Add a sparkle reaction and update the database."""
|
||||
"""React to the message and update both the sparkle counter and event log in the DB."""
|
||||
emoji, description = self.chances[sparkle_type][1:]
|
||||
|
||||
# Add reaction and send notification
|
||||
|
||||
@@ -12,6 +12,18 @@ from embed.embed_color import get_embed_color
|
||||
|
||||
|
||||
class SparkleCommands(commands.Cog):
|
||||
"""Cog providing the /sparkle slash-command group for viewing sparkle counts and stats.
|
||||
|
||||
Subcommands:
|
||||
- check — per-user sparkle counts by type.
|
||||
- info — explanation of sparkle probabilities.
|
||||
- leaderboard — randomly ordered server leaderboard (up to 20 entries).
|
||||
- stats — server-wide totals, message-per-sparkle ratios, and timing data.
|
||||
|
||||
All database reads are offloaded to a thread executor because sparkle.db
|
||||
uses the synchronous sqlite3 driver.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.sparkle_emojis = {
|
||||
@@ -29,6 +41,7 @@ class SparkleCommands(commands.Cog):
|
||||
@sparkle_group.command(name="check", description="Check your sparkle count or another user's")
|
||||
@app_commands.describe(user="The user to check sparkle count for (leave empty for yourself)")
|
||||
async def sparkle_check(self, interaction: discord.Interaction, user: Optional[discord.User] = None):
|
||||
"""Show epic, rare, regular, and total sparkle counts for the target member."""
|
||||
if not interaction.guild:
|
||||
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
|
||||
return
|
||||
@@ -81,6 +94,7 @@ class SparkleCommands(commands.Cog):
|
||||
# ========== /sparkle info ==========
|
||||
@sparkle_group.command(name="info", description="Learn about sparkles and how they work")
|
||||
async def sparkle_info(self, interaction: discord.Interaction):
|
||||
"""Send an embed explaining each sparkle type and its probability."""
|
||||
embed = discord.Embed(
|
||||
title="✨ Sparkles ✨",
|
||||
description=(
|
||||
@@ -100,6 +114,13 @@ class SparkleCommands(commands.Cog):
|
||||
@sparkle_group.command(name="leaderboard", description="Show random sparkle leaderboard")
|
||||
@app_commands.describe(limit="Number of users to show (max 20, default 10)")
|
||||
async def sparkle_leaderboard(self, interaction: discord.Interaction, limit: int = 10):
|
||||
"""Show a randomly ordered sparkle leaderboard for current guild members.
|
||||
|
||||
Filters to members currently in the guild (in-memory set) before
|
||||
querying to avoid showing data for users who left. Ordered randomly
|
||||
(ORDER BY RANDOM()) because a fixed rank ordering would always be the
|
||||
same given equal totals.
|
||||
"""
|
||||
if not interaction.guild:
|
||||
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
|
||||
return
|
||||
@@ -166,6 +187,12 @@ class SparkleCommands(commands.Cog):
|
||||
# ========== /sparkle stats ==========
|
||||
@sparkle_group.command(name="stats", description="View server sparkle statistics")
|
||||
async def sparkle_stats(self, interaction: discord.Interaction):
|
||||
"""Show server-wide sparkle totals, message-per-sparkle ratios, and per-type timing.
|
||||
|
||||
Pulls total counts from the sparkles table and timestamped events from
|
||||
sparkle_events. Also opens stats.db from a separate connection to read
|
||||
the total message count for the messages-per-sparkle calculation.
|
||||
"""
|
||||
if not interaction.guild:
|
||||
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
|
||||
return
|
||||
|
||||
@@ -17,6 +17,13 @@ BOT_DEV_ROLE_ID = 1470439484549234866
|
||||
|
||||
|
||||
class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
|
||||
"""Modal that collects an optional deny reason from a moderator.
|
||||
|
||||
Opened by the Deny button on a SuggestionButtons view. On submit it
|
||||
updates the DB, edits the admin embed (red, disabled buttons), DMs the
|
||||
submitter, and posts a notice in the original suggestion channel.
|
||||
"""
|
||||
|
||||
reason = discord.ui.TextInput(label="Reason (optional)", style=discord.TextStyle.long, required=False, max_length=2000)
|
||||
|
||||
def __init__(self, suggestion_id: int, user_id: int, suggestion_text: str, channel_id: int, admin_message_id: Optional[int], bot: commands.Bot, original_embed: discord.Embed):
|
||||
@@ -30,6 +37,7 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
|
||||
self.original_embed = original_embed
|
||||
|
||||
async def on_submit(self, interaction: discord.Interaction):
|
||||
"""Process the denial: update DB, edit admin embed, DM submitter, post channel notice."""
|
||||
try:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
@@ -48,12 +56,15 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
|
||||
if admin_channel and isinstance(admin_channel, discord.abc.Messageable):
|
||||
orig_msg = await admin_channel.fetch_message(self.admin_message_id)
|
||||
|
||||
updated_embed = self.original_embed.copy()
|
||||
base_embed = self.original_embed if self.original_embed is not None else (orig_msg.embeds[0] if orig_msg.embeds else discord.Embed())
|
||||
updated_embed = base_embed.copy()
|
||||
updated_embed.color = discord.Color.red()
|
||||
updated_embed.title = f"❌ Denied Suggestion (ID: {self.suggestion_id})"
|
||||
|
||||
updated_embed.set_field_at(0, name="Suggested by", value=updated_embed.fields[0].value, inline=True)
|
||||
updated_embed.set_field_at(1, name="Channel", value=updated_embed.fields[1].value, inline=True)
|
||||
if len(updated_embed.fields) > 0:
|
||||
updated_embed.set_field_at(0, name="Suggested by", value=updated_embed.fields[0].value, inline=True)
|
||||
if len(updated_embed.fields) > 1:
|
||||
updated_embed.set_field_at(1, name="Channel", value=updated_embed.fields[1].value, inline=True)
|
||||
updated_embed.add_field(name="Status", value="Denied", inline=False)
|
||||
updated_embed.add_field(name="Denied by", value=f"{interaction.user.mention}", inline=True)
|
||||
updated_embed.add_field(name="Denied at", value=f"<t:{int(datetime.utcnow().timestamp())}:F>", inline=True)
|
||||
@@ -103,6 +114,17 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
|
||||
|
||||
|
||||
class SuggestionButtons(discord.ui.View):
|
||||
"""Persistent approval/deny/complete button view attached to admin-channel suggestion embeds.
|
||||
|
||||
timeout=None makes the view survive bot restarts (re-registered in cog_load).
|
||||
|
||||
show_complete controls which buttons are shown:
|
||||
- False (default): Approve and Deny buttons (shown on Pending suggestions).
|
||||
- True: Mark Complete button only (shown after a suggestion is Approved).
|
||||
|
||||
disabled=True renders all buttons greyed out (used after a terminal action).
|
||||
"""
|
||||
|
||||
def __init__(self, bot, suggestion_id=None, user_id=None, suggestion_text=None, channel_id=None, admin_message_id: Optional[int] = None, disabled: bool = False, show_complete: bool = False):
|
||||
super().__init__(timeout=None)
|
||||
self.bot = bot
|
||||
@@ -130,6 +152,7 @@ class SuggestionButtons(discord.ui.View):
|
||||
self.add_item(complete_btn)
|
||||
|
||||
async def approve(self, interaction: discord.Interaction):
|
||||
"""Approve the suggestion: update DB, swap buttons to 'Mark Complete', DM submitter."""
|
||||
try:
|
||||
has_permission = (
|
||||
interaction.user.id == ADMIN_ID or
|
||||
@@ -206,6 +229,7 @@ class SuggestionButtons(discord.ui.View):
|
||||
pass
|
||||
|
||||
async def deny(self, interaction: discord.Interaction):
|
||||
"""Open the DenyModal to collect an optional reason before denying."""
|
||||
try:
|
||||
has_permission = (
|
||||
interaction.user.id == ADMIN_ID or
|
||||
@@ -252,6 +276,7 @@ class SuggestionButtons(discord.ui.View):
|
||||
pass
|
||||
|
||||
async def complete(self, interaction: discord.Interaction):
|
||||
"""Mark an already-approved suggestion as Completed. Only valid if status == 'Approved'."""
|
||||
try:
|
||||
has_permission = (
|
||||
interaction.user.id == ADMIN_ID or
|
||||
@@ -339,6 +364,12 @@ class SuggestionButtons(discord.ui.View):
|
||||
|
||||
|
||||
class PaginationView(discord.ui.View):
|
||||
"""Simple prev/next paginator for multi-page suggestion list embeds.
|
||||
|
||||
Times out after 3 minutes of inactivity. Only the user who invoked the
|
||||
command can interact with the buttons (others get an ephemeral error).
|
||||
"""
|
||||
|
||||
def __init__(self, embeds, user: discord.User):
|
||||
super().__init__(timeout=180)
|
||||
self.embeds = embeds
|
||||
@@ -352,6 +383,7 @@ class PaginationView(discord.ui.View):
|
||||
self.previous_button.disabled = True
|
||||
|
||||
async def update_page(self, interaction: discord.Interaction):
|
||||
"""Re-render the current page and update button disabled state."""
|
||||
self.previous_button.disabled = self.current_page == 0
|
||||
self.next_button.disabled = self.current_page == len(self.embeds) - 1
|
||||
await interaction.response.edit_message(embed=self.embeds[self.current_page], view=self)
|
||||
@@ -376,6 +408,26 @@ class PaginationView(discord.ui.View):
|
||||
|
||||
|
||||
class Suggestion(commands.GroupCog, name="suggest"):
|
||||
"""GroupCog providing the /suggest slash-command group.
|
||||
|
||||
Subcommands:
|
||||
- submit — submit a new suggestion (any member).
|
||||
- view — view full details of a suggestion by ID.
|
||||
- list — paginated list with optional status/member filters.
|
||||
- stats — per-member submission counts.
|
||||
- complete — admin-only command to mark an approved suggestion as done.
|
||||
- todo — admin-only to-do list of approved suggestions the caller approved.
|
||||
|
||||
Workflow:
|
||||
1. Member submits a suggestion; it is inserted into suggestions.db and an
|
||||
embed with Approve/Deny buttons is posted in the admin channel.
|
||||
2. Admin approves → embed turns green, buttons swap to "Mark Complete".
|
||||
3. Admin marks complete → embed turns blue, buttons disabled.
|
||||
OR admin denies (via DenyModal) → embed turns red, buttons disabled.
|
||||
|
||||
At cog load, all Pending and Approved suggestion views are re-registered
|
||||
with add_view so buttons keep working across bot restarts.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
@@ -383,6 +435,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
self.db = None
|
||||
|
||||
async def cog_load(self):
|
||||
"""Open the database, create the table, and re-register persistent views."""
|
||||
self.db = await aiosqlite.connect(self.db_path)
|
||||
await self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS suggestions (
|
||||
@@ -415,11 +468,18 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
logger.info(f"Re-registered view for suggestion #{sid} (message {admin_msg_id}, status: {status})")
|
||||
|
||||
async def cog_unload(self):
|
||||
"""Close the persistent database connection on unload."""
|
||||
if self.db:
|
||||
await self.db.close()
|
||||
|
||||
@app_commands.command(name="submit", description="Submit a suggestion")
|
||||
async def suggest(self, interaction: discord.Interaction, idea: str):
|
||||
"""Insert the suggestion into the DB, confirm in-channel, and post to admin channel.
|
||||
|
||||
After sending the admin embed, stores the resulting message ID back into
|
||||
the DB row so the embed can be edited on approve/deny/complete, and
|
||||
registers a persistent view so the buttons survive restarts.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
@@ -473,6 +533,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
|
||||
@app_commands.command(name="view", description="View full details of a suggestion")
|
||||
async def viewsuggestion(self, interaction: discord.Interaction, suggestion_id: int):
|
||||
"""Display a single suggestion's full text, status, submitter, and deny reason if any."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
@@ -519,6 +580,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
|
||||
@app_commands.command(name="complete", description="Mark an approved suggestion as completed (Admin only)")
|
||||
async def completesuggestion(self, interaction: discord.Interaction, suggestion_id: int):
|
||||
"""Admin slash command to mark a suggestion complete (mirrors the button in the admin embed)."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
@@ -608,6 +670,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
app_commands.Choice(name="Completed", value="Completed")
|
||||
])
|
||||
async def listsuggestions(self, interaction: discord.Interaction, status: Optional[app_commands.Choice[str]] = None, member: Optional[discord.Member] = None):
|
||||
"""Show a paginated list of suggestions, optionally filtered by status and/or member."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
@@ -667,6 +730,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
|
||||
@app_commands.command(name="stats", description="View suggestion stats for yourself or another user")
|
||||
async def suggestionstats(self, interaction: discord.Interaction, member: Optional[discord.Member] = None):
|
||||
"""Show total, accepted (Approved+Completed), and rejected counts for a member."""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
@@ -707,6 +771,11 @@ class Suggestion(commands.GroupCog, name="suggest"):
|
||||
|
||||
@app_commands.command(name="todo", description="View your approved suggestions to-do list")
|
||||
async def todolist(self, interaction: discord.Interaction, member: Optional[discord.Member] = None):
|
||||
"""Admin-only: list approved suggestions the target user approved, awaiting completion.
|
||||
|
||||
Determines authorship by fetching each approved suggestion's admin embed
|
||||
and checking the "Approved by" field for the target's mention.
|
||||
"""
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
"""
|
||||
constants.py — Centralised Discord IDs and threshold values for the bot.
|
||||
|
||||
All hardcoded IDs (users, guild, channels, roles, emojis) live here so they
|
||||
can be updated in one place without hunting through individual cog files.
|
||||
"""
|
||||
|
||||
# --- Owner / Admin ---
|
||||
LILAC_ID = 252130669919076352
|
||||
LILAC_ID = 252130669919076352 # Bot owner; bypasses all permission checks
|
||||
|
||||
# --- Guild ---
|
||||
GUILD_ID = 876772600704020530
|
||||
GUILD_ID = 876772600704020530 # Primary server the bot operates in
|
||||
|
||||
# --- Channels ---
|
||||
WELCOME_CHANNEL_ID = 876772600704020533
|
||||
FALLBACK_CHANNEL_ID = 876772600704020533
|
||||
LOG_CHANNEL_ID = 1440055015711703242
|
||||
ADMIN_CHANNEL_ID = 1470441786810826884
|
||||
APPROVAL_CHANNEL_ID = 1424145004976275617
|
||||
BACKUP_CHANNEL_ID = 946421558778417172
|
||||
NOTIFICATION_CHANNEL_ID = 1424145004976275617
|
||||
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884]
|
||||
WELCOME_CHANNEL_ID = 876772600704020533 # Where welcome messages are posted
|
||||
FALLBACK_CHANNEL_ID = 876772600704020533 # Fallback channel when a specific channel is unavailable
|
||||
LOG_CHANNEL_ID = 1440055015711703242 # Default channel for mod/system log messages
|
||||
ADMIN_CHANNEL_ID = 1470441786810826884 # Staff-only admin channel
|
||||
APPROVAL_CHANNEL_ID = 1424145004976275617 # Where pending approval embeds (infractions, credits) are sent
|
||||
BACKUP_CHANNEL_ID = 946421558778417172 # Backup/archive channel
|
||||
NOTIFICATION_CHANNEL_ID = 1424145004976275617 # General notification channel (same as approval)
|
||||
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884] # Git webhook targets
|
||||
|
||||
# --- Roles ---
|
||||
BIRTHDAY_ROLE_ID = 1113751318918602762
|
||||
BOT_TRAP_ROLE_ID = 1439354601672282335
|
||||
SERVER_ADMIN_ROLE_ID = 952560403970416722
|
||||
BOT_DEV_ROLE_ID = 1470439484549234866
|
||||
BIRTHDAY_ROLE_ID = 1113751318918602762 # Temporary role assigned on a member's birthday
|
||||
BOT_TRAP_ROLE_ID = 1439354601672282335 # Role given to suspected bots; blocks welcome message
|
||||
SERVER_ADMIN_ROLE_ID = 952560403970416722 # General staff/admin role
|
||||
BOT_DEV_ROLE_ID = 1470439484549234866 # Bot developer role with elevated permissions
|
||||
|
||||
# --- Emojis ---
|
||||
SALT_EMOJI_ID = 1074583707459010560
|
||||
SALT_EMOJI_ID = 1074583707459010560 # Custom :salt: emoji used in reactions
|
||||
|
||||
# --- Thresholds ---
|
||||
NEW_MEMBER_THRESHOLD_DAYS = 7
|
||||
NEW_MEMBER_THRESHOLD_DAYS = 7 # Members newer than this many days are considered "new"
|
||||
|
||||
11
xp/add_xp.py
11
xp/add_xp.py
@@ -5,6 +5,17 @@ from .utils import get_multiplier, random_xp, can_get_xp, check_level_up
|
||||
from .exclude_channels import is_channel_excluded
|
||||
|
||||
async def add_xp(user):
|
||||
"""Award XP to a guild member for sending a message.
|
||||
|
||||
Called from the global on_message handler in bot.py for every non-bot
|
||||
message. Skips DM users, excluded channels, and members still on the
|
||||
per-message cooldown (checked against the lifetime DB).
|
||||
|
||||
A single random base XP value is generated once and then applied across
|
||||
all five leaderboard databases (lifetime, annual, monthly, weekly, daily).
|
||||
The role multiplier is only applied to the lifetime database so bonus
|
||||
rates don't distort the periodic leaderboards.
|
||||
"""
|
||||
# Only process XP for guild members, not DM users
|
||||
if not isinstance(user, discord.Member):
|
||||
return
|
||||
|
||||
@@ -6,6 +6,21 @@ DB_DIR = Path(__file__).parent.parent / "data"
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_db(db_type="lifetime"):
|
||||
"""Open (or create) an XP database for the given leaderboard type and return (conn, cursor).
|
||||
|
||||
Accepts a boolean for backwards-compatibility: True → "lifetime", False → "annual".
|
||||
|
||||
Each leaderboard type has its own SQLite file (e.g. lifetime.db, weekly.db).
|
||||
The daily/weekly/monthly databases also include a reset_log table that
|
||||
records the last time the leaderboard was wiped, so the UI can display
|
||||
'last reset X ago'.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db_type:
|
||||
One of "lifetime", "annual", "monthly", "weekly", "daily" (or a bool).
|
||||
Defaults to "lifetime" for any unrecognised value.
|
||||
"""
|
||||
if isinstance(db_type, bool):
|
||||
db_type = "lifetime" if db_type else "annual"
|
||||
|
||||
@@ -40,6 +55,7 @@ def get_db(db_type="lifetime"):
|
||||
return conn, cur
|
||||
|
||||
def reset_leaderboard(db_type):
|
||||
"""Wipe all XP rows and record the reset timestamp in reset_log."""
|
||||
import time
|
||||
|
||||
conn, cur = get_db(db_type)
|
||||
@@ -48,7 +64,9 @@ def reset_leaderboard(db_type):
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_reset(db_type):
|
||||
"""Return the Unix timestamp of the last leaderboard reset, or 0 if never reset."""
|
||||
conn, cur = get_db(db_type)
|
||||
cur.execute("SELECT last_reset FROM reset_log WHERE id = 1")
|
||||
row = cur.fetchone()
|
||||
|
||||
Reference in New Issue
Block a user