mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-08-22 00:35:05 -05:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
1
bot.py
@@ -36,6 +36,7 @@ COG_FOLDERS = [
|
||||
"wordle",
|
||||
"reminders",
|
||||
"arg",
|
||||
"mail",
|
||||
]
|
||||
|
||||
# --- Bot setup ---
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
648
events/admin_events.py
Normal 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
393
events/capsule.py
Normal 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))
|
||||
@@ -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
285
events/puzzle.py
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user