feat: mail cog, spam image tracking, whitelist pagination, misc fixes

- Add mail cog scaffold (aioimaplib dep, mail/ gitignored)
- spam_protection: TrackedMessage NamedTuple with image/file tracking, show attachments in spam alert embed
- whitelist: paginate /whitelist list with prev/next buttons
- ping_protect: skip self-pings
- salt: always block target user instead of 1% chance easter egg
- stats: parameterize SQL LIMIT queries to prevent injection
- New event files: admin_events, capsule, puzzle
This commit is contained in:
Lilac-Rose
2026-07-26 18:59:18 +02:00
parent 11157adf39
commit abc26515f3
11 changed files with 1472 additions and 61 deletions

3
.gitignore vendored
View File

@@ -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/

1
bot.py
View File

@@ -36,6 +36,7 @@ COG_FOLDERS = [
"wordle",
"reminders",
"arg",
"mail",
]
# --- Bot setup ---

View File

@@ -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")

View File

@@ -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 ─────────────────────────────────────────────────────────

648
events/admin_events.py Normal file
View File

@@ -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))

393
events/capsule.py Normal file
View File

@@ -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))

View File

@@ -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

285
events/puzzle.py Normal file
View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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