diff --git a/.gitignore b/.gitignore index 41d35be..8a6208d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ bad_apple.mp3 # ARG — keep out of public repo to prevent spoilers arg/ + +# Personal mailbox integration — not part of the general bot +mail/ diff --git a/bot.py b/bot.py index 5df5751..678f6a9 100644 --- a/bot.py +++ b/bot.py @@ -36,6 +36,7 @@ COG_FOLDERS = [ "wordle", "reminders", "arg", + "mail", ] # --- Bot setup --- diff --git a/commands/salt.py b/commands/salt.py index baa1874..448abac 100644 --- a/commands/salt.py +++ b/commands/salt.py @@ -3,7 +3,6 @@ from discord.ext import commands from moderation.loader import ModerationBase from typing import Optional import asyncio -import random SALT_EMOJI_ID = 1074583707459010560 @@ -36,12 +35,9 @@ class SaltCommand(ModerationBase): 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: - chance = random.randrange(1,101) - if chance == 1: - await ctx.send("https://tenor.com/view/you-didnt-say-the-magic-word-ah-ah-nope-wagging-finger-gif-17646607") - return + await ctx.send("https://tenor.com/view/you-didnt-say-the-magic-word-ah-ah-nope-wagging-finger-gif-17646607") + return if member.id == ctx.author.id: await ctx.send("You cant salt yourself") diff --git a/commands/whitelist.py b/commands/whitelist.py index b37836f..ff7e38c 100644 --- a/commands/whitelist.py +++ b/commands/whitelist.py @@ -94,6 +94,60 @@ async def rcon_command(cmd: str) -> str: await writer.wait_closed() +PAGE_SIZE = 10 + + +class WhitelistListView(discord.ui.View): + def __init__(self, rows: list, *, timeout: float = 120): + super().__init__(timeout=timeout) + self.rows = rows + self.page = 0 + self.total_pages = max(1, (len(rows) + PAGE_SIZE - 1) // PAGE_SIZE) + self._sync_buttons() + + def _sync_buttons(self): + self.prev_button.disabled = self.page == 0 + self.next_button.disabled = self.page >= self.total_pages - 1 + + def build_embed(self) -> discord.Embed: + embed = discord.Embed(title="Minecraft Whitelist", color=0x57F287) + page_rows = self.rows[self.page * PAGE_SIZE : (self.page + 1) * PAGE_SIZE] + + pending = [r for r in page_rows if r[2] == "pending"] + approved = [r for r in page_rows if r[2] == "approved"] + + if pending: + lines = [] + for mc_name, discord_id, _, requested_at in pending: + user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked" + lines.append(f"• `{mc_name}` — {user_ref} *(requested {requested_at[:10]})*") + embed.add_field(name=f"⏳ 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", value="\n".join(lines), inline=False) + + total_pending = sum(1 for r in self.rows if r[2] == "pending") + total_approved = sum(1 for r in self.rows if r[2] == "approved") + embed.set_footer(text=f"Page {self.page + 1}/{self.total_pages} • {total_pending} pending, {total_approved} approved") + return embed + + @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) + async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page -= 1 + self._sync_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + self._sync_buttons() + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + class Whitelist(commands.GroupCog, name="whitelist"): """Minecraft whitelist request and management commands.""" @@ -157,37 +211,22 @@ class Whitelist(commands.GroupCog, name="whitelist"): await interaction.response.defer(ephemeral=True) - async with aiosqlite.connect(DB_PATH) as db: - async with db.execute( - "SELECT minecraft_username, discord_user_id, status, requested_at FROM whitelist ORDER BY status DESC, requested_at ASC" - ) as cur: - rows = await cur.fetchall() + try: + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT minecraft_username, discord_user_id, status, requested_at FROM whitelist ORDER BY status DESC, requested_at ASC" + ) as cur: + rows = await cur.fetchall() - if not rows: - await interaction.followup.send("No whitelist entries found.", ephemeral=True) - return + if not rows: + await interaction.followup.send("No whitelist entries found.", ephemeral=True) + return - pending = [r for r in rows if r[2] == "pending"] - approved = [r for r in rows if r[2] == "approved"] - - embed = discord.Embed(title="Minecraft Whitelist", color=0x57f287) - - if pending: - lines = [] - for mc_name, discord_id, _, requested_at in pending: - date = requested_at[:10] - user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked" - lines.append(f"• `{mc_name}` — {user_ref} *(requested {date})*") - embed.add_field(name=f"⏳ Pending ({len(pending)})", value="\n".join(lines), inline=False) - - if approved: - lines = [] - for mc_name, discord_id, _, _ in approved: - user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked" - lines.append(f"• `{mc_name}` — {user_ref}") - embed.add_field(name=f"✅ Approved ({len(approved)})", value="\n".join(lines), inline=False) - - await interaction.followup.send(embed=embed, ephemeral=True) + view = WhitelistListView(rows) + await interaction.followup.send(embed=view.build_embed(), view=view, ephemeral=True) + except Exception as e: + await interaction.followup.send(f"❌ Error: `{e}`", ephemeral=True) + raise # ── /whitelist add ───────────────────────────────────────────────────────── diff --git a/events/admin_events.py b/events/admin_events.py new file mode 100644 index 0000000..dfa680f --- /dev/null +++ b/events/admin_events.py @@ -0,0 +1,648 @@ +""" +admin_events.py — Owner-only admin commands for the 5th Anniversary event. + +All commands are gated behind two layers: + 1. default_member_permissions=administrator — hides commands from non-admins in the Discord UI + 2. LILAC_ID check at the top of every body — rejects anyone who isn't the bot owner + +Cross-cog access to TimeCapsule and PuzzleHunt is done via self.bot.cogs. +""" + +import json +import sqlite3 +import discord +import aiohttp +from discord.ext import commands +from discord import app_commands +from datetime import datetime, timezone, timedelta, date +from pathlib import Path + +from utils.constants import GUILD_ID, LILAC_ID +from utils.logger import get_logger + +logger = get_logger(__name__) + +CONFIG_PATH = Path(__file__).parent.parent / "capsule_puzzle_config.json" +CAPSULE_DB = Path(__file__).parent.parent / "data" / "capsule.db" +PUZZLE_DB = Path(__file__).parent.parent / "data" / "puzzle.db" + +EVENT_COLOR = 0xB48EAD +SITE_URL = "http://localhost:3100" + + +def _load_config() -> dict: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def _set_capsule_state(key: str, value: str): + conn = sqlite3.connect(CAPSULE_DB) + c = conn.cursor() + c.execute( + "INSERT INTO capsule_event_state (key, value) VALUES (?, ?)" + " ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value), + ) + conn.commit() + conn.close() + + +def _check_owner(interaction: discord.Interaction) -> bool: + return interaction.user.id == LILAC_ID + + +# --------------------------------------------------------------------------- +# Admin cog +# --------------------------------------------------------------------------- + +class AdminEvents(commands.Cog): + """Owner-only admin commands for capsule, puzzle, and system management.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + def _capsule_cog(self): + return self.bot.cogs.get("TimeCapsule") + + def _puzzle_cog(self): + return self.bot.cogs.get("PuzzleHunt") + + # ------------------------------------------------------------------ # + # Command groups # + # ------------------------------------------------------------------ # + + admin_group = app_commands.Group( + name="admin", + description="5th Anniversary event admin commands", + default_member_permissions=discord.Permissions(administrator=True), + ) + capsule_group = app_commands.Group( + name="capsule", + description="Time Capsule admin commands", + parent=admin_group, + ) + puzzle_group = app_commands.Group( + name="puzzle", + description="Puzzle Hunt admin commands", + parent=admin_group, + ) + system_group = app_commands.Group( + name="system", + description="System health and schedule commands", + parent=admin_group, + ) + + # ================================================================== # + # /admin capsule # + # ================================================================== # + + @capsule_group.command(name="stats", description="Submission counts per day and unique user total") + async def capsule_stats(self, interaction: discord.Interaction): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(CAPSULE_DB) + c = conn.cursor() + c.execute("SELECT COUNT(DISTINCT user_id) FROM capsule_submissions") + unique_users = c.fetchone()[0] + c.execute( + "SELECT day, COUNT(*) FROM capsule_submissions GROUP BY day ORDER BY day" + ) + day_counts = c.fetchall() + conn.close() + + lines = [f"**{unique_users}** unique users have submitted at least one entry.\n"] + cfg = _load_config() + for day, count in day_counts: + prompt = cfg["capsule_prompts"][day - 1] + label = f"Day {day}" if prompt is None else f"Day {day}" + lines.append(f"`{label}` — {count} submissions") + + embed = discord.Embed( + title="Capsule Stats", + description="\n".join(lines), + color=EVENT_COLOR, + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + @capsule_group.command(name="view", description="View a user's full capsule contents") + @app_commands.describe(user="The user whose capsule to view") + async def capsule_view(self, interaction: discord.Interaction, user: discord.Member): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(CAPSULE_DB) + c = conn.cursor() + c.execute( + "SELECT day, response_text, image_url, is_public, submitted_at" + " FROM capsule_submissions WHERE user_id=? ORDER BY day", + (user.id,), + ) + rows = c.fetchall() + conn.close() + + if not rows: + await interaction.followup.send( + f"{user.display_name} has no capsule submissions.", ephemeral=True + ) + return + + cfg = _load_config() + embed = discord.Embed( + title=f"Capsule: {user.display_name}", + color=EVENT_COLOR, + ) + for day, text, image_url, is_public, submitted_at in rows: + prompt = cfg["capsule_prompts"][day - 1] + if prompt is None: + prompt = "Wildcard" + header = f"Day {day} {'[public]' if is_public else '[private]'}" + value = text[:1020] + if image_url: + value += f"\n[image]({image_url})" + embed.add_field(name=header, value=value, inline=False) + + await interaction.followup.send(embed=embed, ephemeral=True) + + @capsule_group.command(name="reset", description="Delete a user's capsule submission(s)") + @app_commands.describe(user="The user", day="Specific day to delete (omit to delete all)") + async def capsule_reset( + self, + interaction: discord.Interaction, + user: discord.Member, + day: int = None, + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(CAPSULE_DB) + c = conn.cursor() + if day is not None: + c.execute( + "DELETE FROM capsule_submissions WHERE user_id=? AND day=?", + (user.id, day), + ) + msg = f"Deleted Day {day} submission for {user.display_name}." + else: + c.execute( + "DELETE FROM capsule_submissions WHERE user_id=?", (user.id,) + ) + msg = f"Deleted all capsule submissions for {user.display_name}." + conn.commit() + conn.close() + await interaction.followup.send(msg, ephemeral=True) + + @capsule_group.command(name="force-post", description="Manually trigger a day's prompt post now") + @app_commands.describe(day="Day number (1-7)") + async def capsule_force_post(self, interaction: discord.Interaction, day: int): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + if not 1 <= day <= 7: + await interaction.response.send_message("Day must be 1-7.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + cog = self._capsule_cog() + if cog is None: + await interaction.followup.send("TimeCapsule cog not loaded.", ephemeral=True) + return + + try: + await cog._post_daily_capsule(day) + await interaction.followup.send(f"Day {day} capsule prompt posted.", ephemeral=True) + except Exception as e: + logger.exception(f"admin capsule force-post day={day}: {e}") + await interaction.followup.send(f"Error: {e}", ephemeral=True) + + @capsule_group.command(name="set-prompt", description="Override a day's prompt text without redeploying") + @app_commands.describe(day="Day number (1-7)", text="New prompt text") + async def capsule_set_prompt( + self, interaction: discord.Interaction, day: int, text: str + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + if not 1 <= day <= 7: + await interaction.response.send_message("Day must be 1-7.", ephemeral=True) + return + _set_capsule_state(f"capsule_prompt_override_{day}", text) + await interaction.response.send_message( + f"Day {day} prompt overridden. Takes effect on next post/submit.", + ephemeral=True, + ) + + @capsule_group.command( + name="force-reveal", + description="Trigger the capsule reveal early for one user or globally", + ) + @app_commands.describe( + user="User to reveal to (omit to run globally)", + confirm="Must be True to run globally", + ) + async def capsule_force_reveal( + self, + interaction: discord.Interaction, + user: discord.Member = None, + confirm: bool = False, + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + + if user is None and not confirm: + await interaction.response.send_message( + "This will DM every capsule participant early. " + "Pass `confirm:True` to proceed globally.", + ephemeral=True, + ) + return + + await interaction.response.defer(ephemeral=True) + cog = self._capsule_cog() + if cog is None: + await interaction.followup.send("TimeCapsule cog not loaded.", ephemeral=True) + return + + try: + if user is not None: + await cog._reveal_for_user(user.id) + await interaction.followup.send( + f"Reveal sent to {user.display_name}.", ephemeral=True + ) + else: + await cog.reveal_capsule() + await interaction.followup.send("Global reveal triggered.", ephemeral=True) + except Exception as e: + logger.exception(f"admin capsule force-reveal: {e}") + await interaction.followup.send(f"Error: {e}", ephemeral=True) + + # ================================================================== # + # /admin puzzle # + # ================================================================== # + + @puzzle_group.command(name="stats", description="Solve counts and first solver per day") + @app_commands.describe(day="Specific day (omit for all days)") + async def puzzle_stats(self, interaction: discord.Interaction, day: int = None): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + + days_to_show = [day] if day else list(range(1, 8)) + lines = [] + for d in days_to_show: + c.execute("SELECT COUNT(*) FROM puzzle_solves WHERE day=?", (d,)) + total = c.fetchone()[0] + c.execute( + "SELECT user_id FROM puzzle_solves WHERE day=? ORDER BY solved_at ASC LIMIT 1", + (d,), + ) + first_row = c.fetchone() + first = f"<@{first_row[0]}>" if first_row else "none" + lines.append(f"**Day {d}** — {total} solves — first: {first}") + conn.close() + + embed = discord.Embed( + title="Puzzle Stats", + description="\n".join(lines), + color=EVENT_COLOR, + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + @puzzle_group.command( + name="set-fragment", + description="Override or add an accepted fragment for a day (without redeploy)", + ) + @app_commands.describe(day="Day number (1-7)", text="New accepted fragment text") + async def puzzle_set_fragment( + self, interaction: discord.Interaction, day: int, text: str + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + if not 1 <= day <= 7: + await interaction.response.send_message("Day must be 1-7.", ephemeral=True) + return + _set_capsule_state(f"puzzle_fragment_override_{day}", text.strip().lower()) + await interaction.response.send_message( + f"Day {day} fragment override set to `{text.strip().lower()}`.", + ephemeral=True, + ) + + @puzzle_group.command(name="force-post", description="Manually trigger a day's lead-in message now") + @app_commands.describe(day="Day number (1-7)") + async def puzzle_force_post(self, interaction: discord.Interaction, day: int): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + if not 1 <= day <= 7: + await interaction.response.send_message("Day must be 1-7.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + cog = self._puzzle_cog() + if cog is None: + await interaction.followup.send("PuzzleHunt cog not loaded.", ephemeral=True) + return + + try: + await cog._post_daily_puzzle(day) + await interaction.followup.send( + f"Day {day} lead-in posted.", ephemeral=True + ) + except Exception as e: + logger.exception(f"admin puzzle force-post day={day}: {e}") + await interaction.followup.send(f"Error: {e}", ephemeral=True) + + @puzzle_group.command(name="clear-solve", description="Remove an erroneous solve record") + @app_commands.describe(user="The user", day="The day to clear") + async def puzzle_clear_solve( + self, interaction: discord.Interaction, user: discord.Member, day: int + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + c.execute( + "DELETE FROM puzzle_solves WHERE user_id=? AND day=?", (user.id, day) + ) + conn.commit() + conn.close() + await interaction.followup.send( + f"Cleared Day {day} solve for {user.display_name}.", ephemeral=True + ) + + @puzzle_group.command( + name="grant-role", + description="Manually assign a day's solver role (for webhook failures)", + ) + @app_commands.describe(user="The user", day="The day's solver role (0 = Puzzle Hunter)") + async def puzzle_grant_role( + self, interaction: discord.Interaction, user: discord.Member, day: int + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + c.execute("SELECT role_id FROM puzzle_roles WHERE day=?", (day,)) + row = c.fetchone() + conn.close() + + if not row: + await interaction.followup.send( + f"No role ID found for day={day}. Run the bot once to create roles.", + ephemeral=True, + ) + return + + role = interaction.guild.get_role(row[0]) + if role is None: + await interaction.followup.send( + f"Role ID {row[0]} not found in this guild.", ephemeral=True + ) + return + + try: + await user.add_roles(role, reason="Admin manual grant — 5th Anniversary Event") + await interaction.followup.send( + f"Granted **{role.name}** to {user.display_name}.", ephemeral=True + ) + except discord.Forbidden: + await interaction.followup.send( + "Missing permissions to assign that role.", ephemeral=True + ) + + @puzzle_group.command(name="reset-day", description="Clear all solve records for a day") + @app_commands.describe(day="Day number (1-7)") + async def puzzle_reset_day(self, interaction: discord.Interaction, day: int): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + if not 1 <= day <= 7: + await interaction.response.send_message("Day must be 1-7.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + c.execute("DELETE FROM puzzle_solves WHERE day=?", (day,)) + deleted = conn.total_changes + conn.commit() + conn.close() + await interaction.followup.send( + f"Cleared {deleted} solve record(s) for Day {day}.", ephemeral=True + ) + + @puzzle_group.command(name="reset-attempts", description="Clear a user's attempt count for a day") + @app_commands.describe(user="The user", day="Day number (1-7)") + async def puzzle_reset_attempts( + self, interaction: discord.Interaction, user: discord.Member, day: int + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + c.execute( + "DELETE FROM puzzle_attempts WHERE user_id=? AND day=?", (user.id, day) + ) + deleted = conn.total_changes + conn.commit() + conn.close() + await interaction.followup.send( + f"Cleared {deleted} attempt record(s) for {user.display_name} on Day {day}.", + ephemeral=True, + ) + + @puzzle_group.command( + name="unlink-check", + description="Check a user's Discord OAuth solve history (proxy for site auth status)", + ) + @app_commands.describe(user="The user to check") + async def puzzle_unlink_check( + self, interaction: discord.Interaction, user: discord.Member + ): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + conn = sqlite3.connect(PUZZLE_DB) + c = conn.cursor() + c.execute( + "SELECT day, solved_at FROM puzzle_solves WHERE user_id=? ORDER BY solved_at", + (user.id,), + ) + solves = c.fetchall() + c.execute( + "SELECT day, COUNT(*) FROM puzzle_attempts WHERE user_id=? GROUP BY day ORDER BY day", + (user.id,), + ) + attempts = c.fetchall() + conn.close() + + lines = [f"**{user.display_name}** (`{user.id}`)"] + if solves: + lines.append(f"\nSolves ({len(solves)}):") + for day, solved_at in solves: + lines.append(f" Day {day} — {solved_at[:19]} UTC") + else: + lines.append("\nNo solves recorded. If they submitted on the site, OAuth may have failed.") + + if attempts: + lines.append(f"\nAttempts by day:") + for day, count in attempts: + lines.append(f" Day {day} — {count} attempt(s)") + + embed = discord.Embed( + title="OAuth / Solve History", + description="\n".join(lines), + color=EVENT_COLOR, + ) + embed.set_footer( + text="Sessions are Redis-based/ephemeral — solve records are the reliable auth proxy." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + # ================================================================== # + # /admin system # + # ================================================================== # + + @system_group.command(name="health", description="Bot uptime, DB status, task loops, and site connectivity") + async def system_health(self, interaction: discord.Interaction): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + await interaction.response.defer(ephemeral=True) + + lines = [] + + # DB connectivity + for label, path in [("capsule.db", CAPSULE_DB), ("puzzle.db", PUZZLE_DB)]: + try: + conn = sqlite3.connect(path) + conn.execute("SELECT 1") + conn.close() + lines.append(f"`{label}` — OK") + except Exception as e: + lines.append(f"`{label}` — ERROR: {e}") + + # Task loop status + capsule_cog = self._capsule_cog() + puzzle_cog = self._puzzle_cog() + + capsule_running = ( + capsule_cog.daily_capsule_post.is_running() if capsule_cog else False + ) + reveal_running = ( + capsule_cog.reveal_capsule.is_running() if capsule_cog else False + ) + puzzle_running = ( + puzzle_cog.daily_puzzle_post.is_running() if puzzle_cog else False + ) + + lines.append(f"`daily_capsule_post` loop — {'running' if capsule_running else 'STOPPED'}") + lines.append(f"`reveal_capsule` loop — {'running' if reveal_running else 'STOPPED'}") + lines.append(f"`daily_puzzle_post` loop — {'running' if puzzle_running else 'STOPPED'}") + + # Site connectivity + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{SITE_URL}/lacie/", timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + lines.append( + f"Site `{SITE_URL}` — HTTP {resp.status}" + ) + except Exception as e: + lines.append(f"Site `{SITE_URL}` — UNREACHABLE: {e}") + + # Bot uptime approximation via first connected guild + lines.append(f"Guilds cached: {len(self.bot.guilds)}") + + embed = discord.Embed( + title="System Health", + description="\n".join(lines), + color=EVENT_COLOR, + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + @system_group.command( + name="schedule", + description="Preview the next N scheduled Discord posts with exact UTC times", + ) + @app_commands.describe(count="Number of upcoming posts to show (default 7)") + async def system_schedule(self, interaction: discord.Interaction, count: int = 7): + if not _check_owner(interaction): + await interaction.response.send_message("No.", ephemeral=True) + return + + cfg = _load_config() + post_h = cfg["post_time_utc"]["hour"] + post_m = cfg["post_time_utc"]["minute"] + start = date.fromisoformat(cfg["event_start_date"]) + end = date.fromisoformat(cfg["event_end_date"]) + reveal = date.fromisoformat(cfg["reveal_date"]) + now = datetime.now(timezone.utc) + + lines = [] + shown = 0 + + # Event week posts + for day_offset in range(7): + post_date = start + timedelta(days=day_offset) + post_dt = datetime( + post_date.year, post_date.month, post_date.day, + post_h, post_m, 0, tzinfo=timezone.utc, + ) + day_num = day_offset + 1 + status = "upcoming" if post_dt > now else "past" + lines.append( + f"`{post_dt.strftime('%Y-%m-%d %H:%M UTC')}` " + f"Day {day_num} capsule + puzzle post [{status}]" + ) + shown += 1 + if shown >= count: + break + + # Reveal + if shown < count: + reveal_dt = datetime( + reveal.year, reveal.month, reveal.day, + 17, 0, 0, tzinfo=timezone.utc, + ) + status = "upcoming" if reveal_dt > now else "past" + lines.append( + f"`{reveal_dt.strftime('%Y-%m-%d %H:%M UTC')}` " + f"Capsule reveal DMs [{status}]" + ) + + embed = discord.Embed( + title="Scheduled Posts", + description="\n".join(lines), + color=EVENT_COLOR, + ) + embed.set_footer(text="All times UTC \u00b7 noon EDT = 16:00 UTC") + await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot): + await bot.add_cog(AdminEvents(bot)) diff --git a/events/capsule.py b/events/capsule.py new file mode 100644 index 0000000..67d7da0 --- /dev/null +++ b/events/capsule.py @@ -0,0 +1,393 @@ +import json +import random +import sqlite3 +import discord +from discord.ext import commands, tasks +from discord import app_commands +from datetime import datetime, time, timezone, date +from pathlib import Path + +from embed.embed_color import get_embed_color +from utils.logger import get_logger + +logger = get_logger(__name__) + +CONFIG_PATH = Path(__file__).parent.parent / "capsule_puzzle_config.json" +DB_PATH = Path(__file__).parent.parent / "data" / "capsule.db" + +EVENT_COLOR = 0xB48EAD + + +def _load_config(): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def _event_day(cfg: dict) -> int | None: + """Return today's day number (1-7) if we're in the event window, else None.""" + today = date.today() + start = date.fromisoformat(cfg["event_start_date"]) + end = date.fromisoformat(cfg["event_end_date"]) + if start <= today <= end: + return (today - start).days + 1 + return None + + +# --------------------------------------------------------------------------- +# Modal +# --------------------------------------------------------------------------- + +class CapsuleModal(discord.ui.Modal): + def __init__(self, day: int, prompt: str, attachment_url: str | None): + super().__init__(title=f"Time Capsule \u2014 Day {day}") + self.day = day + self.attachment_url = attachment_url + self.response_input = discord.ui.TextInput( + label="Your response", + placeholder=prompt[:100], + style=discord.TextStyle.long, + max_length=1500, + required=True, + ) + self.add_item(self.response_input) + + async def on_submit(self, interaction: discord.Interaction): + response_text = self.response_input.value.strip() + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + try: + c.execute( + """ + INSERT INTO capsule_submissions + (user_id, day, response_text, image_url, submitted_at) + VALUES (?, ?, ?, ?, ?) + """, + ( + interaction.user.id, + self.day, + response_text, + self.attachment_url, + datetime.now(timezone.utc).isoformat(), + ), + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + await interaction.response.send_message( + "You've already submitted for today! Each day only allows one entry.", + ephemeral=True, + ) + return + conn.close() + + image_note = " Your image has been saved with your entry." if self.attachment_url else "" + await interaction.response.send_message( + f"Your Day {self.day} capsule entry has been sealed.{image_note}\n" + "Everything will be revealed on August 16, 2027. You can submit again tomorrow for the next prompt.", + ephemeral=True, + ) + logger.info( + f"Capsule submission: user={interaction.user.id} day={self.day}" + ) + + +# --------------------------------------------------------------------------- +# Cog +# --------------------------------------------------------------------------- + +class TimeCapsule(commands.Cog): + """Manages the 5th Anniversary Time Capsule event (Aug 16-22, 2026).""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.cfg = _load_config() + self._init_db() + self.daily_capsule_post.start() + self.reveal_capsule.start() + + async def cog_unload(self): + self.daily_capsule_post.cancel() + self.reveal_capsule.cancel() + + # ------------------------------------------------------------------ # + # Database # + # ------------------------------------------------------------------ # + + def _init_db(self): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS capsule_submissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + day INTEGER NOT NULL, + response_text TEXT NOT NULL, + image_url TEXT, + submitted_at TEXT NOT NULL, + UNIQUE(user_id, day) + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS capsule_event_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + def _get_state(self, key: str) -> str | None: + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT value FROM capsule_event_state WHERE key = ?", (key,)) + row = c.fetchone() + conn.close() + return row[0] if row else None + + def _set_state(self, key: str, value: str): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute( + "INSERT INTO capsule_event_state (key, value) VALUES (?, ?)" + " ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value), + ) + conn.commit() + conn.close() + + # ------------------------------------------------------------------ # + # Helpers # + # ------------------------------------------------------------------ # + + def _get_prompt(self, day: int) -> str: + """Return the prompt for a given day, checking admin overrides first.""" + override = self._get_state(f"capsule_prompt_override_{day}") + if override: + return override + + prompts = self.cfg["capsule_prompts"] + prompt = prompts[day - 1] + if prompt is None: + # Day 7 wildcard — pick once and persist so all submissions see the same question + stored = self._get_state("day7_wildcard") + if stored: + return stored + chosen = random.choice(self.cfg["wildcard_questions"]) + self._set_state("day7_wildcard", chosen) + return chosen + return prompt + + # ------------------------------------------------------------------ # + # Post helper (called by task + admin force-post) # + # ------------------------------------------------------------------ # + + async def _post_daily_capsule(self, day: int): + """Post the time-capsule prompt embed for the given day.""" + self.cfg = _load_config() + channel_id = self.cfg["capsule_channel_id"] + channel = self.bot.get_channel(channel_id) + if channel is None: + logger.error(f"TimeCapsule: channel {channel_id} not found") + return + + prompt = self._get_prompt(day) + day_labels = [ + "DAY 1 OF 7", "DAY 2 OF 7", "DAY 3 OF 7", "DAY 4 OF 7", + "DAY 5 OF 7", "DAY 6 OF 7", "DAY 7 OF 7", + ] + + embed = discord.Embed( + title=f"Time Capsule \u2014 {day_labels[day - 1]}", + description=( + f"{prompt}\n\n" + "Use `/capsule submit` to seal your response. " + "Attach an optional image with `/capsule submit image:`.\n\n" + "Entries are sealed until **August 16, 2027**. " + "Opt in to share a short public excerpt on the website when you submit." + ), + color=EVENT_COLOR, + ) + embed.set_footer(text="5th Anniversary Event \u00b7 Aug 16\u201322, 2026") + await channel.send(embed=embed) + logger.info(f"TimeCapsule: posted Day {day} prompt") + + # ------------------------------------------------------------------ # + # Reveal helper (called by task + admin force-reveal) # + # ------------------------------------------------------------------ # + + async def _reveal_for_user(self, user_id: int): + """DM a single user their full capsule.""" + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute( + "SELECT day, response_text, image_url FROM capsule_submissions" + " WHERE user_id = ? ORDER BY day", + (user_id,), + ) + rows = c.fetchall() + conn.close() + + if not rows: + return + + user = self.bot.get_user(user_id) + if user is None: + try: + user = await self.bot.fetch_user(user_id) + except Exception: + logger.warning(f"TimeCapsule reveal: could not fetch user {user_id}") + return + + prompts = self.cfg["capsule_prompts"] + embed = discord.Embed( + title="Your Time Capsule Has Been Opened", + description=( + "A year ago, you sealed a message to your future self.\n" + "Here's everything you wrote during the 5th Anniversary Event." + ), + color=EVENT_COLOR, + ) + for day_num, response_text, image_url in rows: + prompt_text = prompts[day_num - 1] + if prompt_text is None: + prompt_text = self._get_state("day7_wildcard") or "Wildcard" + field_name = f"Day {day_num} \u2014 {prompt_text[:50]}" + field_value = response_text[:1024] + if image_url: + field_value += f"\n[attached image]({image_url})" + embed.add_field(name=field_name, value=field_value, inline=False) + + embed.set_footer(text="5th Anniversary Event \u00b7 Opened August 16, 2027") + try: + await user.send(embed=embed) + logger.info(f"TimeCapsule reveal: DMed user {user_id}") + except discord.Forbidden: + logger.warning(f"TimeCapsule reveal: could not DM user {user_id} (DMs closed)") + except Exception as e: + logger.error(f"TimeCapsule reveal: error DMing {user_id}: {e}") + + # ------------------------------------------------------------------ # + # Scheduled tasks # + # ------------------------------------------------------------------ # + + @tasks.loop(time=time(hour=16, minute=0, tzinfo=timezone.utc)) + async def daily_capsule_post(self): + """Post the daily time-capsule prompt at noon EDT.""" + self.cfg = _load_config() + day = _event_day(self.cfg) + if day is None: + return + await self._post_daily_capsule(day) + + @tasks.loop(time=time(hour=17, minute=0, tzinfo=timezone.utc)) + async def reveal_capsule(self): + """On Aug 16, 2027, DM every participant their full capsule.""" + today = date.today() + reveal = date.fromisoformat(self.cfg["reveal_date"]) + if today != reveal: + return + + if self._get_state("reveal_done") == "1": + return + self._set_state("reveal_done", "1") + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT DISTINCT user_id FROM capsule_submissions") + user_ids = [row[0] for row in c.fetchall()] + conn.close() + + for user_id in user_ids: + await self._reveal_for_user(user_id) + + # Aggregate announcement + channel_id = self.cfg["capsule_channel_id"] + channel = self.bot.get_channel(channel_id) + if channel: + announce = discord.Embed( + title="The Time Capsules Have Been Opened", + description=( + f"One year ago, **{len(user_ids)}** members of this server sealed a time capsule.\n\n" + "Today, their messages have been delivered. Check your DMs to read yours.\n\n" + "Thank you for being part of this community. Here's to another year together." + ), + color=EVENT_COLOR, + ) + announce.set_footer(text="5th Anniversary Event \u00b7 Opened August 16, 2027") + await channel.send(embed=announce) + + @daily_capsule_post.before_loop + async def before_daily_capsule(self): + await self.bot.wait_until_ready() + + @reveal_capsule.before_loop + async def before_reveal(self): + await self.bot.wait_until_ready() + + # ------------------------------------------------------------------ # + # Slash commands # + # ------------------------------------------------------------------ # + + capsule_group = app_commands.Group( + name="capsule", + description="5th Anniversary Time Capsule commands", + ) + + @capsule_group.command( + name="submit", + description="Submit your entry for today's time capsule prompt", + ) + @app_commands.describe(image="Optional image to attach to your capsule entry") + async def capsule_submit( + self, interaction: discord.Interaction, image: discord.Attachment = None + ): + self.cfg = _load_config() + day = _event_day(self.cfg) + if day is None: + await interaction.response.send_message( + "The Time Capsule event isn't active right now (Aug 16-22, 2026).", + ephemeral=True, + ) + return + + if image is not None: + content_type = image.content_type or "" + if not content_type.startswith("image/"): + await interaction.response.send_message( + "That attachment doesn't look like an image. Please upload a PNG, JPG, or GIF.", + ephemeral=True, + ) + return + + prompt = self._get_prompt(day) + attachment_url = image.url if image else None + modal = CapsuleModal(day=day, prompt=prompt, attachment_url=attachment_url) + await interaction.response.send_modal(modal) + + @capsule_group.command( + name="count", + description="See how many people have submitted a capsule entry this week", + ) + async def capsule_count(self, interaction: discord.Interaction): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute("SELECT COUNT(DISTINCT user_id) FROM capsule_submissions") + total = c.fetchone()[0] + conn.close() + + embed = discord.Embed( + title="Time Capsule \u2014 Sealed So Far", + description=( + f"**{total}** {'person has' if total == 1 else 'people have'} " + "submitted at least one capsule entry this week.\n\n" + "Submit yours with `/capsule submit` before August 22!" + ), + color=get_embed_color(interaction.user.id), + ) + embed.set_footer(text="5th Anniversary Event \u00b7 Aug 16\u201322, 2026") + await interaction.response.send_message(embed=embed) + + +async def setup(bot: commands.Bot): + await bot.add_cog(TimeCapsule(bot)) diff --git a/events/ping_protect.py b/events/ping_protect.py index 2f87f25..d54d11c 100644 --- a/events/ping_protect.py +++ b/events/ping_protect.py @@ -83,6 +83,9 @@ class PingProtect(commands.GroupCog, name="noping"): return for user in message.mentions: + if user.id == message.author.id: + continue + member = message.guild.get_member(user.id) # Skip if the mentioned user has opted in to receiving pings diff --git a/events/puzzle.py b/events/puzzle.py new file mode 100644 index 0000000..7c82e12 --- /dev/null +++ b/events/puzzle.py @@ -0,0 +1,285 @@ +import json +import sqlite3 +import discord +from discord.ext import commands, tasks +from discord import app_commands +from datetime import datetime, time, timezone, date +from pathlib import Path + +from embed.embed_color import get_embed_color +from utils.constants import GUILD_ID +from utils.logger import get_logger + +logger = get_logger(__name__) + +CONFIG_PATH = Path(__file__).parent.parent / "capsule_puzzle_config.json" +DB_PATH = Path(__file__).parent.parent / "data" / "puzzle.db" + +EVENT_COLOR = 0xB48EAD + +DAILY_ROLE_TEMPLATE = "5th Anni Event - Day {day} Solver" +PUZZLE_HUNTER_ROLE_NAME = "5th Anni Event - Puzzle Hunter" + + +def _load_config(): + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def _event_day(cfg: dict) -> int | None: + """Return today's day number (1-7) if we're in the event window, else None.""" + today = date.today() + start = date.fromisoformat(cfg["event_start_date"]) + end = date.fromisoformat(cfg["event_end_date"]) + if start <= today <= end: + return (today - start).days + 1 + return None + + +def _puzzle_for_day(cfg: dict, day: int) -> dict | None: + for puzzle in cfg["puzzles"]: + if puzzle["day"] == day: + return puzzle + return None + + +class PuzzleHunt(commands.Cog): + """Manages the 5th Anniversary ARG Puzzle Hunt (Aug 16-22, 2026). + + Fragment submission and role granting happens entirely on the website. + This cog handles: daily lead-in Discord posts, leaderboard, and role + pre-creation so the site can look up role IDs from puzzle_roles. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.cfg = _load_config() + self._init_db() + self.daily_puzzle_post.start() + + async def cog_load(self): + """Async setup: create event roles and store IDs in puzzle_roles.""" + await self._ensure_roles() + + async def cog_unload(self): + self.daily_puzzle_post.cancel() + + # ------------------------------------------------------------------ # + # Database # + # ------------------------------------------------------------------ # + + def _init_db(self): + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + CREATE TABLE IF NOT EXISTS puzzle_solves ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + day INTEGER NOT NULL, + solved_at TEXT NOT NULL, + is_first_solver INTEGER DEFAULT 0, + UNIQUE(user_id, day) + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS puzzle_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + day INTEGER NOT NULL, + attempted_at TEXT NOT NULL + ) + """) + c.execute(""" + CREATE TABLE IF NOT EXISTS puzzle_roles ( + day INTEGER PRIMARY KEY, + role_id INTEGER NOT NULL + ) + """) + conn.commit() + conn.close() + + # ------------------------------------------------------------------ # + # Role helpers # + # ------------------------------------------------------------------ # + + async def _ensure_roles(self): + """Create all event roles if missing and persist their IDs to puzzle_roles.""" + guild = self.bot.get_guild(GUILD_ID) + if guild is None: + logger.error("PuzzleHunt._ensure_roles: guild not found") + return + + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + + # Day 1-7 solver roles + for day in range(1, 8): + name = DAILY_ROLE_TEMPLATE.format(day=day) + role = await self._get_or_create_role(guild, name) + if role: + c.execute( + "INSERT OR REPLACE INTO puzzle_roles (day, role_id) VALUES (?, ?)", + (day, role.id), + ) + + # Puzzle Hunter role stored at day=0 + hunter_role = await self._get_or_create_role(guild, PUZZLE_HUNTER_ROLE_NAME) + if hunter_role: + c.execute( + "INSERT OR REPLACE INTO puzzle_roles (day, role_id) VALUES (?, ?)", + (0, hunter_role.id), + ) + + conn.commit() + conn.close() + logger.info("PuzzleHunt: event roles ensured and IDs persisted to puzzle_roles") + + async def _get_or_create_role( + self, guild: discord.Guild, name: str + ) -> discord.Role | None: + role = discord.utils.get(guild.roles, name=name) + if role is None: + try: + role = await guild.create_role( + name=name, reason="5th Anniversary Event" + ) + logger.info(f"PuzzleHunt: created role '{name}'") + except discord.Forbidden: + logger.error( + f"PuzzleHunt: missing Manage Roles permission to create '{name}'" + ) + return None + except Exception as e: + logger.error(f"PuzzleHunt: failed to create role '{name}': {e}") + return None + return role + + # ------------------------------------------------------------------ # + # Scheduled task # + # ------------------------------------------------------------------ # + + async def _post_daily_puzzle(self, day: int): + """Post today's ARG lead-in message to #puzzle-hunt. Called by task and admin force-post.""" + self.cfg = _load_config() + puzzle = _puzzle_for_day(self.cfg, day) + if puzzle is None: + logger.error(f"PuzzleHunt: no puzzle config found for day {day}") + return + + channel_id = self.cfg["puzzle_channel_id"] + channel = self.bot.get_channel(channel_id) + if channel is None: + logger.error(f"PuzzleHunt: channel {channel_id} not found") + return + + page_url = puzzle["page_url"] + full_url = f"https://bots.lilacrose.dev{page_url}" + + embed = discord.Embed( + description=f"{puzzle['discord_message']}\n\n[investigate]({full_url})", + color=EVENT_COLOR, + ) + embed.set_footer( + text=f"5th Anniversary Puzzle Hunt \u00b7 Day {day} of 7 \u00b7 Aug 16-22, 2026" + ) + await channel.send(embed=embed) + logger.info(f"PuzzleHunt: posted Day {day} lead-in") + + @tasks.loop( + time=time(hour=16, minute=0, tzinfo=timezone.utc) # noon EDT (UTC-4) + ) + async def daily_puzzle_post(self): + self.cfg = _load_config() + day = _event_day(self.cfg) + if day is None: + return + await self._post_daily_puzzle(day) + + @daily_puzzle_post.before_loop + async def before_daily_puzzle(self): + await self.bot.wait_until_ready() + + # ------------------------------------------------------------------ # + # Slash commands # + # ------------------------------------------------------------------ # + + puzzle_group = app_commands.Group( + name="puzzle", + description="5th Anniversary Puzzle Hunt commands", + ) + + @puzzle_group.command( + name="today", + description="Show today's puzzle lead-in and link", + ) + async def puzzle_today(self, interaction: discord.Interaction): + self.cfg = _load_config() + day = _event_day(self.cfg) + if day is None: + await interaction.response.send_message( + "The Puzzle Hunt isn't active right now (Aug 16-22, 2026).", + ephemeral=True, + ) + return + + puzzle = _puzzle_for_day(self.cfg, day) + if puzzle is None: + await interaction.response.send_message( + "No puzzle found for today. Let a mod know!", ephemeral=True + ) + return + + page_url = puzzle["page_url"] + full_url = f"https://bots.lilacrose.dev{page_url}" + + embed = discord.Embed( + description=f"{puzzle['discord_message']}\n\n[investigate]({full_url})", + color=get_embed_color(interaction.user.id), + ) + embed.set_footer( + text=f"Day {day} of 7 \u00b7 5th Anniversary Puzzle Hunt" + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @puzzle_group.command( + name="leaderboard", + description="See who's solved the most puzzles this week", + ) + async def puzzle_leaderboard(self, interaction: discord.Interaction): + await interaction.response.defer() + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute(""" + SELECT user_id, COUNT(*) as solve_count + FROM puzzle_solves + GROUP BY user_id + ORDER BY solve_count DESC + LIMIT 15 + """) + rows = c.fetchall() + conn.close() + + if not rows: + await interaction.followup.send("No puzzles solved yet — be the first!") + return + + lines = [] + medals = ["\U0001f947", "\U0001f948", "\U0001f949"] + for i, (user_id, count) in enumerate(rows): + user = self.bot.get_user(user_id) + name = user.display_name if user else f"User {user_id}" + prefix = medals[i] if i < 3 else f"`{i + 1}.`" + plural = "puzzle" if count == 1 else "puzzles" + lines.append(f"{prefix} **{name}** — {count} {plural} solved") + + embed = discord.Embed( + title="Puzzle Hunt — Leaderboard", + description="\n".join(lines), + color=get_embed_color(interaction.user.id), + ) + embed.set_footer(text="5th Anniversary Event \u00b7 Aug 16-22, 2026") + await interaction.followup.send(embed=embed) + + +async def setup(bot: commands.Bot): + await bot.add_cog(PuzzleHunt(bot)) diff --git a/moderation/spam_protection.py b/moderation/spam_protection.py index 175a841..d682e75 100644 --- a/moderation/spam_protection.py +++ b/moderation/spam_protection.py @@ -5,6 +5,7 @@ from discord.ui import View, Button import asyncio from datetime import datetime, timedelta, timezone from collections import defaultdict, deque +from typing import NamedTuple import sqlite3 from pathlib import Path from utils.logger import get_logger @@ -28,6 +29,13 @@ INVITE_PATTERN = re.compile( re.IGNORECASE ) +class TrackedMessage(NamedTuple): + timestamp: datetime + channel_id: int + content: str + image_urls: tuple[str, ...] = () + file_names: tuple[str, ...] = () + class SpamProtection(commands.Cog): """Automatic spam detection and prevention system. @@ -166,7 +174,7 @@ class SpamProtection(commands.Cog): log_cog = self.bot.get_cog("Logger") if log_cog: - await log_cog.log_moderation_action( + await log_cog.log_moderation_action( # type: ignore[attr-defined] guild_id, "timeout", member, self.bot.user, "Spam protection - extended to 24h (no staff response)", "24h" ) @@ -188,6 +196,8 @@ class SpamProtection(commands.Cog): if message.author.bot or not message.guild: return + assert isinstance(message.author, discord.Member) + # 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: @@ -222,13 +232,20 @@ class SpamProtection(commands.Cog): async def _process_message(self, message: discord.Message): """Record the message in the user's history and check for spam patterns.""" + assert isinstance(message.author, discord.Member) and message.guild is not None now = datetime.now(timezone.utc) user_id = message.author.id - self.user_messages[user_id].append(( - now, - message.channel.id, - message.content[:200] + images = tuple ( + a.url for a in message.attachments + if (a.content_type or "").startswith("image/") + ) + files = tuple( + a.filename for a in message.attachments + if not (a.content_type or "").startswith("image/") + ) + self.user_messages[user_id].append(TrackedMessage( + now, message.channel.id, message.content[:200], images, files )) spam_detected = await self.check_spam_patterns(message.author, message.guild, message.content) @@ -284,8 +301,8 @@ class SpamProtection(commands.Cog): same_cutoff = now - timedelta(seconds=SAME_CHANNEL_WINDOW_SECONDS) recent_same = [msg for msg in messages if msg[0] >= same_cutoff] channel_counts = defaultdict(int) - for _, channel_id, _ in recent_same: - channel_counts[channel_id] += 1 + for msg in recent_same: + channel_counts[msg.channel_id] += 1 for channel_id, count in channel_counts.items(): if count >= SAME_CHANNEL_MIN_MESSAGES: @@ -390,7 +407,7 @@ class SpamProtection(commands.Cog): elif spam_data["type"] == "same_channel": ch = spam_data["channel"] - ch_ref = ch.mention if ch and isinstance(ch, discord.abc.Messageable) else f"<#{spam_data.get('channel_id', '?')}>" + ch_ref = ch.mention if ch and isinstance(ch, discord.abc.GuildChannel) else f"<#{spam_data.get('channel_id', '?')}>" embed.add_field( name="Spam Pattern", value=f"**{spam_data['count']} messages** in {ch_ref} within {SAME_CHANNEL_WINDOW_SECONDS}s", @@ -399,12 +416,15 @@ class SpamProtection(commands.Cog): # Sample messages sample_lines = [] - for timestamp, channel_id, content in spam_data["messages"][:5]: - time_str = timestamp.strftime("%H:%M:%S") - channel = guild.get_channel(channel_id) - ch_name = channel.mention if channel else f"<#{channel_id}>" - preview = content[:50] + "..." if len(content) > 50 else content - sample_lines.append(f"`[{time_str}]` {ch_name}: {preview}") + for msg in spam_data["messages"][:5]: + time_str = msg.timestamp.strftime("%H:%M:%S") + channel = guild.get_channel(msg.channel_id) + ch_name = channel.mention if channel else f"<#{msg.channel_id}>" + preview = msg.content[:50] + "..." if len(msg.content) > 50 else msg.content + attach_count = len(msg.image_urls) + len(msg.file_names) + if attach_count: + preview = f"{preview} [{attach_count} attachment(s)]".strip() + sample_lines.append(f"`[{time_str}]` {ch_name}: {preview or '*(no text)'}") if sample_lines: embed.add_field( name=f"Sample Messages", @@ -417,6 +437,28 @@ class SpamProtection(commands.Cog): value="⏱️ User timed out for **1 hour**\n⚠️ If no action in 12 hours, timeout extends to **24 hours**", inline=False ) + + image_urls = [u for msg in spam_data["messages"]for u in msg.image_urls] + file_names = [f for msg in spam_data["messages"] for f in msg.file_names] + + if image_urls: + embed.set_image(url=image_urls[0]) + if len(image_urls) > 1: + links = " • ".join( + f"[{i}]({u})" for i, u in enumerate(image_urls[1:], start=2) + ) + embed.add_field( + name=f"Other Images ({len(image_urls) - 1})", + value=links[:1024], + inline=False + ) + + if file_names: + embed.add_field( + name="Non-Image Attachments", + value=", ".join(f"`{n}`" for n in file_names[:10])[:1024], + inline=False + ) embed.set_thumbnail(url=member.display_avatar.url) embed.set_footer(text="Use buttons below to take action") @@ -457,14 +499,14 @@ class SpamActionView(View): 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): + def __init__(self, bot: commands.Bot, member: discord.Member, guild: discord.Guild, spam_data: dict, db_path: Path): super().__init__(timeout=43200) # 12 hours self.bot = bot self.member = member self.guild = guild self.spam_data = spam_data self.db_path = db_path - self.alert_message_id = None + self.alert_message_id: int | None = None async def _remove_from_pending(self): """Delete the spam_actions row so the escalation loop won't fire.""" @@ -503,7 +545,7 @@ class SpamActionView(View): log_cog = self.bot.get_cog("Logger") if log_cog: - await log_cog.log_moderation_action( + await log_cog.log_moderation_action( # type: ignore[attr-defined] self.guild.id, "untimeout", self.member, interaction.user, "Spam report determined to be false positive" ) @@ -542,7 +584,7 @@ class SpamActionView(View): log_cog = self.bot.get_cog("Logger") if log_cog: - await log_cog.log_moderation_action( + await log_cog.log_moderation_action( # type: ignore[attr-defined] self.guild.id, "timeout", self.member, interaction.user, "Spam confirmed — timeout extended to 24h", "24h" ) @@ -599,7 +641,7 @@ class SpamActionView(View): log_cog = self.bot.get_cog("Logger") if log_cog: - await log_cog.log_moderation_action( + await log_cog.log_moderation_action( # type: ignore[attr-defined] self.guild.id, "ban", self.member, interaction.user, reason ) @@ -618,7 +660,7 @@ class SpamActionView(View): class ConfirmView(View): """Generic ephemeral confirm/cancel view for spam action buttons.""" - def __init__(self, user: discord.User): + def __init__(self, user: discord.abc.User): super().__init__(timeout=30) self.user = user self.confirmed = False diff --git a/requirements.txt b/requirements.txt index a9ea240..df44157 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ mcrcon>=0.7.0 redis>=5.0 aiohttp==3.12.15 aiosqlite==0.21.0 +aioimaplib==2.0.1 discord.py==2.7.1 numpy==2.3.5 Pillow==12.0.0 diff --git a/stats/stats.py b/stats/stats.py index 9e35a57..404f24d 100644 --- a/stats/stats.py +++ b/stats/stats.py @@ -254,12 +254,12 @@ class Stats(commands.Cog): def get_top_channels(self, limit: int = 10) -> List[Tuple[str, int]]: db = sqlite3.connect(DB_PATH) cursor = db.cursor() - cursor.execute(f""" + cursor.execute(""" SELECT channel_id, message_count FROM message_stats ORDER BY message_count DESC - LIMIT {limit} - """) + LIMIT ? + """, (limit,)) results = cursor.fetchall() db.close() @@ -287,12 +287,12 @@ class Stats(commands.Cog): db = sqlite3.connect(DB_PATH) cursor = db.cursor() if limit > 0: - cursor.execute(f""" + cursor.execute(""" SELECT word, count FROM word_frequency ORDER BY count DESC - LIMIT {limit} - """) + LIMIT ? + """, (limit,)) else: cursor.execute(""" SELECT word, count