From 971ec5c84950c7f1d22b1378bd85fb497e2a1c97 Mon Sep 17 00:00:00 2001 From: Lilac-Rose Date: Tue, 16 Jun 2026 15:01:11 +0200 Subject: [PATCH] feat: mod logging overhaul, bug fixes, minesweeper fix, emote credits pending view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot.py | 20 ++ commands/bonk.py | 15 +- commands/coinflip.py | 3 + commands/diceroll.py | 12 + commands/emote_credits.py | 206 ++++++++++- commands/faq.py | 3 + commands/generate_role_color_images.py | 15 + commands/giveaway.py | 56 ++- commands/help.py | 13 +- commands/infractions.py | 40 ++- commands/meow.py | 3 + commands/ping.py | 9 + commands/prestige_color.py | 38 ++ commands/quote.py | 75 +++- commands/rng.py | 11 + commands/role_color.py | 26 ++ commands/role_track.py | 57 ++- commands/salt.py | 25 +- commands/send_message.py | 28 ++ commands/tts.py | 55 ++- commands/whitelist.py | 293 ++++++++++++++++ events/april_fools.py | 38 ++ events/archipelago_monitor.py | 124 ++++++- events/botban.py | 33 +- events/chain.py | 34 +- events/daily_fractal.py | 22 ++ events/git_webhook.py | 100 ++++-- events/goodbot.py | 9 +- events/ping_protect.py | 30 ++ events/welcome.py | 23 +- games/minesweeper.py | 5 + moderation/ban.py | 42 ++- moderation/cleanban.py | 84 ++++- moderation/infraction.py | 231 ++++++++++--- moderation/kick.py | 38 +- moderation/loader.py | 47 ++- moderation/lock.py | 163 ++++++--- moderation/log_config.py | 213 +++++++----- moderation/logger.py | 459 +++++++++++++++++-------- moderation/modhelp.py | 7 + moderation/mute.py | 116 +++++-- moderation/purge.py | 403 +++++++++++++++++++--- moderation/send_embed.py | 103 ++++-- moderation/spam_protection.py | 106 +++++- moderation/unban.py | 21 +- moderation/unmmute.py | 28 +- moderation/warn.py | 38 +- profiles/profiles.py | 37 +- reminders/reminder.py | 38 ++ requirements.txt | 1 + sparkle/sparkle.py | 15 +- sparkle/sparkle_commands.py | 27 ++ suggestion/suggest.py | 75 +++- utils/constants.py | 39 ++- xp/add_xp.py | 11 + xp/database.py | 18 + 56 files changed, 3112 insertions(+), 669 deletions(-) create mode 100644 commands/whitelist.py diff --git a/bot.py b/bot.py index fd35fd8..5df5751 100644 --- a/bot.py +++ b/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) diff --git a/commands/bonk.py b/commands/bonk.py index 7a13197..65a84bd 100644 --- a/commands/bonk.py +++ b/commands/bonk.py @@ -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)) diff --git a/commands/coinflip.py b/commands/coinflip.py index 123850d..0d0671c 100644 --- a/commands/coinflip.py +++ b/commands/coinflip.py @@ -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: diff --git a/commands/diceroll.py b/commands/diceroll.py index 12d08a9..057a7db 100644 --- a/commands/diceroll.py +++ b/commands/diceroll.py @@ -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()) diff --git a/commands/emote_credits.py b/commands/emote_credits.py index d9c413c..fb60fbc 100644 --- a/commands/emote_credits.py +++ b/commands/emote_credits.py @@ -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>`` / ````, + 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 for animated emoji_pattern = r'' 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,)) diff --git a/commands/faq.py b/commands/faq.py index c6b5d7a..b5ecd50 100644 --- a/commands/faq.py +++ b/commands/faq.py @@ -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), diff --git a/commands/generate_role_color_images.py b/commands/generate_role_color_images.py index 5617bab..41694a1 100644 --- a/commands/generate_role_color_images.py +++ b/commands/generate_role_color_images.py @@ -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() diff --git a/commands/giveaway.py b/commands/giveaway.py index fd80065..33b14c1 100644 --- a/commands/giveaway.py +++ b/commands/giveaway.py @@ -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() diff --git a/commands/help.py b/commands/help.py index 879adae..2a28acf 100644 --- a/commands/help.py +++ b/commands/help.py @@ -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)) \ No newline at end of file + await bot.add_cog(Help(bot)) diff --git a/commands/infractions.py b/commands/infractions.py index 5e76464..d189352 100644 --- a/commands/infractions.py +++ b/commands/infractions.py @@ -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) diff --git a/commands/meow.py b/commands/meow.py index 5e70785..f1536ca 100644 --- a/commands/meow.py +++ b/commands/meow.py @@ -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]) diff --git a/commands/ping.py b/commands/ping.py index 0ae939d..9a93b1a 100644 --- a/commands/ping.py +++ b/commands/ping.py @@ -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...") diff --git a/commands/prestige_color.py b/commands/prestige_color.py index a17fbd0..26744b8 100644 --- a/commands/prestige_color.py +++ b/commands/prestige_color.py @@ -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: diff --git a/commands/quote.py b/commands/quote.py index cc23638..bcc4acc 100644 --- a/commands/quote.py +++ b/commands/quote.py @@ -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 ) diff --git a/commands/rng.py b/commands/rng.py index fc18ca4..86db76a 100644 --- a/commands/rng.py +++ b/commands/rng.py @@ -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 diff --git a/commands/role_color.py b/commands/role_color.py index d90c488..88c0729 100644 --- a/commands/role_color.py +++ b/commands/role_color.py @@ -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" diff --git a/commands/role_track.py b/commands/role_track.py index bfeb89b..6307721 100644 --- a/commands/role_track.py +++ b/commands/role_track.py @@ -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) diff --git a/commands/salt.py b/commands/salt.py index 6482889..baa1874 100644 --- a/commands/salt.py +++ b/commands/salt.py @@ -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 diff --git a/commands/send_message.py b/commands/send_message.py index 4f138e0..381a5ab 100644 --- a/commands/send_message.py +++ b/commands/send_message.py @@ -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) diff --git a/commands/tts.py b/commands/tts.py index bc486ee..861817d 100644 --- a/commands/tts.py +++ b/commands/tts.py @@ -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 diff --git a/commands/whitelist.py b/commands/whitelist.py new file mode 100644 index 0000000..2b6e16d --- /dev/null +++ b/commands/whitelist.py @@ -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("" 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)) diff --git a/events/april_fools.py b/events/april_fools.py index 6498c6a..b9a7bb3 100644 --- a/events/april_fools.py +++ b/events/april_fools.py @@ -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) diff --git a/events/archipelago_monitor.py b/events/archipelago_monitor.py index 8ed7563..585673f 100644 --- a/events/archipelago_monitor.py +++ b/events/archipelago_monitor.py @@ -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() diff --git a/events/botban.py b/events/botban.py index 6a1a6a0..b3e408b 100644 --- a/events/botban.py +++ b/events/botban.py @@ -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)) \ No newline at end of file + await bot.add_cog(AutoBanOnRole(bot)) diff --git a/events/chain.py b/events/chain.py index 1bc6491..2ca0f6e 100644 --- a/events/chain.py +++ b/events/chain.py @@ -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)) \ No newline at end of file + await bot.add_cog(ChainDetector(bot)) diff --git a/events/daily_fractal.py b/events/daily_fractal.py index c2e3be2..5d8d135 100644 --- a/events/daily_fractal.py +++ b/events/daily_fractal.py @@ -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() diff --git a/events/git_webhook.py b/events/git_webhook.py index 3e01dff..8c92c3e 100644 --- a/events/git_webhook.py +++ b/events/git_webhook.py @@ -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)) \ No newline at end of file + await bot.add_cog(GitWebhook(bot)) diff --git a/events/goodbot.py b/events/goodbot.py index 2063035..81ba37d 100644 --- a/events/goodbot.py +++ b/events/goodbot.py @@ -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)) \ No newline at end of file + await bot.add_cog(GoodBotListener(bot)) diff --git a/events/ping_protect.py b/events/ping_protect.py index 491162d..bca3bbb 100644 --- a/events/ping_protect.py +++ b/events/ping_protect.py @@ -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 = ?", diff --git a/events/welcome.py b/events/welcome.py index 3c0fd19..26858f6 100644 --- a/events/welcome.py +++ b/events/welcome.py @@ -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)) \ No newline at end of file + await bot.add_cog(Welcome(bot)) diff --git a/games/minesweeper.py b/games/minesweeper.py index e14de38..aa84f36 100644 --- a/games/minesweeper.py +++ b/games/minesweeper.py @@ -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]: diff --git a/moderation/ban.py b/moderation/ban.py index 1a794c9..149b375 100644 --- a/moderation/ban.py +++ b/moderation/ban.py @@ -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)) diff --git a/moderation/cleanban.py b/moderation/cleanban.py index 285be8f..8809bde 100644 --- a/moderation/cleanban.py +++ b/moderation/cleanban.py @@ -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)) diff --git a/moderation/infraction.py b/moderation/infraction.py index a253347..8113e50 100644 --- a/moderation/infraction.py +++ b/moderation/infraction.py @@ -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 `` — list active infractions for a user. + - ``search_full `` — full history including removed infractions. + - ``list`` — all active infractions in the server. + - ``delete `` — permanently delete an infraction with notify/silent choice. + - ``resend `` — 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)) \ No newline at end of file + await bot.add_cog(InfractionCommand(bot)) diff --git a/moderation/kick.py b/moderation/kick.py index b4aee9d..b58ff87 100644 --- a/moderation/kick.py +++ b/moderation/kick.py @@ -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)) \ No newline at end of file + await bot.add_cog(KickCommand(bot)) diff --git a/moderation/loader.py b/moderation/loader.py index 752c0ce..cbfdfd6 100644 --- a/moderation/loader.py +++ b/moderation/loader.py @@ -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)) diff --git a/moderation/lock.py b/moderation/lock.py index 9cd3dcf..e1c0063 100644 --- a/moderation/lock.py +++ b/moderation/lock.py @@ -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)) \ No newline at end of file + await bot.add_cog(LockCog(bot)) diff --git a/moderation/log_config.py b/moderation/log_config.py index a9306f0..38300ae 100644 --- a/moderation/log_config.py +++ b/moderation/log_config.py @@ -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 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 - 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 - 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 """ 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 `", 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)) \ No newline at end of file + await bot.add_cog(LogConfig(bot)) diff --git a/moderation/logger.py b/moderation/logger.py index 63f8ddb..cb0ff22 100644 --- a/moderation/logger.py +++ b/moderation/logger.py @@ -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"", 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)) \ No newline at end of file + await bot.add_cog(Logger(bot)) diff --git a/moderation/modhelp.py b/moderation/modhelp.py index 3b43fc3..277f581 100644 --- a/moderation/modhelp.py +++ b/moderation/modhelp.py @@ -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 diff --git a/moderation/mute.py b/moderation/mute.py index 77ac904..6486148 100644 --- a/moderation/mute.py +++ b/moderation/mute.py @@ -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)) \ No newline at end of file + await bot.add_cog(MuteCommand(bot)) diff --git a/moderation/purge.py b/moderation/purge.py index 125e06c..0ab5d94 100644 --- a/moderation/purge.py +++ b/moderation/purge.py @@ -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)) diff --git a/moderation/send_embed.py b/moderation/send_embed.py index 3747edd..0e6aa3b 100644 --- a/moderation/send_embed.py +++ b/moderation/send_embed.py @@ -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 + """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)) \ No newline at end of file + await bot.add_cog(SendEmbedCommand(bot)) diff --git a/moderation/spam_protection.py b/moderation/spam_protection.py index 2de7f77..175a841 100644 --- a/moderation/spam_protection.py +++ b/moderation/spam_protection.py @@ -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 diff --git a/moderation/unban.py b/moderation/unban.py index 4fcfb8c..af5be33 100644 --- a/moderation/unban.py +++ b/moderation/unban.py @@ -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) diff --git a/moderation/unmmute.py b/moderation/unmmute.py index a6c94bc..aeb99f7 100644 --- a/moderation/unmmute.py +++ b/moderation/unmmute.py @@ -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)) \ No newline at end of file + await bot.add_cog(UnmuteCommand(bot)) diff --git a/moderation/warn.py b/moderation/warn.py index 6c0be07..ac28b6a 100644 --- a/moderation/warn.py +++ b/moderation/warn.py @@ -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)) \ No newline at end of file + await bot.add_cog(WarnCommand(bot)) diff --git a/profiles/profiles.py b/profiles/profiles.py index 4c9b55d..b9555e9 100644 --- a/profiles/profiles.py +++ b/profiles/profiles.py @@ -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) diff --git a/reminders/reminder.py b/reminders/reminder.py index 05d07fc..7a4ac37 100644 --- a/reminders/reminder.py +++ b/reminders/reminder.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 1e6eecc..a9ea240 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiofiles==25.1.0 +mcrcon>=0.7.0 redis>=5.0 aiohttp==3.12.15 aiosqlite==0.21.0 diff --git a/sparkle/sparkle.py b/sparkle/sparkle.py index 12f5331..4adc451 100644 --- a/sparkle/sparkle.py +++ b/sparkle/sparkle.py @@ -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 diff --git a/sparkle/sparkle_commands.py b/sparkle/sparkle_commands.py index 0e83b52..2895b1c 100644 --- a/sparkle/sparkle_commands.py +++ b/sparkle/sparkle_commands.py @@ -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 diff --git a/suggestion/suggest.py b/suggestion/suggest.py index 2d656d9..b2bea95 100644 --- a/suggestion/suggest.py +++ b/suggestion/suggest.py @@ -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"", 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: diff --git a/utils/constants.py b/utils/constants.py index 573b512..5521369 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -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" diff --git a/xp/add_xp.py b/xp/add_xp.py index 014658f..a6f4fad 100644 --- a/xp/add_xp.py +++ b/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 diff --git a/xp/database.py b/xp/database.py index 9574823..a43e670 100644 --- a/xp/database.py +++ b/xp/database.py @@ -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()