feat: mod logging overhaul, bug fixes, minesweeper fix, emote credits pending view

Moderation & logging:
- cleanban now collects all messages before banning and attaches them as a
  .txt audit log file via the member_ban log channel
- purgememberall restricted to server owner only (nuke protection)
- unban, lock, unlock, spam ban-button, and manual infraction deletes all
  now write to the DB infractions table and post to the mod log
- new log types (channel_lock, infraction_modify, cleanban) added and seeded
  to route to the main mod log channel (982644273960873994)

Bug fixes:
- unban: mod log no longer fires on failure (missing return added)
- mute: fixed DB connection leaks and silent unmute failures in
  schedule_unmute and check_mutes (try/finally + proper error logging)
- suggest: fixed AttributeError crash when original_embed is None and
  IndexError risk on set_field_at calls without field count guards
- ban/cleanban: removed isinstance(user, discord.User) guard that silently
  prevented DMs from ever being sent to in-server members
- cleanban: wrapped collect_status.delete() in try/except
- commands/infractions: fixed DB connection leak with try/finally
- events/botban: silent ban failure now logs error instead of swallowing it

Minesweeper:
- track detonated_cell on mine hit; show 💥 on the clicked mine and 💣
  on all others when the board is revealed on loss

Emote credits:
- new /emote_credits_pending command: shows the user their own unreviewed
  submissions; admins can pass all:True to see the full pending queue

Code quality:
- comprehensive docstrings and inline comments added across all cogs,
  events, commands, utils, and supporting modules
This commit is contained in:
Lilac-Rose
2026-06-16 15:01:11 +02:00
parent 4a06d80054
commit 971ec5c849
56 changed files with 3112 additions and 669 deletions

20
bot.py
View File

@@ -54,6 +54,13 @@ bot.tree.add_command(xp_admin_group)
# --- Cog loading ---
async def load_cogs(folder: str):
"""Load (or reload) every cog .py file in the given folder.
Skips utility modules listed in non_cog_files (they have no setup()
function) and any cogs in disabled_cogs (unloads them if they were
previously loaded, then skips). Uses reload_extension when a module is
already registered so hot-reloading works without a full restart.
"""
non_cog_files = {"add_xp.py", "database.py", "utils.py", "__init__.py", "groups.py", "loader.py", "constants.py"}
disabled_cogs = {"archipelago_monitor.py"}
for file in glob.glob(f"{folder}/*.py"):
@@ -81,6 +88,12 @@ async def load_cogs(folder: str):
# --- Events and commands ---
@bot.event
async def on_ready():
"""Run startup tasks after the bot connects and its cache is populated.
Verifies XP and sparkle database connectivity, loads all cog folders,
then syncs slash commands globally. Runs every time the bot (re)connects,
so load_cogs is written to be idempotent via reload_extension.
"""
logger.info(f"Logged in as {bot.user}!")
for lifetime in (True, False):
@@ -111,6 +124,7 @@ async def on_ready():
@bot.event
async def on_command_error(ctx, error):
"""Suppress CommandNotFound silently; log all other prefix-command errors."""
if isinstance(error, commands.CommandNotFound):
return
logger.error(f"Command error: {error}", exc_info=True)
@@ -130,6 +144,11 @@ async def reload(ctx):
@bot.event
async def on_message(message):
"""Award XP for every non-bot message and then process prefix commands.
XP errors are caught and logged rather than propagated so a broken XP
system never prevents prefix commands from running.
"""
if message.author.bot:
return
try:
@@ -140,6 +159,7 @@ async def on_message(message):
# --- Entry point ---
async def main():
"""Start the bot using the TOKEN from the environment (.env file)."""
try:
async with bot:
await bot.start(TOKEN)

View File

@@ -4,24 +4,32 @@ from discord.ext import commands
import os
from pathlib import Path
class Bonk(commands.Cog):
"""Cog providing the /bonk slash command."""
def __init__(self, bot):
self.bot = bot
# resolve path at init so we're not doing it on every command call
# Resolve path at init so we're not doing it on every command call
self.bonk_path = Path(__file__).parent.parent / "media" / "kat_bonk.png"
@app_commands.command(name="bonk", description="Bonk another user!")
@app_commands.describe(user="The user you want to bonk")
async def bonk(self, interaction: discord.Interaction, user: discord.User):
"""Send the bonk image mentioning both the caller and the target.
Prevents self-bonking. Defers before sending because the response
includes a file attachment.
"""
try:
# defer because we're sending a file attachment
# Defer because we're sending a file attachment
await interaction.response.defer(thinking=True)
if not os.path.exists(self.bonk_path):
await interaction.followup.send("Bonk image not found!", ephemeral=True)
return
# no self-bonking
# No self-bonking
if user.id == interaction.user.id:
await interaction.followup.send("You cannot bonk yourself silly", ephemeral=False)
return
@@ -37,5 +45,6 @@ class Bonk(commands.Cog):
await interaction.followup.send("An error occurred while processing the bonk.", ephemeral=True)
raise e
async def setup(bot: commands.Bot):
await bot.add_cog(Bonk(bot))

View File

@@ -4,11 +4,14 @@ from discord import app_commands
import random
class Coinflip(commands.Cog):
"""Cog providing the /coinflip slash command."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(name="coinflip")
async def coinflip(self, interaction: discord.Interaction):
"""Flip a virtual coin and respond with Heads or Tails."""
# 0 = tails, 1 = heads
coin = random.randrange(0,2)
if coin == 1:

View File

@@ -5,12 +5,24 @@ import random
import re
class DiceRoll(commands.Cog):
"""Cog providing the /diceroll slash command."""
def __init__(self, bot):
self.bot = bot
@app_commands.command(name="diceroll", description="Roll dice, supported die are d4, d6, d8, d10, d12, d20, d100")
@app_commands.describe(dice="Enter your roll (e.g., d20, 2d6+3, 3d8-2)")
async def diceroll(self, interaction: discord.Interaction, dice: str):
"""Parse a dice string in NdS±M format, roll the dice, and report individual rolls and total.
Parameters
----------
dice:
A dice expression such as ``d20``, ``2d6+3``, or ``3d8-2``.
N (number of dice) is optional and defaults to 1.
Only standard tabletop die sizes are accepted (d4 through d100).
A maximum of 100 dice may be rolled at once.
"""
# expects NdS+M format — num dice (optional), die sides, optional +/- modifier
pattern = r"^(\d*)d(\d+)([+-]\d+)?$"
match = re.match(pattern, dice.replace(" ", "").lower())

View File

@@ -9,17 +9,40 @@ from typing import Optional
from embed.embed_color import get_embed_color
class EmoteCredits(ModerationBase, commands.Cog):
"""Cog for tracking and querying emote/sticker artist credits.
Users submit credits via /emote_credits_add; submissions are routed to an
approval channel where admins accept or deny them via button views. Credits
are stored in emote_credits.db and queried via /emote_credit and related
slash commands.
Slash commands:
- ``/emote_credit`` — Look up the artist for a specific emote/sticker.
- ``/emote_credits_add`` — Submit a new credit for admin approval.
- ``/emote_credits_update`` — Submit a correction to an existing credit.
- ``/emote_artists`` — List all credited artists ranked by count.
- ``/emote_by_artist`` — View all emotes credited to a specific artist.
- ``/missing_credits`` — (admin) List uncredited emotes/stickers.
- ``/emote_credits_resolve`` — (admin) Convert raw @username credits to mentions.
"""
def __init__(self, bot):
super().__init__(bot)
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "emote_credits.db"
# Channel where new credit submissions are posted for admin review
self.approval_channel_id = 1470441786810826884
async def cog_load(self):
"""Initialise the database tables on cog load."""
await self._init_db()
async def _init_db(self):
"""Initialize the database"""
"""Create emote_credits and pending_credits tables if they don't exist.
Also migrates older DB schemas by adding ``is_update`` and ``old_artist``
columns to pending_credits if they are missing (safe to call repeatedly).
"""
async with aiosqlite.connect(self.db_path) as conn:
# stores finalized credits
await conn.execute("""
@@ -55,7 +78,15 @@ class EmoteCredits(ModerationBase, commands.Cog):
await conn.commit()
async def get_credit(self, emote_name: str):
"""Get credit for an emote from database"""
"""Return the credited artist for the given emote name, or None if not found.
The lookup is case-insensitive.
Parameters
----------
emote_name:
The emote or sticker name to look up.
"""
async with aiosqlite.connect(self.db_path) as conn:
async with conn.execute(
"SELECT artist FROM emote_credits WHERE LOWER(emote_name) = LOWER(?)",
@@ -65,10 +96,21 @@ class EmoteCredits(ModerationBase, commands.Cog):
return result[0] if result else None
async def cog_unload(self):
"""No-op cleanup; aiosqlite connections are opened/closed per-query."""
pass
async def add_credit(self, emote_name: str, artist: str, added_by: Optional[int] = None):
"""Add a credit to the database"""
"""Insert or replace a credit record in the emote_credits table.
Parameters
----------
emote_name:
The emote or sticker name to credit.
artist:
The artist to credit.
added_by:
Discord user ID of the moderator approving the credit, or None.
"""
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute(
"INSERT OR REPLACE INTO emote_credits (emote_name, artist, added_by) VALUES (?, ?, ?)",
@@ -77,14 +119,26 @@ class EmoteCredits(ModerationBase, commands.Cog):
await conn.commit()
async def get_all_credits(self):
"""Get all credits from database"""
"""Return a set of all credited emote names (lowercased) for fast membership checks."""
async with aiosqlite.connect(self.db_path) as conn:
async with conn.execute("SELECT emote_name FROM emote_credits") as cursor:
results = {row[0].lower() async for row in cursor}
return results
def parse_emoji_name(self, emote_input: str) -> str:
"""Extract emoji name from Discord emoji format or return as-is"""
"""Extract the emoji name from a Discord emoji tag, or return the input unchanged.
Parameters
----------
emote_input:
Either a Discord emoji tag like ``<:name:id>`` / ``<a:name:id>``,
or a plain text name.
Returns
-------
str
The extracted name portion, or the original string if no tag was found.
"""
# Discord custom emojis look like <:name:id> or <a:name:id> for animated
emoji_pattern = r'<a?:([^:]+):\d+>'
match = re.match(emoji_pattern, emote_input)
@@ -96,6 +150,16 @@ class EmoteCredits(ModerationBase, commands.Cog):
@app_commands.command(name="emote_credit", description="Find out who created a specific emoji or sticker")
@app_commands.describe(emote="The emoji or sticker name (you can type the emoji directly!)")
async def emote_credit(self, interaction: discord.Interaction, emote: str):
"""Look up the credited artist for a server emoji or sticker.
Accepts either a raw emoji tag (``<:name:id>``) or a plain text name.
Responds publicly on success, ephemerally on failure to keep the channel clean.
Parameters
----------
emote:
The emoji tag or name to query.
"""
await interaction.response.defer()
emoji_name = self.parse_emoji_name(emote)
@@ -124,6 +188,20 @@ class EmoteCredits(ModerationBase, commands.Cog):
artist="The artist's username (e.g., @username)"
)
async def emote_credits_add(self, interaction: discord.Interaction, emote: str, artist: str):
"""Submit a new artist credit for staff approval.
Rejects the submission immediately if a credit already exists for the
emote. Otherwise inserts a pending record and posts an approval embed
with Accept/Deny buttons to the approval channel. The submitter is DM'd
with the outcome when staff decide.
Parameters
----------
emote:
The emoji tag or name to credit.
artist:
The artist's name or Discord mention.
"""
await interaction.response.defer(ephemeral=True)
emoji_name = self.parse_emoji_name(emote)
@@ -189,6 +267,19 @@ class EmoteCredits(ModerationBase, commands.Cog):
artist="The corrected artist name"
)
async def emote_credits_update(self, interaction: discord.Interaction, emote: str, artist: str):
"""Submit a correction to an existing emote credit for staff approval.
Requires that a credit already exists; use ``/emote_credits_add`` for new
entries. Submits to the approval channel with the old and new artist names
visible side by side so staff can compare before deciding.
Parameters
----------
emote:
The emoji tag or name whose credit needs correcting.
artist:
The corrected artist name.
"""
await interaction.response.defer(ephemeral=True)
emoji_name = self.parse_emoji_name(emote)
@@ -253,8 +344,84 @@ class EmoteCredits(ModerationBase, commands.Cog):
)
await interaction.followup.send(embed=embed, ephemeral=True)
@app_commands.command(name="emote_credits_pending", description="View pending emote credit submissions awaiting approval")
@app_commands.describe(all="[Admin] Show all pending submissions from everyone, not just your own")
async def emote_credits_pending(self, interaction: discord.Interaction, all: bool = False):
"""Show pending (not yet approved) credit submissions.
Without arguments, shows only the invoking user's own pending submissions.
Passing ``all=True`` is admin-only and shows the full pending queue for
all users, sorted by submission date.
Parameters
----------
all:
If True (admin only), list every pending submission regardless of submitter.
"""
await interaction.response.defer(ephemeral=True)
if all and not await self._check_admin(interaction):
await interaction.followup.send("❌ Only admins can view all pending submissions.", ephemeral=True)
return
async with aiosqlite.connect(self.db_path) as conn:
if all:
async with conn.execute(
"SELECT id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist "
"FROM pending_credits ORDER BY submitted_at ASC"
) as cursor:
rows = await cursor.fetchall()
else:
async with conn.execute(
"SELECT id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist "
"FROM pending_credits WHERE submitted_by = ? ORDER BY submitted_at ASC",
(interaction.user.id,)
) as cursor:
rows = await cursor.fetchall()
if not rows:
msg = "There are no pending submissions." if all else "You have no pending submissions."
await interaction.followup.send(msg, ephemeral=True)
return
lines = []
for row_id, emote_name, artist, submitted_by, submitted_at, is_update, old_artist in rows:
submitter_str = f"<@{submitted_by}>" if all else ""
kind = "update" if is_update else "new"
if is_update and old_artist:
detail = f"`{emote_name}`: {old_artist} → **{artist}** ({kind})"
else:
detail = f"`{emote_name}` by **{artist}** ({kind})"
date_str = submitted_at[:10] if submitted_at else "?"
line = f"{detail} — submitted {date_str}"
if all:
line += f" by {submitter_str}"
lines.append(line)
title = f"🕐 All Pending Submissions ({len(rows)})" if all else f"🕐 Your Pending Submissions ({len(rows)})"
chunks = [lines[i:i+20] for i in range(0, len(lines), 20)]
for i, chunk in enumerate(chunks):
embed = discord.Embed(
title=f"{title} — Page {i+1}/{len(chunks)}" if len(chunks) > 1 else title,
description="\n".join(chunk),
color=get_embed_color(interaction.user.id)
)
embed.set_footer(text="Submissions are reviewed by admins in the approval channel.")
await interaction.followup.send(embed=embed, ephemeral=True)
async def _check_admin(self, interaction: discord.Interaction) -> bool:
"""Return True if the interaction user passes the is_admin check."""
if not interaction.guild or not isinstance(interaction.user, discord.Member):
return False
return interaction.user.guild_permissions.administrator
@app_commands.command(name="emote_artists", description="List all artists who have credited emotes or stickers")
async def emote_artists(self, interaction: discord.Interaction):
"""List all credited artists, sorted by emote count descending.
Results are paginated into embeds of 20 entries each and sent as
sequential followup messages (Discord enforces a 10-embed-per-send cap).
"""
await interaction.response.defer()
async with aiosqlite.connect(self.db_path) as conn:
@@ -288,6 +455,18 @@ class EmoteCredits(ModerationBase, commands.Cog):
@app_commands.command(name="emote_by_artist", description="View all emotes and stickers credited to a specific artist")
@app_commands.describe(artist="The artist's name, @mention, or user ID")
async def emote_by_artist(self, interaction: discord.Interaction, artist: str):
"""Show all emotes credited to the given artist, with inline emoji previews.
Performs a multi-strategy lookup to handle credits stored as plain text
names, @user_id mentions, or usernames. Falls back to fetching member
names from the Discord gateway and the API when the in-memory cache misses.
Offers partial-match suggestions if no exact artist is found.
Parameters
----------
artist:
Artist name, @mention, or Discord user ID to search for.
"""
await interaction.response.defer()
# Build a list of search terms to try. Credits may be stored as plain
@@ -488,6 +667,12 @@ class EmoteCredits(ModerationBase, commands.Cog):
@app_commands.command(name="emote_credits_resolve", description="[Admin] Auto-convert @username credits to user mentions where possible")
@ModerationBase.is_admin()
async def emote_credits_resolve(self, interaction: discord.Interaction):
"""Batch-convert raw ``@username`` artist strings to ``<@user_id>`` mentions (admin only).
Chunks the guild member list and matches case-insensitively against
username, global_name, and display_name. Reports resolved and unresolved
entries; unresolved entries are left unchanged.
"""
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if not guild:
@@ -551,7 +736,7 @@ class EmoteCredits(ModerationBase, commands.Cog):
@commands.command(name="missing_credits")
@ModerationBase.is_admin()
async def missing_credits_text(self, ctx):
"""List all server emotes and stickers that don't have credits"""
"""Prefix-command mirror of /missing_credits — list uncredited emotes and stickers."""
if not ctx.guild:
return
@@ -620,6 +805,13 @@ class EmoteCredits(ModerationBase, commands.Cog):
class CreditApprovalView(discord.ui.View):
"""Persistent button view for approving or denying emote credit submissions.
Uses timeout=None so buttons remain functional after bot restarts.
Both Approve and Deny remove the pending record from the DB and notify
the original submitter via DM.
"""
def __init__(self, cog, submission_id, emote_name, artist, submitted_by, is_update=False, old_artist=None):
super().__init__(timeout=None)
self.cog = cog
@@ -632,6 +824,7 @@ class CreditApprovalView(discord.ui.View):
@discord.ui.button(label="Approve", style=discord.ButtonStyle.green, custom_id="approve_credit")
async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Approve the submission — write to emote_credits table and DM the submitter."""
# Add to database
await self.cog.add_credit(self.emote_name, self.artist, interaction.user.id)
@@ -676,6 +869,7 @@ class CreditApprovalView(discord.ui.View):
@discord.ui.button(label="Deny", style=discord.ButtonStyle.red, custom_id="deny_credit")
async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Deny the submission — remove from pending table and DM the submitter."""
# Remove from pending
async with aiosqlite.connect(self.cog.db_path) as conn:
await conn.execute("DELETE FROM pending_credits WHERE id = ?", (self.submission_id,))

View File

@@ -40,11 +40,14 @@ FAQ_ITEMS = [
class FAQ(commands.Cog):
"""Cog providing the /faq slash command."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(name="faq", description="View frequently asked questions")
async def faq(self, interaction: discord.Interaction):
"""Display all FAQ entries in a single embed using the caller's accent colour."""
embed = discord.Embed(
title="Frequently Asked Questions",
color=get_embed_color(interaction.user.id),

View File

@@ -13,6 +13,14 @@ from utils.logger import get_logger
logger = get_logger(__name__)
class ColorImageGen(commands.Cog):
"""Cog providing the !generateimages command (admin only).
Renders a color preview image listing every role in COLOR_ROLE_NAMES with
its Discord-assigned color, saves it to media/colorimage.png, and sends a
preview to the command channel. Configuration is hot-reloaded from
role_color.py each time the command runs.
"""
def __init__(self, bot):
self.bot = bot
# Import and get fresh references on initialization
@@ -35,6 +43,13 @@ class ColorImageGen(commands.Cog):
@commands.command(name="generateimages")
@ModerationBase.is_admin()
async def generate_list(self, ctx):
"""Regenerate the color role preview image and send it to the current channel.
Reloads role_color.py config on each run so changes made after startup
are picked up without restarting the bot. If any role in COLOR_ROLE_NAMES
does not exist in the guild, the command aborts with an error message.
The generated PNG is saved to media/colorimage.png and also sent inline.
"""
# Reload config to get latest values in case role_color.py changed since startup
self._reload_config()

View File

@@ -24,11 +24,18 @@ GIVEAWAY_EMOJI = "🎉"
def _is_admin(user: discord.Member) -> bool:
"""Return True if the member is the bot owner or holds an admin role."""
from utils.constants import LILAC_ID
return user.id == LILAC_ID or any(r.id in ADMIN_ROLE_IDS for r in user.roles)
class GiveawayRollAgainView(discord.ui.View):
"""Persistent button view attached to ended giveaway messages.
Uses timeout=None so it stays active after bot restarts. The custom_id
includes the giveaway ID so the view can be re-registered on startup.
"""
def __init__(self, bot: commands.Bot, giveaway_id: int):
super().__init__(timeout=None)
self.bot = bot
@@ -42,6 +49,7 @@ class GiveawayRollAgainView(discord.ui.View):
self.add_item(btn)
async def _roll_callback(self, interaction: discord.Interaction):
"""Re-roll the giveaway and announce a new winner (admin only)."""
if not isinstance(interaction.user, discord.Member) or not _is_admin(interaction.user):
return await interaction.response.send_message(
"Only admins can re-roll a giveaway.", ephemeral=True
@@ -101,11 +109,20 @@ class GiveawayRollAgainView(discord.ui.View):
class GiveawayCog(commands.Cog):
"""Cog providing the /giveaway start slash command and background expiry task.
Giveaways are persisted to giveaways.db. A background task polls every 30
seconds for past-deadline entries and calls _end_giveaway to pick a winner
and post the result. Roll Again views are re-registered on startup so the
button remains functional after restarts.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "giveaways.db"
async def cog_load(self):
"""Set up DB, re-register persistent views, and start the check loop."""
await self._setup_db()
# Re-register persistent roll-again views for already-ended giveaways so
# buttons continue to work after a restart.
@@ -119,9 +136,11 @@ class GiveawayCog(commands.Cog):
self.check_giveaways.start()
async def cog_unload(self):
"""Cancel the background task when the cog unloads."""
self.check_giveaways.cancel()
async def _setup_db(self):
"""Create the giveaways table if it doesn't exist."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
@@ -160,6 +179,21 @@ class GiveawayCog(commands.Cog):
deadline: str,
timezone: Optional[str] = None,
):
"""Start a giveaway with a prize and deadline.
Parses the deadline as a relative duration or an absolute datetime string.
Persists the giveaway before posting so the ID is available for the embed
footer and the message ID can be stored back after the post.
Parameters
----------
prize:
What is being given away.
deadline:
Duration like ``2h``, ``3d``, or a natural date/time string.
timezone:
Optional timezone name for interpreting absolute deadline strings.
"""
# Parse deadline — try duration first, then datetime string
try:
delta = parse_timeframe(deadline)
@@ -172,7 +206,7 @@ class GiveawayCog(commands.Cog):
unix_ts = int(ends_at.timestamp())
# Persist before posting so we have an ID
# Persist before posting so we have an ID for the embed footer
async with aiosqlite.connect(self.db_path) as db:
cur = await db.execute(
"""
@@ -199,7 +233,7 @@ class GiveawayCog(commands.Cog):
await interaction.response.send_message(embed=embed)
msg = await interaction.original_response()
# Add the entry reaction
# Add the entry reaction so users know what to click
await msg.add_reaction(GIVEAWAY_EMOJI)
# Store the message ID so the task can fetch reactions later
@@ -214,6 +248,7 @@ class GiveawayCog(commands.Cog):
@tasks.loop(seconds=30)
async def check_giveaways(self):
"""Poll every 30 seconds for giveaways whose deadline has passed."""
now = datetime.now(dt_timezone.utc)
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
@@ -233,6 +268,22 @@ class GiveawayCog(commands.Cog):
message_id: int | None,
prize: str,
):
"""End a giveaway, pick a winner from reaction users, and announce the result.
Marks the giveaway as ended immediately to prevent double-firing if the
loop tick overlaps. Handles the no-participants edge case gracefully.
Parameters
----------
giveaway_id:
DB ID of the giveaway to end.
channel_id:
Discord channel where the giveaway was posted.
message_id:
ID of the original giveaway message (for fetching reactions).
prize:
Prize text to include in the result embed.
"""
# Mark ended immediately to prevent double-firing
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
@@ -293,6 +344,7 @@ class GiveawayCog(commands.Cog):
@check_giveaways.before_loop
async def before_check_giveaways(self):
"""Wait until the bot is ready before the polling loop starts."""
await self.bot.wait_until_ready()

View File

@@ -92,6 +92,7 @@ HELP_PAGES = {
def build_overview_embed(color: discord.Color) -> discord.Embed:
"""Build the top-level overview embed listing all categories and command counts."""
embed = discord.Embed(
title="Lacie Help",
description="Select a category below to see available commands.",
@@ -104,6 +105,7 @@ def build_overview_embed(color: discord.Color) -> discord.Embed:
def build_category_embed(category: str, color: discord.Color) -> discord.Embed:
"""Build a detail embed listing every command in a category."""
embed = discord.Embed(title=f"{category}", color=color)
for command, description in HELP_PAGES[category]:
embed.add_field(name=command, value=description, inline=False)
@@ -111,6 +113,8 @@ def build_category_embed(category: str, color: discord.Color) -> discord.Embed:
class HelpSelect(discord.ui.Select):
"""Dropdown that switches between the overview and per-category embeds."""
def __init__(self, embed_color: discord.Color):
self.embed_color = embed_color
options = [discord.SelectOption(label="Overview", value="__overview__")] + [
@@ -120,6 +124,7 @@ class HelpSelect(discord.ui.Select):
super().__init__(placeholder="Select a category...", options=options)
async def callback(self, interaction: discord.Interaction):
"""Swap the embed for the selected category (or back to overview)."""
if self.values[0] == "__overview__":
embed = build_overview_embed(self.embed_color)
else:
@@ -128,27 +133,33 @@ class HelpSelect(discord.ui.Select):
class HelpView(discord.ui.View):
"""View wrapping the HelpSelect dropdown. Times out after 2 minutes of inactivity."""
def __init__(self, color: discord.Color):
super().__init__(timeout=120)
self.add_item(HelpSelect(embed_color=color))
class Help(commands.Cog):
"""Cog providing both the /help slash command and the !help prefix command."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(name="help", description="View all available commands")
async def help_slash(self, interaction: discord.Interaction):
"""Show the interactive help menu via slash command."""
color = get_embed_color(interaction.user.id)
embed = build_overview_embed(color)
await interaction.response.send_message(embed=embed, view=HelpView(color))
@commands.command(name="help")
async def help_prefix(self, ctx: commands.Context):
"""Show the interactive help menu via prefix command."""
color = get_embed_color(ctx.author.id)
embed = build_overview_embed(color)
await ctx.send(embed=embed, view=HelpView(color))
async def setup(bot: commands.Bot):
await bot.add_cog(Help(bot))
await bot.add_cog(Help(bot))

View File

@@ -6,7 +6,12 @@ from pathlib import Path
class InfractionsCommand(commands.Cog):
"""Command for users to view their own infractions"""
"""Cog providing the /infractions slash command for self-service infraction lookup.
Results are sent via DM so the user's infraction history is not exposed
publicly. The command defers ephemerally before fetching so the bot can
take more than 3 seconds without timing out.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@@ -14,7 +19,8 @@ class InfractionsCommand(commands.Cog):
@app_commands.command(name="infractions", description="View your infractions in this server")
async def infractions(self, interaction: discord.Interaction):
# send via DM so it doesn't expose their infraction history publicly
"""Fetch the caller's active infractions and deliver them via DM."""
# Defer ephemerally — DB read may take a moment
await interaction.response.defer(ephemeral=True)
if not interaction.guild:
@@ -23,18 +29,20 @@ class InfractionsCommand(commands.Cog):
try:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
try:
c = conn.cursor()
# only show active (not removed) infractions for this user in this guild
c.execute("""
SELECT id, type, reason, timestamp
FROM infractions
WHERE user_id=? AND guild_id=? AND removed=0
ORDER BY timestamp DESC
""", (interaction.user.id, interaction.guild.id))
# Only show active (not removed) infractions for this user in this guild
c.execute("""
SELECT id, type, reason, timestamp
FROM infractions
WHERE user_id=? AND guild_id=? AND removed=0
ORDER BY timestamp DESC
""", (interaction.user.id, interaction.guild.id))
results = c.fetchall()
conn.close()
results = c.fetchall()
finally:
conn.close()
if not results:
await interaction.followup.send("You have no active infractions in this server.", ephemeral=True)
@@ -44,7 +52,7 @@ class InfractionsCommand(commands.Cog):
rows = []
for row in results:
inf_id, inf_type, reason, timestamp = row
# strip the T and milliseconds out of the ISO timestamp for readability
# Strip the T and milliseconds out of the ISO timestamp for readability
timestamp_formatted = timestamp.replace("T", " ")[:19]
reason_text = reason or "None"
@@ -55,12 +63,12 @@ class InfractionsCommand(commands.Cog):
"reason": reason_text
})
# auto-size each column to the widest value in it
# Auto-size each column to the widest value in it
widths = {key: max(len(key), *(len(r[key]) for r in rows)) for key in rows[0].keys()}
header = " | ".join(f"{key.capitalize():{widths[key]}}" for key in rows[0].keys())
separator = "-" * len(header)
# split into pages in case they have a lot of infractions — Discord has a 2000 char limit
# Split into pages in case they have many infractions — Discord has a 2000 char limit
chunk_size = 1800
pages = []
current_chunk = [header, separator]
@@ -84,7 +92,7 @@ class InfractionsCommand(commands.Cog):
for page in pages:
await interaction.user.send(dm_header + page)
dm_header = "" # only show the header on the first page
dm_header = "" # Only show the header on the first page
await interaction.followup.send("Your infractions have been sent to your DMs!", ephemeral=True)

View File

@@ -7,11 +7,14 @@ import random
MEOW_LIST = ["Meowwwww~", "Purrrrrr", "Nyaaaaaa", "Meow Meow", "Nya!", "Meow :3"]
class Meow(commands.Cog):
"""Cog providing the /meow slash command."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(name="meow")
async def meow(self, interaction: discord.Interaction):
"""Respond with a random meow-like sound from MEOW_LIST."""
meow_index = random.randrange(0, len(MEOW_LIST))
await interaction.response.send_message(MEOW_LIST[meow_index])

View File

@@ -7,12 +7,21 @@ from embed.embed_color import get_embed_color
from pathlib import Path
class Ping(commands.Cog):
"""Cog providing the /ping slash command for latency diagnostics."""
def __init__(self, bot: commands.Bot):
self.bot = bot
# Reuse the suggestions DB for the latency probe — any DB will do
self.db_path = Path(__file__).parent.parent / "data" / "suggestions.db"
@app_commands.command(name="ping", description="Check the bot's latency")
async def ping(self, interaction: discord.Interaction):
"""Report WebSocket, API, and database latency in a single embed.
API latency is measured as the round-trip time to send the initial
response. WebSocket latency comes from discord.py's heartbeat tracking.
Database latency is measured by running ``SELECT 1`` against the DB.
"""
# send a message first, then measure how long it took — that's our API round-trip
start_time = time.perf_counter()
await interaction.response.send_message("Pinging...")

View File

@@ -36,12 +36,25 @@ def load_color_role_ids() -> dict[str, int]:
def save_color_role_ids(data: dict[str, int]):
"""Persist the original_id -> color_copy_id mapping to DATA_PATH as JSON."""
DATA_PATH.parent.mkdir(exist_ok=True)
with open(DATA_PATH, "w") as f:
json.dump(data, f, indent=2)
class PrestigeColor(commands.Cog):
"""Cog providing the /prestige slash command group.
Manages color-copy roles for prestige (high-ranked) members. Each prestige
role gets a duplicate "color copy" role placed just above Acclaimed Ritualist
in the hierarchy so its color overrides the prestige role below it.
Subcommands:
- ``/prestige setup`` — (admin) Create color copy roles for each prestige role.
- ``/prestige color`` — Switch your active prestige color.
- ``/prestige removecolor`` — Remove your prestige color.
"""
def __init__(self, bot):
self.bot = bot
@@ -53,6 +66,12 @@ class PrestigeColor(commands.Cog):
)
@ModerationBase.is_admin()
async def setup(self, interaction: discord.Interaction):
"""Create or recreate color copy roles for every prestige role (admin only).
Skips roles whose copies already exist in the server. Positions each new
copy just above ACCLAIMED_ROLE_ID so it wins the color resolution order.
The resulting original_id -> copy_id mapping is saved to DATA_PATH.
"""
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if not guild:
@@ -110,6 +129,12 @@ class PrestigeColor(commands.Cog):
await interaction.followup.send("\n".join(lines) or "Nothing to do.", ephemeral=True)
async def prestige_autocomplete(self, interaction: discord.Interaction, current: str):
"""Autocomplete callback for the ``prestige`` parameter of /prestige color.
Filters the autocomplete list to only the prestige roles the invoking
member actually holds (no point offering roles they can't pick). Color
copy IDs that haven't been set up yet are also excluded.
"""
# Only show roles the user actually has — no point listing ones they can't pick
color_ids = load_color_role_ids()
member = interaction.user
@@ -131,6 +156,18 @@ class PrestigeColor(commands.Cog):
@app_commands.describe(prestige="The prestige role whose color you want to display.")
@app_commands.autocomplete(prestige=prestige_autocomplete)
async def color(self, interaction: discord.Interaction, prestige: str):
"""Equip a prestige color role copy, swapping out any previously active copy.
Prevents a user from equipping the color copy of their highest prestige role
since the original already controls the display color at that rank. When
switching colors, the old copy is removed and the original role is restored
before the new copy is added, so the user never loses a prestige title.
Parameters
----------
prestige:
The display name of the prestige role (from autocomplete).
"""
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if not guild:
@@ -216,6 +253,7 @@ class PrestigeColor(commands.Cog):
@prestige_group.command(name="removecolor", description="Remove your prestige color.")
async def removecolor(self, interaction: discord.Interaction):
"""Remove all prestige color copy roles and restore the original prestige roles."""
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if not guild:

View File

@@ -30,6 +30,7 @@ FOOTER_COLOR = (90, 90, 90)
def _make_circle(data: bytes, size: int) -> Image.Image:
"""Crop avatar bytes to a circular image of the given pixel size."""
img = Image.open(io.BytesIO(data)).convert("RGBA").resize(
(size, size), Image.Resampling.LANCZOS
)
@@ -41,6 +42,7 @@ def _make_circle(data: bytes, size: int) -> Image.Image:
def _wrap(text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_px: int) -> list[str]:
"""Word-wrap text to fit within max_px pixels wide using the given font."""
dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
words = text.split(" ")
lines: list[str] = []
@@ -59,6 +61,11 @@ def _wrap(text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_px:
def _clean_content(content: str, guild: discord.Guild | None) -> str:
"""Replace Discord mention/emoji markup with human-readable text.
Converts <@user_id> → @display_name, <#channel_id> → #channel_name,
<@&role_id> → @role_name, and custom emoji tags → :name:.
"""
def sub_user(m: re.Match) -> str:
if not guild:
return f"@{m.group(1)}"
@@ -104,6 +111,31 @@ def _render(
channel_name: str,
grayscale: bool = False,
) -> bytes:
"""Render a quote image and return the PNG bytes.
Layout:
- Left panel (~46% of canvas): avatar scaled to fill, faded to black with a
tilted angled gradient mask.
- Right panel (~54%): center-aligned body text, attribution row (dash +
small circular avatar + display name), @username, and a footer watermark.
Parameters
----------
avatar_data:
Raw bytes of the author's avatar image.
display_name:
The author's server display name.
username:
The author's Discord username (shown as @handle below the name).
content:
The quote body text (truncated to MAX_CHARS).
timestamp:
Formatted date string for the watermark.
channel_name:
Channel name (not currently rendered but available for future use).
grayscale:
If True, convert the final image to grayscale before returning.
"""
# ── fonts ──────────────────────────────────────────────────────────────────
try:
renogare = str(FONT_DIR / "Renogare-Regular.otf")
@@ -132,7 +164,8 @@ def _render(
cy = (nh - HEIGHT) // 2
av_raw = av_raw.crop((cx, cy, cx + LEFT_W, cy + HEIGHT))
# angled gradient mask: opaque left → transparent right, tilted by ANGLE_DEG
# Angled gradient mask: opaque left → transparent right, tilted by ANGLE_DEG.
# The tilt makes the fade line lean slightly, giving a less harsh edge.
fade_base = int(LEFT_W * FADE_FROM)
fade_range = max(1, LEFT_W - fade_base)
angle_rad = math.radians(ANGLE_DEG)
@@ -157,7 +190,7 @@ def _render(
text_w = text_right - text_left
center_x = (LEFT_W + WIDTH) // 2
# measure helpers
# Measure helpers
dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
body_lh = dummy.textbbox((0, 0), "Ag", font=font_body)
body_line_h = (body_lh[3] - body_lh[1]) + 8
@@ -181,7 +214,7 @@ def _render(
)
start_y = (HEIGHT - total_h) // 2
# body text — center-aligned
# Body text — center-aligned
y = start_y
for line in body_lines:
bbox = dummy.textbbox((0, 0), line, font=font_body)
@@ -189,7 +222,7 @@ def _render(
d.text((center_x - lw // 2, y), line, font=font_body, fill=TEXT_COLOR)
y += body_line_h
# attribution row: "- [avatar] display_name"
# Attribution row: "- [avatar] display_name"
attr_y = y + ATTR_GAP
small_av = _make_circle(avatar_data, AV_SM)
@@ -212,7 +245,7 @@ def _render(
hw = dummy.textbbox((0, 0), handle_tx, font=font_handle)[2]
d.text((center_x - hw // 2, handle_y), handle_tx, font=font_handle, fill=MUTED_COLOR)
# watermark — bottom right
# Watermark — bottom right
wm = timestamp
wm_w = dummy.textbbox((0, 0), wm, font=font_footer)[2]
d.text((WIDTH - PAD_X - wm_w, HEIGHT - 28), wm, font=font_footer, fill=FOOTER_COLOR)
@@ -227,14 +260,23 @@ def _render(
class Quote(commands.Cog):
"""Cog providing the /quote slash command.
Fetches the target message, downloads the author's avatar via aiohttp,
renders a quote image in a thread executor (PIL is CPU-bound), and posts
the result as a PNG file.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.session: aiohttp.ClientSession | None = None
async def cog_load(self):
"""Open a shared aiohttp session for avatar downloads."""
self.session = aiohttp.ClientSession()
async def cog_unload(self):
"""Close the aiohttp session on unload."""
if self.session:
await self.session.close()
@@ -253,6 +295,26 @@ class Quote(commands.Cog):
channel_id: str | None = None,
grayscale: bool = False,
):
"""Generate a stylised quote image from any message.
Supports three ways to specify the source channel:
- No channel argument: use the current channel.
- channel: resolved by Discord's type system (text channels only).
- channel_id: raw integer ID for threads or channels not in the chooser.
Temporarily joins threads the bot is not a member of and leaves after fetching.
Parameters
----------
message_id:
ID of the message to quote.
channel:
Text channel the message is in (optional).
channel_id:
Numeric channel/thread ID (optional, for threads and unlisted channels).
grayscale:
If True, render the final image in grayscale.
"""
await interaction.response.defer()
# Resolve target channel / thread
@@ -284,7 +346,7 @@ class Quote(commands.Cog):
)
return
# Join threads the bot isn't already a member of
# Join threads the bot isn't already a member of so we can read history
if isinstance(target, discord.Thread) and not target.me:
await target.join()
joined_thread = target
@@ -337,6 +399,7 @@ class Quote(commands.Cog):
async with self.session.get(author.display_avatar.url) as resp:
avatar_data = await resp.read()
# Run the PIL render in a thread executor — it's CPU-bound
image_bytes = await asyncio.to_thread(
_render, avatar_data, display_name, username, content, ts, channel_name, grayscale
)

View File

@@ -5,12 +5,23 @@ import random
class RNG(commands.Cog):
"""Cog providing the /rng slash command for random number generation."""
def __init__(self, bot):
self.bot = bot
@app_commands.command(name="rng", description="Generate a random number between two values")
@app_commands.describe(minimum="The minimum value (inclusive)", maximum="The maximum value (inclusive)")
async def rng(self, interaction: discord.Interaction, minimum: int, maximum: int):
"""Generate a random integer in [minimum, maximum] (both endpoints inclusive).
Parameters
----------
minimum:
Lower bound (inclusive).
maximum:
Upper bound (inclusive). Must be strictly greater than minimum.
"""
if minimum >= maximum:
await interaction.response.send_message("Minimum must be less than maximum!", ephemeral=True)
return

View File

@@ -25,6 +25,18 @@ COLOR_ROLE_NAMES = [
FONTS_PATH = Path(__file__).parent.parent / "fonts"
class ColorRoles(commands.Cog):
"""Cog providing the /color slash-command group for cosmetic color role management.
Members can freely assign or remove any role from COLOR_ROLE_NAMES.
Admin variants (setfor, removefor) allow moderators to manage color roles on
behalf of other members.
Role assignment always strips all existing color roles first so a member
can only ever hold one color role at a time. The `/color list` command
serves a pre-generated image from media/colorimage.png, falling back to
the older split images if the combined file hasn't been generated yet.
"""
def __init__(self, bot):
self.bot = bot
@@ -34,6 +46,11 @@ class ColorRoles(commands.Cog):
@app_commands.describe(color="The color role you'd like to have.")
@app_commands.choices(color=[app_commands.Choice(name=name, value=name) for name in COLOR_ROLE_NAMES])
async def set_color(self, interaction: discord.Interaction, color: app_commands.Choice[str]):
"""Assign the chosen color role to the caller, removing any existing color role first.
Fetches the full Member object if interaction.user comes back as a bare
User, which can happen in certain edge cases with the gateway cache.
"""
await interaction.response.defer(ephemeral=True)
try:
guild = interaction.guild
@@ -104,6 +121,7 @@ class ColorRoles(commands.Cog):
@app_commands.choices(color=[app_commands.Choice(name=name, value=name) for name in COLOR_ROLE_NAMES])
@ModerationBase.is_admin()
async def set_color_for(self, interaction: discord.Interaction, user: discord.Member, color: app_commands.Choice[str]):
"""Admin-only: assign a color role to another member, stripping their current color first."""
await interaction.response.defer(ephemeral=False)
try:
guild = interaction.guild
@@ -159,6 +177,7 @@ class ColorRoles(commands.Cog):
@color_group.command(name="remove", description="Remove your current color role.")
async def remove_color(self, interaction: discord.Interaction):
"""Remove all color roles from the caller. Succeeds silently if none are held."""
await interaction.response.defer(ephemeral=True)
try:
guild = interaction.guild
@@ -212,6 +231,7 @@ class ColorRoles(commands.Cog):
@app_commands.describe(user="The user to remove color roles from.")
@ModerationBase.is_admin()
async def remove_color_for(self, interaction: discord.Interaction, user: discord.Member):
"""Admin-only: remove all color roles from the specified member."""
await interaction.response.defer(ephemeral=False)
try:
guild = interaction.guild
@@ -255,6 +275,12 @@ class ColorRoles(commands.Cog):
@color_group.command(name="list", description="Show all available role colors")
async def list_colors(self, interaction: discord.Interaction):
"""Send the color role reference image.
Prefers media/colorimage.png (a single combined image). If that file
doesn't exist yet, falls back to colorimage1.png / colorimage2.png
(the old split format generated before the combined image was added).
"""
await interaction.response.defer(ephemeral=False)
try:
media_dir = Path(__file__).resolve().parent.parent / "media"

View File

@@ -10,14 +10,32 @@ from typing import Optional
logger = get_logger(__name__)
class RoleTrack(commands.Cog):
"""Cog that automatically saves and restores member roles across leaves and rejoins.
Role snapshots are stored in roletrack.db with a composite primary key of
(user_id, guild_id). Role IDs are serialised as a comma-separated string
in a single TEXT column — simple enough that a join table isn't needed.
Three event listeners keep the snapshot current:
- on_member_remove: snapshot on leave.
- on_member_join: restore from snapshot on rejoin; snapshot if brand new.
- on_member_update: re-snapshot whenever roles change.
Two slash commands expose the system to members:
- /syncroles: force a manual snapshot of current roles.
- /checkroles: display the currently stored snapshot.
"""
def __init__(self, bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "roletrack.db"
async def cog_load(self):
"""Initialise the database table on cog load."""
await self.init_db()
async def init_db(self):
"""Create the tracked_roles table if it doesn't exist."""
async with aiosqlite.connect(self.db_path) as db:
# role_ids stored as comma-separated string — simple enough, don't need a join table
await db.execute('''
@@ -33,7 +51,14 @@ class RoleTrack(commands.Cog):
await db.commit()
async def save_user_roles(self, member: discord.Member):
"""Save all roles for a user (excluding @everyone)"""
"""Snapshot all roles for a member, excluding @everyone.
Uses INSERT OR REPLACE so this works for both first-time inserts and
subsequent updates without needing separate code paths.
Note: @everyone's role ID equals the guild ID, which is how the filter
works below.
"""
# @everyone's id == guild id, so this filters it out
role_ids = [str(role.id) for role in member.roles if role.id != member.guild.id]
role_ids_str = ",".join(role_ids) if role_ids else ""
@@ -47,7 +72,7 @@ class RoleTrack(commands.Cog):
await db.commit()
async def get_saved_roles(self, user_id: int, guild_id: int):
"""Get saved role IDs for a user"""
"""Return the list of saved role IDs for a member, or an empty list if none are stored."""
async with aiosqlite.connect(self.db_path) as db:
async with db.execute('''
SELECT role_ids FROM tracked_roles
@@ -60,7 +85,11 @@ class RoleTrack(commands.Cog):
@app_commands.command(name="syncroles", description="Manually sync your current roles to the role tracking system")
async def syncroles(self, interaction: discord.Interaction):
"""Command to manually sync user's current roles"""
"""Force a manual snapshot of the caller's current roles.
Useful if automatic tracking missed a role assignment, or after an
admin manually adds roles that the listener didn't fire for.
"""
await interaction.response.defer(ephemeral=True)
if not interaction.guild or not isinstance(interaction.user, discord.Member):
@@ -103,7 +132,12 @@ class RoleTrack(commands.Cog):
@app_commands.command(name="checkroles", description="Check what roles are saved in the database for you")
async def checkroles(self, interaction: discord.Interaction):
"""Debug command to check saved roles"""
"""Display the currently stored role snapshot for the caller.
Shows each role by name where possible; for roles that have since been
deleted from the server, shows the raw ID so the member can see they
are stored and won't cause errors on restore.
"""
await interaction.response.defer(ephemeral=True)
if not interaction.guild:
@@ -152,12 +186,17 @@ class RoleTrack(commands.Cog):
@commands.Cog.listener()
async def on_member_remove(self, member: discord.Member):
"""Save roles when a member leaves"""
"""Snapshot the member's roles when they leave so they can be restored on rejoin."""
await self.save_user_roles(member)
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
"""Restore roles when a member rejoins, and save them for new members"""
"""Restore a returning member's roles, or initialise a snapshot for brand-new members.
Silently skips roles that no longer exist on the server to avoid errors.
Sends a DM to notify the member of the restoration; DM failures are
swallowed since users frequently have DMs disabled.
"""
saved_role_ids = await self.get_saved_roles(member.id, member.guild.id)
if not saved_role_ids:
@@ -201,7 +240,11 @@ class RoleTrack(commands.Cog):
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Track role changes in real-time"""
"""Re-snapshot roles whenever a member's role list changes.
Comparing before.roles to after.roles avoids unnecessary DB writes for
unrelated member updates such as nickname changes.
"""
# only re-save if roles actually changed
if before.roles != after.roles:
await self.save_user_roles(after)

View File

@@ -7,7 +7,16 @@ import random
SALT_EMOJI_ID = 1074583707459010560
class SaltCommand(ModerationBase):
"""Cog providing the !salt moderation command.
Queues a member to receive the salt emoji reaction on their very next
message. The queue is held in memory only — it does not persist across
restarts, which is intentional (a salt target that lasts forever without
being triggered is harmless to lose).
"""
def __init__(self, bot):
self.bot = bot
# tracks who's queued to be salted: {guild_id: {user_id: reason}}
@@ -17,7 +26,15 @@ class SaltCommand(ModerationBase):
@commands.command(name="salt")
@ModerationBase.is_admin()
async def salt(self, ctx, member: discord.Member, *, reason: Optional[str] = None):
"""React with salt emoji to the user's next message"""
"""Queue the salt emoji reaction on the target's next message.
Parameters
----------
member:
The member to salt.
reason:
Optional reason to show in the confirmation message.
"""
# easter egg — 1% chance of denying the command if used on this specific user
if member.id == 252130669919076352:
@@ -45,6 +62,12 @@ class SaltCommand(ModerationBase):
@commands.Cog.listener()
async def on_message(self, message):
"""Fire the salt reaction when a queued member sends their next message.
Dequeues the member immediately after reacting so they only get salted
once per !salt invocation. Ignores bots, except for the bot's own user
ID which is explicitly kept saltable.
"""
# ignore bots (except our own bot id — that one should still be saltable)
if message.author.bot and message.author.id != 1409637508689563689 or not message.guild:
return

View File

@@ -12,6 +12,7 @@ DB_PATH = Path(__file__).parent.parent / "data" / "scheduled.db"
def _db_init():
"""Create the scheduled messages table if it doesn't already exist."""
con = sqlite3.connect(DB_PATH)
con.execute("""
CREATE TABLE IF NOT EXISTS scheduled (
@@ -26,6 +27,11 @@ def _db_init():
def _db_insert(job_id: int, channel_id: int, fire_at: float, message: str):
"""Persist a scheduled job to the database before the asyncio task is created.
Persisting first ensures the job survives a restart even if the process
exits between insertion and the task being created.
"""
con = sqlite3.connect(DB_PATH)
con.execute(
"INSERT INTO scheduled (id, channel_id, fire_at, message) VALUES (?, ?, ?, ?)",
@@ -36,6 +42,7 @@ def _db_insert(job_id: int, channel_id: int, fire_at: float, message: str):
def _db_delete(job_id: int):
"""Remove a completed or cancelled job from the database."""
con = sqlite3.connect(DB_PATH)
con.execute("DELETE FROM scheduled WHERE id = ?", (job_id,))
con.commit()
@@ -43,6 +50,7 @@ def _db_delete(job_id: int):
def _db_load_all() -> list[tuple]:
"""Return all rows from the scheduled table as (id, channel_id, fire_at, message) tuples."""
con = sqlite3.connect(DB_PATH)
rows = con.execute("SELECT id, channel_id, fire_at, message FROM scheduled").fetchall()
con.close()
@@ -86,6 +94,20 @@ def _parse_delay(time_str: str) -> float | None:
class SendMessage(commands.Cog):
"""Owner-only cog for sending and scheduling messages to arbitrary channels.
Provides four prefix commands (all restricted to OWNER_ID):
- !sendmessage — send to any channel in any guild immediately.
- !schedulemessage — schedule a message for a future time, persisted to
scheduled.db so it survives bot restarts.
- !listscheduled — show all pending scheduled job IDs.
- !cancelscheduled — cancel and remove a pending scheduled job.
Scheduled jobs are stored as asyncio Tasks indexed by an auto-incrementing
integer ID. On cog load, any jobs that survived the last restart are
re-queued; jobs whose fire_at has already passed are sent immediately.
"""
def __init__(self, bot):
self.bot = bot
self._scheduled: dict[int, asyncio.Task] = {} # id -> Task
@@ -93,6 +115,7 @@ class SendMessage(commands.Cog):
_db_init()
async def cog_load(self):
"""Re-schedule any jobs that were pending before the bot restarted."""
# re-schedule anything that was pending before the bot restarted
rows = await asyncio.to_thread(_db_load_all)
if rows:
@@ -104,6 +127,11 @@ class SendMessage(commands.Cog):
self._schedule_task(job_id, channel_id, delay, message)
def _schedule_task(self, job_id: int, channel_id: int, delay: float, message: str):
"""Create and register an asyncio Task that sends the message after delay seconds.
Cleans up both the in-memory task dict and the DB row after sending,
regardless of whether the send succeeded, so dead jobs don't accumulate.
"""
async def _send():
await asyncio.sleep(delay)
channel = self.bot.get_channel(channel_id)

View File

@@ -27,6 +27,20 @@ DB_PATH = Path(__file__).parent.parent / "data" / "tts.db"
class TTS(commands.GroupCog, name="tts"):
"""GroupCog providing voice-channel TTS for muted members.
When active in a guild, messages sent to the active voice channel's text
chat by muted members are synthesised with edge-tts (falling back to gTTS)
and played through the bot's voice connection.
Features:
- Per-guild asyncio queue so messages play sequentially without overlap.
- Rate limit of one TTS per user per RATE_LIMIT_SECONDS.
- Automatic disconnect after IDLE_TIMEOUT_SECONDS of queue inactivity.
- Automatic disconnect if all human members leave the voice channel.
- Optional TTS nickname stored in tts.db to override the Discord username.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
# guild_id -> voice_channel_id currently being listened to
@@ -39,6 +53,7 @@ class TTS(commands.GroupCog, name="tts"):
self.rate_limits: dict[tuple[int, int], float] = defaultdict(float)
async def cog_load(self):
"""Create the tts_nicks table if it doesn't exist."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS tts_nicks (
@@ -49,13 +64,27 @@ class TTS(commands.GroupCog, name="tts"):
await db.commit()
async def _get_tts_name(self, user_id: int, fallback: str) -> str:
"""Return the user's saved TTS nickname, or their Discord username as fallback."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT nickname FROM tts_nicks WHERE user_id = ?", (user_id,))
row = await cursor.fetchone()
return row[0] if row else fallback
async def _generate_tts(self, text: str, filepath: str) -> bool:
"""Generate TTS audio to filepath. Tries edge-tts, falls back to gTTS."""
"""Generate TTS audio to filepath. Tries edge-tts first, falls back to gTTS.
Parameters
----------
text:
The text to synthesise.
filepath:
Path to write the resulting MP3 file to.
Returns
-------
bool
True if audio was generated successfully, False otherwise.
"""
try:
import edge_tts
communicate = edge_tts.Communicate(text, TTS_VOICE)
@@ -74,7 +103,12 @@ class TTS(commands.GroupCog, name="tts"):
return False
async def _process_queue(self, guild_id: int):
"""Sequentially processes TTS entries for a guild."""
"""Sequentially process TTS entries for a guild.
Waits up to IDLE_TIMEOUT_SECONDS for the next queue item. If the timeout
fires, disconnects the bot and cleans up state. Exits if the voice
connection is lost between items.
"""
while True:
queue = self.queues.get(guild_id)
if not queue:
@@ -120,6 +154,7 @@ class TTS(commands.GroupCog, name="tts"):
success = await self._generate_tts(f"{name} said: {text}", tmpfile)
if success and vc.is_connected():
# Wait for any currently playing audio to finish before queuing the next
while vc.is_playing():
await asyncio.sleep(0.1)
@@ -144,6 +179,7 @@ class TTS(commands.GroupCog, name="tts"):
queue.task_done()
def _cleanup(self, guild_id: int):
"""Remove all TTS state for a guild and cancel the queue processor task."""
self.active_channels.pop(guild_id, None)
self.queues.pop(guild_id, None)
task = self.queue_tasks.pop(guild_id, None)
@@ -155,6 +191,11 @@ class TTS(commands.GroupCog, name="tts"):
@app_commands.command(name="join", description="Join this voice channel and read muted members' messages aloud")
async def tts_join(self, interaction: discord.Interaction):
"""Join the current voice channel and start TTS.
Must be used from a voice channel's text chat. The caller must be in
the voice channel. Only one TTS session is allowed per guild at a time.
"""
if not isinstance(interaction.channel, discord.VoiceChannel):
await interaction.response.send_message(
"This command can only be used inside a voice channel's text chat!",
@@ -219,6 +260,7 @@ class TTS(commands.GroupCog, name="tts"):
@app_commands.command(name="leave", description="Leave the voice channel and stop TTS")
async def tts_leave(self, interaction: discord.Interaction):
"""Disconnect from the voice channel and tear down TTS state for this guild."""
guild = interaction.guild
if not guild:
return
@@ -240,6 +282,10 @@ class TTS(commands.GroupCog, name="tts"):
@app_commands.command(name="setnick", description="Set a short TTS nickname for yourself. Anyone found misusing this will be punished.")
@app_commands.describe(nickname="Your TTS nickname (max 32 characters)")
async def tts_setnick(self, interaction: discord.Interaction, nickname: str):
"""Save a TTS nickname that will be read instead of the Discord username.
URLs are stripped from the nickname to prevent misuse.
"""
nickname = nickname.strip()
if not nickname:
await interaction.response.send_message("Nickname can't be empty!", ephemeral=True)
@@ -264,6 +310,7 @@ class TTS(commands.GroupCog, name="tts"):
@app_commands.command(name="clearnick", description="Remove your TTS nickname and go back to your username")
async def tts_clearnick(self, interaction: discord.Interaction):
"""Delete the saved TTS nickname so the Discord username is used again."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM tts_nicks WHERE user_id = ?", (interaction.user.id,))
await db.commit()
@@ -276,6 +323,7 @@ class TTS(commands.GroupCog, name="tts"):
before: discord.VoiceState,
after: discord.VoiceState,
):
"""Disconnect and clean up if all human members leave the active TTS channel."""
if not self.bot.user or member.id == self.bot.user.id:
return
@@ -307,6 +355,7 @@ class TTS(commands.GroupCog, name="tts"):
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Queue a TTS entry for muted members' messages in the active voice channel chat."""
if message.author.bot or not message.guild:
return
@@ -326,9 +375,11 @@ class TTS(commands.GroupCog, name="tts"):
return
voice_state = message.author.voice
# Only read messages from users who are self-muted or server-muted
if not voice_state or not (voice_state.self_mute or voice_state.mute):
return
# Skip slash/prefix commands — they'll already be handled by the bot
if message.content.startswith('/') or message.content.startswith('!'):
return

293
commands/whitelist.py Normal file
View File

@@ -0,0 +1,293 @@
import os
import asyncio
from datetime import datetime, timezone
from pathlib import Path
import discord
from discord import app_commands
from discord.ext import commands
import aiosqlite
OWNER_ID = 252130669919076352
RCON_HOST = os.getenv("RCON_HOST")
RCON_PORT = int(os.getenv("RCON_PORT", "25575"))
RCON_PASSWORD = os.getenv("RCON_PASSWORD")
SERVER_IP = os.getenv("MC_SERVER_IP", "vanilla.lilacrose.dev")
DB_PATH = Path(__file__).parent.parent / "data" / "whitelist.db"
async def init_db():
"""Create the whitelist table if it doesn't exist."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
minecraft_username TEXT NOT NULL UNIQUE,
discord_user_id INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
requested_at TEXT NOT NULL
)
""")
await db.commit()
async def rcon_command(cmd: str) -> str:
"""Minimal async RCON client — avoids mcrcon's signal.signal() thread issue.
Implements the Source RCON protocol:
1. Open TCP connection.
2. Send a SERVERDATA_AUTH (type 3) packet with the password.
3. Send a SERVERDATA_EXECCOMMAND (type 2) packet with the command.
4. Return the server's response body string.
Parameters
----------
cmd:
The Minecraft server command to execute (without leading slash).
Returns
-------
str
The raw response text from the server.
Raises
------
ValueError
If RCON authentication fails (wrong password).
"""
import struct
RCON_LOGIN = 3
RCON_COMMAND = 2
REQ_ID = 1
def _pack(req_id: int, ptype: int, body: str) -> bytes:
payload = body.encode("utf-8") + b"\x00\x00"
length = 4 + 4 + len(payload)
return struct.pack("<iii", length, req_id, ptype) + payload
def _unpack(data: bytes):
length, req_id, ptype = struct.unpack_from("<iii", data, 0)
body = data[12 : 8 + length - 2].decode("utf-8", errors="replace")
return req_id, ptype, body
reader, writer = await asyncio.open_connection(RCON_HOST, RCON_PORT)
try:
# Authenticate
writer.write(_pack(REQ_ID, RCON_LOGIN, RCON_PASSWORD))
await writer.drain()
raw = await reader.read(4096)
auth_id, _, _ = _unpack(raw)
if auth_id == -1:
raise ValueError("RCON authentication failed — wrong password?")
# Send command
writer.write(_pack(REQ_ID, RCON_COMMAND, cmd))
await writer.drain()
raw = await reader.read(4096)
_, _, response = _unpack(raw)
return response
finally:
writer.close()
await writer.wait_closed()
class Whitelist(commands.GroupCog, name="whitelist"):
"""GroupCog providing the /whitelist slash command group.
Allows server members to request Minecraft whitelist access (/whitelist request)
and the owner to approve or remove players (/whitelist add, remove, list).
Requests are persisted to whitelist.db and approved via RCON.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
async def cog_load(self):
"""Initialise the DB table on cog load."""
await init_db()
# ── /whitelist request ─────────────────────────────────────────────────────
@app_commands.command(name="request", description="Request to be added to the Minecraft server whitelist")
@app_commands.describe(minecraft_username="Your Minecraft username")
async def whitelist_request(self, interaction: discord.Interaction, minecraft_username: str):
"""Submit a whitelist request for a Minecraft username.
Rejects duplicate requests for the same username. On acceptance, stores
a pending record so the owner can approve via /whitelist add.
"""
await interaction.response.defer(ephemeral=True)
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT status FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
(minecraft_username,)
) as cur:
row = await cur.fetchone()
if row:
if row[0] == "approved":
await interaction.followup.send(
f"`{minecraft_username}` is already on the whitelist!", ephemeral=True
)
else:
await interaction.followup.send(
f"There's already a pending request for `{minecraft_username}`.", ephemeral=True
)
return
await db.execute(
"INSERT INTO whitelist (minecraft_username, discord_user_id, status, requested_at) VALUES (?, ?, 'pending', ?)",
(minecraft_username, interaction.user.id, datetime.now(timezone.utc).isoformat())
)
await db.commit()
await interaction.followup.send(
f"Your whitelist request for `{minecraft_username}` has been submitted! "
f"You'll receive a DM when it's approved.",
ephemeral=True
)
# ── /whitelist list ────────────────────────────────────────────────────────
@app_commands.command(name="list", description="View whitelist requests and approved players")
async def whitelist_list(self, interaction: discord.Interaction):
"""List all pending and approved whitelist entries (owner only)."""
if interaction.user.id != OWNER_ID:
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT minecraft_username, discord_user_id, status, requested_at FROM whitelist ORDER BY status DESC, requested_at ASC"
) as cur:
rows = await cur.fetchall()
if not rows:
await interaction.followup.send("No whitelist entries found.", ephemeral=True)
return
pending = [r for r in rows if r[2] == "pending"]
approved = [r for r in rows if r[2] == "approved"]
embed = discord.Embed(title="Minecraft Whitelist", color=0x57f287)
if pending:
lines = []
for mc_name, discord_id, _, requested_at in pending:
date = requested_at[:10]
user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked"
lines.append(f"• `{mc_name}` — {user_ref} *(requested {date})*")
embed.add_field(name=f"⏳ Pending ({len(pending)})", value="\n".join(lines), inline=False)
if approved:
lines = []
for mc_name, discord_id, _, _ in approved:
user_ref = f"<@{discord_id}>" if discord_id else "No Discord linked"
lines.append(f"• `{mc_name}` — {user_ref}")
embed.add_field(name=f"✅ Approved ({len(approved)})", value="\n".join(lines), inline=False)
await interaction.followup.send(embed=embed, ephemeral=True)
# ── /whitelist add ─────────────────────────────────────────────────────────
@app_commands.command(name="add", description="Add a player to the Minecraft whitelist via RCON")
@app_commands.describe(minecraft_username="The Minecraft username to whitelist")
async def whitelist_add(self, interaction: discord.Interaction, minecraft_username: str):
"""Approve a whitelist request via RCON and DM the requester (owner only).
If a pending request exists for the username, marks it as approved and
notifies the linked Discord user. If no request exists, inserts an
approved record directly.
"""
if interaction.user.id != OWNER_ID:
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
try:
result = await rcon_command(f"whitelist add {minecraft_username}")
except Exception as e:
await interaction.followup.send(f"RCON error: `{e}`", ephemeral=True)
return
discord_user_id = None
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT discord_user_id FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
(minecraft_username,)
) as cur:
row = await cur.fetchone()
if row:
discord_user_id = row[0]
await db.execute(
"UPDATE whitelist SET status = 'approved' WHERE minecraft_username = ? COLLATE NOCASE",
(minecraft_username,)
)
else:
# No prior request — insert a new approved record
await db.execute(
"INSERT INTO whitelist (minecraft_username, discord_user_id, status, requested_at) VALUES (?, NULL, 'approved', ?)",
(minecraft_username, datetime.now(timezone.utc).isoformat())
)
await db.commit()
dm_note = ""
if discord_user_id:
try:
user = await self.bot.fetch_user(discord_user_id)
await user.send(
f"Hey! Your whitelist request for **{minecraft_username}** has been approved! 🎉\n"
f"You can now join the Minecraft server at `{SERVER_IP}`."
)
dm_note = f"\nDM sent to <@{discord_user_id}>."
except discord.Forbidden:
dm_note = f"\n⚠️ Couldn't DM <@{discord_user_id}> (DMs disabled)."
except Exception as e:
dm_note = f"\n⚠️ DM to <@{discord_user_id}> failed: {e}"
await interaction.followup.send(
f"✅ Added `{minecraft_username}` to the whitelist.\nServer: `{result}`{dm_note}",
ephemeral=True
)
# ── /whitelist remove ──────────────────────────────────────────────────────
@app_commands.command(name="remove", description="Remove a player from the Minecraft whitelist via RCON")
@app_commands.describe(minecraft_username="The Minecraft username to remove")
async def whitelist_remove(self, interaction: discord.Interaction, minecraft_username: str):
"""Remove a player from the Minecraft whitelist via RCON and the DB (owner only)."""
if interaction.user.id != OWNER_ID:
await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True)
try:
result = await rcon_command(f"whitelist remove {minecraft_username}")
except Exception as e:
await interaction.followup.send(f"RCON error: `{e}`", ephemeral=True)
return
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"DELETE FROM whitelist WHERE minecraft_username = ? COLLATE NOCASE",
(minecraft_username,)
)
await db.commit()
await interaction.followup.send(
f"🗑️ Removed `{minecraft_username}` from the whitelist.\nServer: `{result}`",
ephemeral=True
)
async def setup(bot: commands.Bot):
await bot.add_cog(Whitelist(bot))

View File

@@ -44,25 +44,38 @@ JOHN_ROLES = [
class AprilFools(commands.Cog):
"""Cog providing the /johnify and /unjohnify slash commands for April Fools.
Johnification renames every channel and role in the server to variations of
"John" and saves the original names to april_fools_backup.json. A timed
auto-revert task fires at REVERT_TIME to restore everything automatically.
The backup file serves as a state sentinel — if it exists on startup the
auto-revert is rescheduled, ensuring names are restored even after a restart.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self._revert_task: asyncio.Task | None = None
async def cog_load(self):
"""Re-schedule the auto-revert task if a backup exists from a previous run."""
if BACKUP_PATH.exists():
logger.info("April Fools: backup found on load, scheduling auto-revert")
self._schedule_revert()
async def cog_unload(self):
"""Cancel the pending auto-revert task when the cog unloads."""
if self._revert_task and not self._revert_task.done():
self._revert_task.cancel()
def _schedule_revert(self):
"""Start the auto-revert task, skipping if one is already running."""
if self._revert_task and not self._revert_task.done():
return
self._revert_task = asyncio.create_task(self._wait_and_revert())
async def _wait_and_revert(self):
"""Sleep until REVERT_TIME then call _do_revert to restore all names."""
now = datetime.now(timezone.utc)
delay = (REVERT_TIME - now).total_seconds()
if delay > 0:
@@ -73,6 +86,18 @@ class AprilFools(commands.Cog):
logger.info(f"April Fools: auto-revert result — {msg}")
async def _do_johnify(self, guild: discord.Guild) -> tuple[bool, str]:
"""Rename all channels and roles to John-themed names and save a backup.
Writes original names to BACKUP_PATH as JSON before making any changes.
Returns ``(False, message)`` immediately if a backup already exists (i.e.,
Johnification is already active). A 0.6-second delay is inserted between
each rename to avoid Discord API rate limits.
Returns
-------
tuple[bool, str]
``(True, "John.")`` on success, ``(False, reason)`` on failure.
"""
if BACKUP_PATH.exists():
return False, "Already Johnified! Use `/unjohnify` to revert first."
@@ -113,6 +138,17 @@ class AprilFools(commands.Cog):
return True, "John."
async def _do_revert(self) -> tuple[bool, str]:
"""Restore all channel and role names from the backup JSON, then delete it.
Returns ``(False, reason)`` if no backup exists. Iterates every entry in
the backup and renames matching channels/roles; missing channels or roles
(deleted since Johnification) are silently skipped.
Returns
-------
tuple[bool, str]
``(True, message)`` on success, ``(False, reason)`` if no backup found.
"""
if not BACKUP_PATH.exists():
return False, "Nothing to revert — no backup found."
@@ -147,6 +183,7 @@ class AprilFools(commands.Cog):
@app_commands.command(name="johnify", description="Johnify the entire server (April Fools)")
@ModerationBase.is_admin()
async def johnify(self, interaction: discord.Interaction):
"""Rename all channels and roles to John-themed names (admin only)."""
await interaction.response.defer(ephemeral=True)
if not interaction.guild:
await interaction.followup.send("This command can only be used in a server.", ephemeral=True)
@@ -157,6 +194,7 @@ class AprilFools(commands.Cog):
@app_commands.command(name="unjohnify", description="Revert the Johnification and restore all names")
@ModerationBase.is_admin()
async def unjohnify(self, interaction: discord.Interaction):
"""Restore all original channel and role names from the backup (admin only)."""
await interaction.response.defer(ephemeral=True)
ok, msg = await self._do_revert()
await interaction.followup.send(msg, ephemeral=True)

View File

@@ -14,21 +14,35 @@ logger = get_logger(__name__)
class ArchipelagoMonitor(commands.Cog):
"""Monitors Archipelago multiworld log file and posts updates to Discord."""
"""Cog that monitors an Archipelago multiworld server log and posts live updates to Discord.
Polls the most recently modified ``Server_*.txt`` log file in the configured
log directory every 2 seconds. New lines are matched against regex patterns
for item sends, player joins/leaves, DeathLink tag changes, and server starts.
Matching events are formatted as Discord embeds and sent to ARCHIPELAGO_CHANNEL_ID.
Configuration is read from environment variables:
- ``ARCHIPELAGO_ENABLED`` — set to ``true`` to enable (default: disabled).
- ``ARCHIPELAGO_CHANNEL_ID`` — Discord channel to post events to.
- ``ARCHIPELAGO_LOG_DIR`` — directory containing ``Server_*.txt`` files.
A ``seen_lines`` set prevents duplicate notifications if the monitor lags
behind or re-reads a file from position 0 after a file switch.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
# Get configuration from environment variables
self.channel_id = int(os.getenv("ARCHIPELAGO_CHANNEL_ID", "0"))
self.log_directory = os.getenv("ARCHIPELAGO_LOG_DIR", "/home/lilacrose/Archipelago/logs")
self.enabled = os.getenv("ARCHIPELAGO_ENABLED", "false").lower() == "true"
self.notification_channel: Optional[discord.TextChannel] = None
self.current_log_file: Optional[Path] = None
self.last_position = 0 # Track where we last read in the file
self.seen_lines: Set[str] = set() # Track lines we've already processed
self.seen_lines: Set[str] = set() # Track lines we've already processed to avoid duplicates
# Regex patterns for parsing log messages
self.patterns = {
'item_send': re.compile(r'\(Team #\d+\) (.+?) sent (.+?) to (.+?) \((.+?)\)'),
@@ -40,6 +54,7 @@ class ArchipelagoMonitor(commands.Cog):
logger.info(f"Initializing log monitor - Enabled: {self.enabled}, Channel: {self.channel_id}, Log dir: {self.log_directory}")
# Only start the polling loop if both the feature flag and channel ID are configured
if self.enabled and self.channel_id:
logger.info("Monitor will start when bot is ready")
self.monitor_log.start()
@@ -50,7 +65,7 @@ class ArchipelagoMonitor(commands.Cog):
logger.info("Monitor DISABLED: ARCHIPELAGO_CHANNEL_ID is not set")
async def cog_unload(self):
"""Called when the cog is unloaded."""
"""Stop the polling loop when the cog unloads."""
self.monitor_log.cancel()
def get_most_recent_log_file(self) -> Optional[Path]:
@@ -79,7 +94,13 @@ class ArchipelagoMonitor(commands.Cog):
@tasks.loop(seconds=2)
async def monitor_log(self):
"""Monitor the Archipelago log file for new events."""
"""Poll the log file every 2 seconds and process any newly written lines.
Switches to a newer file automatically if a more recently modified
``Server_*.txt`` appears (e.g. after a server restart). Uses a byte
offset (``last_position``) to read only lines appended since the last
tick, rather than scanning from the beginning each time.
"""
try:
# Check if we need to find a new log file (first run or file changed)
most_recent = self.get_most_recent_log_file()
@@ -126,9 +147,10 @@ class ArchipelagoMonitor(commands.Cog):
self.seen_lines.add(line)
# Keep seen_lines from growing too large
# Bound memory usage: keep only the 500 most recently seen lines.
# Retaining some history prevents re-processing lines still in the
# rolling window if the set was cleared entirely.
if len(self.seen_lines) > 1000:
# Remove oldest half
self.seen_lines = set(list(self.seen_lines)[-500:])
# Try to parse and handle the line
@@ -139,9 +161,14 @@ class ArchipelagoMonitor(commands.Cog):
@monitor_log.before_loop
async def before_monitor(self):
"""Wait until the bot is ready before starting the monitor."""
"""Wait until the bot is ready, resolve the notification channel, and seek to EOF.
Seeking to the end of the log file on startup prevents the bot from
replaying old events each time it restarts. Posts a startup message to
the notification channel confirming which log file is being monitored.
"""
await self.bot.wait_until_ready()
# Get the notification channel
self.notification_channel = self.bot.get_channel(self.channel_id)
if self.notification_channel:
@@ -172,7 +199,17 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Could not find channel with ID {self.channel_id}")
async def process_log_line(self, line: str):
"""Process a single log line and send notifications if needed."""
"""Match a single log line against all known patterns and dispatch to the handler.
Tries patterns in priority order: item_send → join → leave → tag_change →
server_start. Stops at the first match. Unrecognised lines are silently
ignored (not every log line corresponds to a Discord-worthy event).
Parameters
----------
line:
A single stripped line from the Archipelago server log.
"""
if not self.notification_channel:
return
@@ -223,7 +260,22 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Error: {e}")
async def handle_item_send(self, sender: str, item: str, receiver: str, location: str):
"""Handle an item send notification."""
"""Post a Discord embed for an item being sent between players.
Replaces underscores with spaces in the item and location names to make
auto-generated game identifiers more human-readable.
Parameters
----------
sender:
The player who sent the item.
item:
The item name (raw from the log).
receiver:
The player who received the item.
location:
The in-game location the item was found at.
"""
# Clean up item name (remove underscores, make readable)
item_display = item.replace('_', ' ')
location_display = location.replace('_', ' ')
@@ -245,7 +297,15 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Failed to send Discord message: {e}")
async def handle_join(self, player: str, game: str):
"""Handle a player join notification."""
"""Post a join notification embed when a player connects to the session.
Parameters
----------
player:
The connecting player's name.
game:
The game title they are playing (underscores replaced with spaces).
"""
# Clean up game name
game_display = game.replace('_', ' ')
@@ -261,7 +321,13 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Failed to send Discord message: {e}")
async def handle_leave(self, player: str):
"""Handle a player disconnect notification."""
"""Post a leave notification embed when a player disconnects.
Parameters
----------
player:
The disconnecting player's name.
"""
embed = discord.Embed(
description=f"👋 **{player}** left the game",
color=discord.Color.greyple()
@@ -274,7 +340,18 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Failed to send Discord message: {e}")
async def handle_tag_change(self, player: str, tags: str):
"""Handle a tag change notification (like enabling DeathLink)."""
"""Post a notification embed for interesting tag changes (currently only DeathLink).
Only fires when the new tag set contains ``DeathLink``; all other tag
changes are silently ignored.
Parameters
----------
player:
The player who changed their tags.
tags:
The full new tag string from the log (comma-separated).
"""
# Only notify for interesting tags
if 'DeathLink' in tags:
embed = discord.Embed(
@@ -289,7 +366,18 @@ class ArchipelagoMonitor(commands.Cog):
logger.error(f"Failed to send Discord message: {e}")
async def handle_server_start(self, address: str, password: str):
"""Handle server start notification."""
"""Post a server-start embed including the connection address and password.
The password field is omitted when it equals ``"None"`` (the literal
string Archipelago uses when no password was set).
Parameters
----------
address:
The server's host:port string.
password:
The server password, or ``"None"`` if no password was set.
"""
embed = discord.Embed(
title="🎮 Archipelago Server Started",
color=discord.Color.green()

View File

@@ -5,17 +5,34 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Role whose assignment triggers the bot-trap check
ROLE_ID_TO_BAN = 1439354601672282335
# Channel for logging trap-role events
LOG_CHANNEL_ID = 1440055015711703242
# Fallback channel to ping the user if DMs are disabled
FALLBACK_CHANNEL_ID = 876772600704020533
# Members newer than this threshold are assumed to be bots and auto-banned
NEW_MEMBER_THRESHOLD_DAYS = 1
class AutoBanOnRole(commands.Cog):
"""Cog that automatically bans or warns users who receive the bot-trap role.
When the trap role is assigned, the response depends on how long the
member has been in the server:
- New members (< 1 day): assumed to be bots — banned immediately with a
7-day message purge and logged to LOG_CHANNEL_ID.
- Established members (>= 1 day): the role is removed and they are warned
via DM (or fallback channel ping if DMs are disabled).
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Watch for the trap role being added and take the appropriate action."""
# Role was just added
before_roles = set(before.roles)
after_roles = set(after.roles)
@@ -26,10 +43,10 @@ class AutoBanOnRole(commands.Cog):
if role.id == ROLE_ID_TO_BAN:
trap_role = role
break
if trap_role is None:
return
guild = after.guild
# Check how long they've been in the server
@@ -38,15 +55,16 @@ class AutoBanOnRole(commands.Cog):
is_new_member = server_join_age < timedelta(days=NEW_MEMBER_THRESHOLD_DAYS)
if is_new_member:
try:
try:
await guild.ban(
after,
reason="Bot automatically banned due to receiving bot trap role",
delete_message_days=7
)
except Exception as e:
logger.error(f"Failed to auto-ban bot trap user {after.id}: {e}", exc_info=True)
return
channel = guild.get_channel(LOG_CHANNEL_ID)
if channel is not None and isinstance(channel, discord.abc.Messageable):
embed = discord.Embed(
@@ -61,6 +79,7 @@ class AutoBanOnRole(commands.Cog):
embed.set_thumbnail(url=after.display_avatar.url)
await channel.send(embed=embed)
else:
# Established member — warn and remove the trap role instead of banning
warning_message = (
f"**WARNING!** You were given a role that is designed to auto-ban bots. "
f"Since you're an established member, the role has been removed instead. "
@@ -75,8 +94,9 @@ class AutoBanOnRole(commands.Cog):
pass
await after.remove_roles(trap_role, reason="Auto removed trap role from established member")
if not dm_sent:
# Fall back to pinging them in a public channel if DMs are closed
fallback_channel = guild.get_channel(FALLBACK_CHANNEL_ID)
if fallback_channel is not None and isinstance(fallback_channel, discord.abc.Messageable):
await fallback_channel.send(f"{after.mention}\n{warning_message}")
@@ -100,5 +120,6 @@ class AutoBanOnRole(commands.Cog):
async def on_ready(self):
logger.info("AutoBanOnRole cog loaded and ready!")
async def setup(bot: commands.Bot):
await bot.add_cog(AutoBanOnRole(bot))
await bot.add_cog(AutoBanOnRole(bot))

View File

@@ -1,16 +1,28 @@
import discord
from discord.ext import commands
class ChainDetector(commands.Cog):
"""Cog that detects message chains and mirrors them once a third user joins in.
A chain is defined as 3 different users in the same channel sending the
exact same text (or the same sticker). When the third unique participant
is detected, the bot echoes the message, then resets the chain state so
subsequent posts don't trigger it again.
Messages with attachments or any kind of mention (user, role, channel,
everyone) are excluded to prevent abuse.
"""
def __init__(self, bot):
self.bot = bot
# Stores per-channel:
# Stores per-channel state:
# {channel_id: {"last_message": str, "users": [user_ids]}}
self.cache = {}
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
# Ignore bot messages
"""Track messages for chain detection and echo on the third participant."""
if message.author.bot:
return
@@ -25,6 +37,7 @@ class ChainDetector(commands.Cog):
if message.stickers:
sticker = message.stickers[0]
chain_key = f"sticker:{sticker.id}"
async def send_chain():
await message.channel.send(stickers=[sticker])
else:
@@ -32,12 +45,13 @@ class ChainDetector(commands.Cog):
if not content:
return
chain_key = content
async def send_chain():
await message.channel.send(chain_key)
channel_id = message.channel.id
# Initialize cache for this channel
# Initialize cache for this channel on first message
if channel_id not in self.cache:
self.cache[channel_id] = {
"last_message": chain_key,
@@ -47,22 +61,22 @@ class ChainDetector(commands.Cog):
chain = self.cache[channel_id]
# If message matches the chain message
if chain_key == chain["last_message"]:
# Only count if it's a DIFFERENT user
# Only count if it's a DIFFERENT user — same user repeating doesn't advance the chain
if message.author.id not in chain["users"]:
chain["users"].append(message.author.id)
else:
# Reset chain
# Different message breaks the chain — start fresh
chain["last_message"] = chain_key
chain["users"] = [message.author.id]
# If three different users said the same thing
# Echo on exactly the third unique participant
if len(chain["users"]) == 3:
await send_chain()
# Reset the chain completely
# Reset so a fourth+ message doesn't echo again
chain["last_message"] = ""
chain["users"] = []
async def setup(bot):
await bot.add_cog(ChainDetector(bot))
await bot.add_cog(ChainDetector(bot))

View File

@@ -22,14 +22,31 @@ DAILY_POST_TIME = time(hour=10, minute=0, tzinfo=timezone.utc)
class DailyFractal(commands.Cog):
"""Cog that posts a daily fractal image to a designated channel at 10:00 UTC.
Fetches fractal metadata and the image file from a local web service running
at WEBSITE_BASE. After posting, it automatically unpins the previous day's
fractal and pins the new one so only the latest post is pinned.
The ``!fractal`` command (owner only) triggers a manual post and silently
deletes the command message.
"""
def __init__(self, bot):
self.bot = bot
self.daily_fractal.start()
async def cog_unload(self):
"""Cancel the background loop when the cog unloads."""
self.daily_fractal.cancel()
async def _post_fractal(self):
"""Fetch and post today's fractal.
Hits the local API for metadata and the image, builds an embed,
replaces the pinned fractal from the previous day, then pins the
new message.
"""
channel = self.bot.get_channel(FRACTAL_CHANNEL_ID)
if channel is None or not isinstance(channel, discord.TextChannel):
logger.error("Daily fractal: channel not found")
@@ -60,6 +77,7 @@ class DailyFractal(commands.Cog):
fractal_name = meta.get("name") or "Unknown"
fractal_type = (meta.get("type") or "unknown").replace("_", " ").title()
seed = meta.get("seed")
# Derive palette from metadata, falling back to a deterministic seed-based lookup
palette = meta.get("palette") or (PALETTE_NAMES[seed % len(PALETTE_NAMES)] if seed is not None else "Unknown")
embed = discord.Embed(
@@ -75,6 +93,7 @@ class DailyFractal(commands.Cog):
file = discord.File(io.BytesIO(image_bytes), filename="fractal.png")
# Unpin the previous day's fractal so only the latest stays pinned
try:
pins = await channel.pins()
for pin in pins:
@@ -100,10 +119,12 @@ class DailyFractal(commands.Cog):
@tasks.loop(time=DAILY_POST_TIME)
async def daily_fractal(self):
"""Background task that fires once daily at DAILY_POST_TIME (10:00 UTC)."""
await self._post_fractal()
@commands.command(name="fractal")
async def fractal_command(self, ctx):
"""Manually trigger a fractal post (owner only). Deletes the command message."""
if ctx.author.id != OWNER_ID:
return
await ctx.message.delete()
@@ -111,6 +132,7 @@ class DailyFractal(commands.Cog):
@daily_fractal.before_loop
async def before_daily_fractal(self):
"""Wait until the bot is ready before the daily loop starts."""
await self.bot.wait_until_ready()

View File

@@ -11,13 +11,22 @@ load_dotenv()
logger = get_logger(__name__)
# Channels that receive commit notifications
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884]
WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "")
# Updated port to be 8000 to prevent conflicts (should work)
# I think the port itself wants me dead
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", 8000))
class GitWebhook(commands.Cog):
"""Cog that runs an aiohttp webhook server to receive GitHub/GitLab push events.
On cog load, starts a TCPSite on WEBHOOK_PORT (default 8000). Each push
event is formatted into a Discord embed and broadcast to all COMMIT_CHANNEL_IDS.
Signature verification uses HMAC-SHA256 with GITHUB_WEBHOOK_SECRET. If the
secret is empty, verification is skipped (useful for local testing).
"""
def __init__(self, bot):
self.bot = bot
self.app = web.Application()
@@ -25,7 +34,7 @@ class GitWebhook(commands.Cog):
self.app.router.add_get('/health', self.health_check)
self.runner = None
self.site = None
async def cog_load(self):
"""Start the webhook server when cog loads."""
self.runner = web.AppRunner(self.app)
@@ -34,7 +43,7 @@ class GitWebhook(commands.Cog):
await self.site.start()
logger.info(f"Git webhook server started on port {WEBHOOK_PORT}")
logger.info(f"Sending notifications to {len(COMMIT_CHANNEL_IDS)} channel(s)")
async def cog_unload(self):
"""Stop the webhook server when cog unloads."""
if self.site:
@@ -42,12 +51,16 @@ class GitWebhook(commands.Cog):
if self.runner:
await self.runner.cleanup()
logger.info("Git webhook server stopped")
def verify_signature(self, payload_body, signature_header):
"""Verify GitHub webhook signature for security."""
"""Verify GitHub webhook signature for security.
Uses HMAC-SHA256 with the configured secret. Returns True immediately
if no secret is configured (test/dev mode).
"""
if not WEBHOOK_SECRET:
return True
hash_object = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
msg=payload_body,
@@ -55,13 +68,19 @@ class GitWebhook(commands.Cog):
)
expected_signature = "sha256=" + hash_object.hexdigest()
return hmac.compare_digest(expected_signature, signature_header)
async def health_check(self, request):
"""Health check endpoint."""
"""Health check endpoint — returns 200 OK so uptime monitors can probe the server."""
return web.json_response({"status": "healthy"})
async def handle_webhook(self, request):
"""Handle incoming Git webhook from GitHub/GitLab."""
"""Handle incoming Git webhook from GitHub or GitLab.
Detects the payload format by inspecting top-level keys:
- GitHub ping: presence of ``zen`` and ``hook_id``.
- GitHub push: presence of ``commits`` and ``repository``.
- GitLab push: presence of ``commits`` and ``project``.
"""
try:
# Verify signature if secret is configured
if WEBHOOK_SECRET:
@@ -72,7 +91,7 @@ class GitWebhook(commands.Cog):
data = await request.json()
else:
data = await request.json()
# Handle GitHub ping event (test from GitHub)
if 'zen' in data and 'hook_id' in data:
logger.info("Received GitHub ping event - webhook is configured correctly!")
@@ -90,44 +109,47 @@ class GitWebhook(commands.Cog):
if not channels:
logger.error("No valid channels found!")
return web.json_response({"error": "No channels found"}, status=500)
# Handle GitHub push events
if 'commits' in data and 'repository' in data:
await self.handle_github_push(data, channels)
return web.json_response({"status": "success"}, status=200)
# Handle GitLab push events
elif 'project' in data and 'commits' in data:
await self.handle_gitlab_push(data, channels)
return web.json_response({"status": "success"}, status=200)
logger.warning(f"Unknown webhook format. Keys in data: {list(data.keys())}")
return web.json_response({"error": "Unknown webhook format"}, status=400)
except Exception as e:
logger.error(f"Webhook error: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def handle_github_push(self, data, channels):
"""Handle GitHub push webhook."""
"""Format a GitHub push payload as a Discord embed and broadcast it.
Single commits use field-based formatting for full message visibility.
Multi-commit pushes use a description list capped at 10 entries.
"""
repo_name = data['repository']['full_name']
repo_url = data['repository']['html_url']
branch = data['ref'].split('/')[-1]
pusher = data['pusher']['name']
commits = data['commits']
compare_url = data.get('compare', '')
if not commits:
return
# For single commit, use fields for better formatting
if len(commits) == 1:
commit = commits[0]
short_sha = commit['id'][:7]
message = commit['message']
author = commit['author']['name']
url = commit['url']
embed = discord.Embed(
title=f"📝 [{repo_name}:{branch}] New commit",
url=compare_url if compare_url else repo_url,
@@ -151,46 +173,48 @@ class GitWebhook(commands.Cog):
author = commit['author']['name']
url = commit['url']
commit_lines.append(f"[`{short_sha}`]({url}) {message} - {author}")
embed = discord.Embed(
title=f"📝 [{repo_name}:{branch}] {len(commits)} new commits",
url=compare_url if compare_url else repo_url,
description="\n".join(commit_lines),
color=discord.Color.blue()
)
if len(commits) > 10:
embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*"
embed.set_author(name=pusher, icon_url="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png")
embed.set_footer(text="GitHub")
# Send to all Discord channels
for channel in channels:
try:
await channel.send(embed=embed)
except Exception as e:
logger.error(f"Failed to send to channel {channel.id}: {e}")
async def handle_gitlab_push(self, data, channels):
"""Handle GitLab push webhook."""
"""Format a GitLab push payload as a Discord embed and broadcast it.
Mirrors the GitHub handler format but uses GitLab orange as the embed
colour and omits the GitHub icon from the author field.
"""
repo_name = data['project']['path_with_namespace']
repo_url = data['project']['web_url']
branch = data['ref'].split('/')[-1]
pusher = data['user_name']
commits = data['commits']
if not commits:
return
# For single commit, use fields for better formatting
if len(commits) == 1:
commit = commits[0]
short_sha = commit['id'][:7]
message = commit['message']
author = commit['author']['name']
url = commit['url']
embed = discord.Embed(
title=f"📝 [{repo_name}:{branch}] New commit",
url=repo_url,
@@ -214,26 +238,26 @@ class GitWebhook(commands.Cog):
author = commit['author']['name']
url = commit['url']
commit_lines.append(f"[`{short_sha}`]({url}) {message} - {author}")
embed = discord.Embed(
title=f"📝 [{repo_name}:{branch}] {len(commits)} new commits",
url=repo_url,
description="\n".join(commit_lines),
color=0xFC6D26 # GitLab orange
)
if len(commits) > 10:
embed.description += f"\n\n*...and {len(commits) - 10} more commit(s)*"
embed.set_author(name=pusher)
embed.set_footer(text="GitLab")
# Send to all Discord channels
for channel in channels:
try:
await channel.send(embed=embed)
except Exception as e:
logger.error(f"Failed to send to channel {channel.id}: {e}")
async def setup(bot):
await bot.add_cog(GitWebhook(bot))
await bot.add_cog(GitWebhook(bot))

View File

@@ -1,21 +1,22 @@
import discord
from discord.ext import commands
class GoodBotListener(commands.Cog):
"""Responds to 'good bot' mentions."""
"""Responds to 'good bot' mentions with a blush emote."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
# Ignore bot's own messages
"""Reply with a blush emote when someone mentions the bot and says 'good bot'."""
if message.author.bot:
return
# Check if bot is mentioned and message contains "good bot" (case insensitive)
if self.bot.user and self.bot.user.mentioned_in(message) and "good bot" in message.content.lower():
await message.reply("<:CatgirlLacieBlush:1283389963018440754>")
async def setup(bot: commands.Bot):
await bot.add_cog(GoodBotListener(bot))
await bot.add_cog(GoodBotListener(bot))

View File

@@ -4,19 +4,33 @@ from discord.ext import commands
from pathlib import Path
import aiosqlite
# Role assigned to users who have opted out of pings
NO_PINGS_ROLE_ID = 1439583411517001819
# Role assigned to users who have opted in to receiving pings
PINGS_OK_ROLE_ID = 1439583327844827227
# Only this user gets ping tracking in the DB (everyone with NO_PINGS_ROLE gets the reply)
PROTECTED_USER_ID = 252130669919076352 # only lilac gets ping tracking
DB_PATH = Path(__file__).parent.parent / "data" / "ping_protect.db"
class PingProtect(commands.GroupCog, name="noping"):
"""GroupCog providing the /noping slash command group and ping detection.
Intercepts messages that mention a protected user (anyone with the no-pings
role, or PROTECTED_USER_ID) and replies reminding the sender not to ping
them, unless the sender is on that user's allowlist.
Slash commands (/noping allow, remove, list, permitted) let protected users
manage their own allowlists.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
async def cog_load(self):
"""Create DB tables on first load if they don't exist yet."""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS ping_counts (
@@ -34,12 +48,18 @@ class PingProtect(commands.GroupCog, name="noping"):
await db.commit()
def _has_permission(self, member: discord.Member) -> bool:
"""Return True if the member may manage their own ping allowlist.
Covers both PROTECTED_USER_ID (always permitted) and anyone who holds
NO_PINGS_ROLE_ID.
"""
return (
member.id == PROTECTED_USER_ID or
any(r.id == NO_PINGS_ROLE_ID for r in member.roles)
)
async def _is_allowed(self, protected_user_id: int, pinger_id: int) -> bool:
"""Return True if pinger_id is on protected_user_id's allowlist."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"SELECT 1 FROM allowlists WHERE protected_user_id = ? AND allowed_user_id = ?",
@@ -48,6 +68,7 @@ class PingProtect(commands.GroupCog, name="noping"):
return await cursor.fetchone() is not None
def _get_subject_pronoun(self, member: discord.Member | None) -> str:
"""Derive a subject pronoun for the member from their pronoun roles."""
if not member:
return "They"
role_names = {r.name.lower() for r in member.roles}
@@ -60,16 +81,19 @@ class PingProtect(commands.GroupCog, name="noping"):
return "They"
def _get_verb(self, pronoun: str) -> str:
"""Return the correct conjugation of 'have' for the given pronoun."""
return "have" if pronoun == "They" else "has"
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Detect pings to protected users and reply with a reminder."""
if message.author.bot or not message.guild:
return
for user in message.mentions:
member = message.guild.get_member(user.id)
# Skip if the mentioned user has opted in to receiving pings
if member and any(r.id == PINGS_OK_ROLE_ID for r in member.roles):
continue
@@ -80,10 +104,12 @@ class PingProtect(commands.GroupCog, name="noping"):
if not is_protected:
continue
# Allowlisted senders may always ping the protected user
if await self._is_allowed(user.id, message.author.id):
continue
if user.id == PROTECTED_USER_ID:
# Track ping count for the specific protected user
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
INSERT INTO ping_counts (user_id, count) VALUES (?, 1)
@@ -102,6 +128,7 @@ class PingProtect(commands.GroupCog, name="noping"):
@app_commands.command(name="allow", description="Allow someone to ping you")
@app_commands.describe(user="The user to allow")
async def allow(self, interaction: discord.Interaction, user: discord.Member):
"""Add a user to your ping allowlist."""
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
return
@@ -118,6 +145,7 @@ class PingProtect(commands.GroupCog, name="noping"):
@app_commands.command(name="remove", description="Remove someone from your ping allowlist")
@app_commands.describe(user="The user to remove")
async def remove(self, interaction: discord.Interaction, user: discord.Member):
"""Remove a user from your ping allowlist."""
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
return
@@ -133,6 +161,7 @@ class PingProtect(commands.GroupCog, name="noping"):
@app_commands.command(name="list", description="View your ping allowlist")
async def list_allowed(self, interaction: discord.Interaction):
"""List everyone on your ping allowlist."""
if not isinstance(interaction.user, discord.Member) or not self._has_permission(interaction.user):
await interaction.response.send_message("You need the no-pings role to use this.", ephemeral=True)
return
@@ -153,6 +182,7 @@ class PingProtect(commands.GroupCog, name="noping"):
@app_commands.command(name="permitted", description="See who has given you permission to ping them")
async def permitted(self, interaction: discord.Interaction):
"""List all users who have added you to their allowlist."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"SELECT protected_user_id FROM allowlists WHERE allowed_user_id = ?",

View File

@@ -1,15 +1,34 @@
import discord
from discord.ext import commands
class Welcome(commands.Cog):
"""Cog that sends a welcome DM and channel message when a new member completes onboarding.
Two triggers fire _send_welcome: receiving their first real role (excluding
@everyone) or the Discord pending/membership-screening flag clearing. A
deduplication set ensures the welcome fires at most once per member per
bot session, even if both triggers fire for the same join.
Members holding the bot-trap role are silently skipped so bots never receive
a welcome before being auto-banned.
"""
def __init__(self, bot):
self.bot = bot
# In-memory dedup set — reset on restart, which is fine for a per-session guard
self._welcomed: set[int] = set()
async def _send_welcome(self, member):
"""Send the welcome DM and channel message for a newly onboarded member.
Skips silently if the member has already been welcomed this session or
holds the bot-trap role. Falls back to a channel ping if the DM fails.
"""
if member.id in self._welcomed:
return
self._welcomed.add(member.id)
bot_trap_role_id = 1439354601672282335
if any(role.id == bot_trap_role_id for role in member.roles):
return
@@ -46,6 +65,7 @@ class Welcome(commands.Cog):
@commands.Cog.listener()
async def on_member_update(self, before, after):
"""Fire the welcome on first real role assignment or onboarding completion."""
# Trigger 1: member receives their first real role (onboarding assigned a role)
before_roles = [r for r in before.roles if r.name != "@everyone"]
after_roles = [r for r in after.roles if r.name != "@everyone"]
@@ -57,5 +77,6 @@ class Welcome(commands.Cog):
if first_role or completed_pending:
await self._send_welcome(after)
async def setup(bot):
await bot.add_cog(Welcome(bot))
await bot.add_cog(Welcome(bot))

View File

@@ -68,6 +68,7 @@ class MinesweeperGame:
self.game_over = False
self.won = False
self.first_move = True
self.detonated_cell: Optional[tuple[int, int]] = None # the mine the player clicked on
# 11-13 use custom bot emojis
self.col_emojis = [
@@ -112,6 +113,7 @@ class MinesweeperGame:
if self.board[row][col] == -1:
self.game_over = True
self.detonated_cell = (row, col)
return True
if self.board[row][col] == 0:
@@ -167,6 +169,9 @@ class MinesweeperGame:
def get_cell_display(self, row: int, col: int, show_all: bool = False) -> str:
if show_all and self.board[row][col] == -1:
# The cell the player clicked shows 💥; all other mines show 💣
if self.detonated_cell == (row, col):
return "💥"
return "💣"
if self.flags[row][col]:

View File

@@ -3,12 +3,27 @@ from discord.ext import commands
from discord.ui import View, Button
from .loader import ModerationBase
class BanCommand(ModerationBase):
"""Cog providing the !ban prefix command."""
@commands.command(name="ban")
@ModerationBase.is_admin()
async def ban(self, ctx, user: discord.User | discord.Member | str, *, reason: str | None = None):
"""Ban a user (even if not in the server) with confirmation and log infraction"""
# Convert raw ID or mention to user object if needed
"""Ban a user from the server with a confirmation prompt.
The user does not need to be in the server — a raw user ID or
mention also works. A DM is sent to the user before banning and
an infraction record is written to the database.
Parameters
----------
user:
The user to ban (mention, ID, or User/Member object).
reason:
Optional reason for the ban.
"""
# Resolve a raw ID string or mention into a User object
if isinstance(user, str):
user_id = user.strip("<@!>")
try:
@@ -17,7 +32,6 @@ class BanCommand(ModerationBase):
await ctx.send("Could not find that user. Please provide a valid mention or ID.")
return
# Ask for confirmation
view = View(timeout=30)
confirmed = {"value": False}
@@ -44,8 +58,9 @@ class BanCommand(ModerationBase):
view.add_item(yes_button)
view.add_item(no_button)
user_ref = user.mention if hasattr(user, "mention") else str(user)
await ctx.send(
f"Are you sure you want to ban {user.mention if hasattr(user, 'mention') else user}? "
f"Are you sure you want to ban {user_ref}? "
f"Reason: {reason or 'No reason provided'}",
view=view
)
@@ -57,32 +72,29 @@ class BanCommand(ModerationBase):
if not ctx.guild:
return
# Attempt to DM user
# Attempt to DM the user before banning so they can still receive the message
try:
if isinstance(user, discord.User):
await user.send(
f"You have been **banned** from **{ctx.guild.name}**.\n"
f"Reason: {reason or 'No reason provided'}\n\n"
f"If you believe this ban was unfiair and would like to appeal, join here: https://discord.gg/FYpfBzpjvq"
)
await user.send(
f"You have been **banned** from **{ctx.guild.name}**.\n"
f"Reason: {reason or 'No reason provided'}\n\n"
f"If you believe this ban was unfair and would like to appeal, join here: https://discord.gg/FYpfBzpjvq"
)
except Exception:
await ctx.send("Could not DM the user.")
# Perform the ban
try:
await ctx.guild.ban(discord.Object(id=user.id), reason=reason)
await ctx.send(f"{user.mention if hasattr(user, 'mention') else user} has been banned.")
await ctx.send(f"{user_ref} has been banned.")
except Exception as e:
await ctx.send(f"Failed to ban user: `{e}`")
return
# Log infraction
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "ban", reason)
# Log to logging system if available
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(ctx.guild.id, "ban", user, ctx.author, reason)
async def setup(bot: commands.Bot):
await bot.add_cog(BanCommand(bot))

View File

@@ -1,20 +1,45 @@
import io
import discord
from datetime import datetime, timezone, timedelta
from discord.ext import commands
from discord.ui import View, Button
from .loader import ModerationBase
class CleanBanCommand(ModerationBase):
"""Cog providing the !cleanban prefix command."""
@commands.command(name="cleanban")
@ModerationBase.is_admin()
async def cleanban(self, ctx, user: discord.User | discord.Member | str, days: int = 1, *, reason: str | None = None):
"""Ban a user and delete their messages from past specified days (1-7)"""
async def cleanban(
self,
ctx,
user: discord.User | discord.Member | str,
days: int = 1,
*,
reason: str | None = None
):
"""Ban a user and delete their recent messages.
# Discord only supports 1-7 for delete_message_days
Before banning, this command iterates all text channels and collects
the user's messages from the past `days` days for the audit log.
Discord then purges those messages via the delete_message_days parameter.
Parameters
----------
user:
The user to ban (mention, ID, or User/Member object).
days:
Number of days of message history to delete (17, default 1).
reason:
Optional reason for the ban.
"""
# Discord's ban API only supports 17 days of message deletion
if days < 1 or days > 7:
await ctx.send("Days must be between 1 and 7.")
return
# Convert raw ID or mention to user object if needed
# Resolve a raw ID string or mention into a User object
if isinstance(user, str):
user_id = user.strip("<@!>")
try:
@@ -23,7 +48,6 @@ class CleanBanCommand(ModerationBase):
await ctx.send("Could not find that user. Please provide a valid mention or ID.")
return
# Ask for confirmation — extra warning since this also wipes their message history
view = View(timeout=30)
confirmed = {"value": False}
@@ -50,8 +74,9 @@ class CleanBanCommand(ModerationBase):
view.add_item(yes_button)
view.add_item(no_button)
user_ref = user.mention if hasattr(user, "mention") else str(user)
await ctx.send(
f"Are you sure you want to cleanban {user.mention if hasattr(user, 'mention') else user}?\n"
f"Are you sure you want to cleanban {user_ref}?\n"
f"**This will delete their messages from the past {days} day(s) and ban them.**\n"
f"Reason: {reason or 'No reason provided'}",
view=view
@@ -64,18 +89,42 @@ class CleanBanCommand(ModerationBase):
if not ctx.guild:
return
# DM before banning so they receive it before we delete them from the server
# Collect the user's messages BEFORE banning so they can be preserved in the audit log
collect_status = await ctx.send("📋 Collecting message history for audit log before banning...")
deleted_messages = []
cutoff_dt = datetime.now(timezone.utc) - timedelta(days=days)
for channel in ctx.guild.text_channels:
if not channel.permissions_for(ctx.guild.me).read_message_history:
continue
try:
async for msg in channel.history(limit=None, after=cutoff_dt, oldest_first=True):
if msg.author.id == user.id:
deleted_messages.append({
"channel": channel.name,
"channel_id": channel.id,
"message_id": msg.id,
"timestamp": msg.created_at.strftime("%Y-%m-%d %H:%M:%S UTC"),
"content": msg.content or "",
"attachments": [a.url for a in msg.attachments],
})
except (discord.Forbidden, discord.HTTPException):
pass
try:
if isinstance(user, discord.User):
await user.send(
f"You have been **banned** from **{ctx.guild.name}**.\n"
f"Messages from the past {days} day(s) have been deleted.\n"
f"Reason: {reason or 'No reason provided'}\n\n"
)
await collect_status.delete()
except discord.HTTPException:
pass
# DM before banning so the message is delivered while the user is still in the server
try:
await user.send(
f"You have been **banned** from **{ctx.guild.name}**.\n"
f"Messages from the past {days} day(s) have been deleted.\n"
f"Reason: {reason or 'No reason provided'}"
)
except Exception:
await ctx.send("Could not DM the user.")
# Perform the ban with message deletion
try:
await ctx.guild.ban(
discord.Object(id=user.id),
@@ -83,20 +132,21 @@ class CleanBanCommand(ModerationBase):
delete_message_days=days
)
await ctx.send(
f"{user.mention if hasattr(user, 'mention') else user} has been banned.\n"
f"{user_ref} has been banned.\n"
f"Messages from the past {days} day(s) have been deleted."
+ (f" ({len(deleted_messages)} message(s) logged)" if deleted_messages else "")
)
except Exception as e:
await ctx.send(f"Failed to ban user: `{e}`")
return
# Log infraction
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "cleanban", reason)
# Log to logging system if available
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(ctx.guild.id, "cleanban", user, ctx.author, reason)
await logger.log_ban_messages(ctx.guild.id, user, deleted_messages, days)
async def setup(bot: commands.Bot):
await bot.add_cog(CleanBanCommand(bot))

View File

@@ -6,10 +6,26 @@ from utils.logger import get_logger
logger = get_logger(__name__)
class InfractionCommand(ModerationBase):
"""Cog providing the !inf command for querying and managing infractions.
Subcommands:
- ``search <user_id>`` — list active infractions for a user.
- ``search_full <user_id>`` — full history including removed infractions.
- ``list`` — all active infractions in the server.
- ``delete <id>`` — permanently delete an infraction with notify/silent choice.
- ``resend <id>`` — re-send the auto-removal approval embed for an infraction.
Also manages an automatic infraction removal pipeline: every 24 hours,
users whose most recent infraction is 4+ months old and who have stayed
in the server without re-offending are sent to an approval channel for
staff review. Persistent views are re-registered on restart via cog_load.
"""
def __init__(self, bot: commands.Bot):
super().__init__(bot)
# Channel where auto-removal approval embeds are sent for staff review
self.approval_channel_id = 1467901790333960377
self.migrate_existing_infractions()
self.check_auto_removals.start()
@@ -20,7 +36,12 @@ class InfractionCommand(ModerationBase):
await super().cog_unload()
async def cog_load(self):
"""Re-register persistent views for all pending approval messages."""
"""Re-register persistent views for all pending approval messages.
Called automatically by discord.py when the cog loads. Queries the DB
for infractions that were already sent to the approval channel so their
buttons remain functional after a bot restart.
"""
self.c.execute("""
SELECT id, user_id, guild_id, type, reason, timestamp, moderator_id, approval_message_id
FROM infractions
@@ -37,31 +58,35 @@ class InfractionCommand(ModerationBase):
self.bot.add_view(view, message_id=msg_id)
def migrate_existing_infractions(self):
"""Add new columns to existing infractions table for auto-removal system."""
"""Add new columns to existing infractions table for auto-removal system.
Uses ALTER TABLE ... ADD COLUMN and silently catches errors for columns
that already exist, making this safe to run on every startup.
"""
try:
# Add removed column (0 = active, 1 = removed)
self.c.execute("ALTER TABLE infractions ADD COLUMN removed INTEGER DEFAULT 0")
except Exception:
pass # Column already exists
try:
# Add removed_date column
self.c.execute("ALTER TABLE infractions ADD COLUMN removed_date TEXT")
except Exception:
pass
try:
# Add removed_by column (moderator who approved removal)
self.c.execute("ALTER TABLE infractions ADD COLUMN removed_by INTEGER")
except Exception:
pass
try:
# Add skip_auto_removal column (1 = staff chose to keep it, skip future checks)
self.c.execute("ALTER TABLE infractions ADD COLUMN skip_auto_removal INTEGER DEFAULT 0")
except Exception:
pass
try:
# Add pending_approval column (1 = approval request already sent, waiting for staff decision)
self.c.execute("ALTER TABLE infractions ADD COLUMN pending_approval INTEGER DEFAULT 0")
@@ -78,15 +103,19 @@ class InfractionCommand(ModerationBase):
@tasks.loop(hours=24)
async def check_auto_removals(self):
"""Background task that runs every 24 hours to check for eligible infraction removals."""
"""Background task that runs every 24 hours to check for eligible infraction removals.
Iterates every guild and user with active infractions. For each user,
delegates to check_user_eligibility which applies the 4-month rule.
"""
try:
self.c.execute("SELECT DISTINCT guild_id FROM infractions WHERE removed=0 AND skip_auto_removal=0 AND pending_approval=0")
guilds = [row[0] for row in self.c.fetchall()]
for guild_id in guilds:
self.c.execute("""
SELECT DISTINCT user_id
FROM infractions
SELECT DISTINCT user_id
FROM infractions
WHERE guild_id=? AND removed=0 AND skip_auto_removal=0 AND pending_approval=0
""", (guild_id,))
users = [row[0] for row in self.c.fetchall()]
@@ -103,13 +132,27 @@ class InfractionCommand(ModerationBase):
await self.bot.wait_until_ready()
async def check_user_eligibility(self, guild_id: int, user_id: int):
"""Check if a user's most recent infraction is eligible for removal."""
"""Check if a user's most recent infraction is eligible for auto-removal.
Eligibility criteria (all must be true):
- User is not banned.
- User is currently in the server.
- Most recent infraction is more than 4 months (120 days) old.
- User has not left and rejoined since the infraction (clock resets on rejoin).
- User has no newer infractions of any kind.
Parameters
----------
guild_id:
Guild to check eligibility within.
user_id:
User to check.
"""
try:
# Get guild
guild = self.bot.get_guild(guild_id)
if not guild:
return
# Check if user is banned
try:
ban = await guild.fetch_ban(discord.Object(id=user_id))
@@ -119,7 +162,7 @@ class InfractionCommand(ModerationBase):
pass # User is not banned, continue
except Exception:
pass # Error checking ban, continue anyway
# Check if user is in the server
member = guild.get_member(user_id)
if not member:
@@ -133,11 +176,11 @@ class InfractionCommand(ModerationBase):
ORDER BY timestamp DESC
LIMIT 1
""", (user_id, guild_id))
most_recent = self.c.fetchone()
if not most_recent:
return # No active infractions
inf_id, timestamp_str, inf_type, reason, mod_id = most_recent
infraction_date = datetime.fromisoformat(timestamp_str)
@@ -153,52 +196,59 @@ class InfractionCommand(ModerationBase):
joined_naive = joined_at.replace(tzinfo=None)
if joined_naive > infraction_date:
return # Left and rejoined after infraction, not eligible
# Check if user got ANY infractions after this one
self.c.execute("""
SELECT COUNT(*)
SELECT COUNT(*)
FROM infractions
WHERE user_id=? AND guild_id=? AND timestamp > ?
""", (user_id, guild_id, timestamp_str))
newer_infractions = self.c.fetchone()[0]
if newer_infractions > 0:
return # User got infractions after this one, not eligible
# User is eligible! Send to staff for approval
# User is eligible — send to staff for approval
await self.send_removal_approval(guild_id, user_id, inf_id, inf_type, reason, timestamp_str, mod_id)
except Exception as e:
logger.error(f"Error checking eligibility for user {user_id} in guild {guild_id}: {e}", exc_info=True)
async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int,
async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int,
inf_type: str, reason: str, timestamp: str, mod_id: int):
"""Send an infraction removal request to the approval channel."""
"""Send an infraction removal request to the approval channel.
Also marks the infraction as pending_approval and stores the alert
message ID so the view can be re-registered on the next restart.
Parameters
----------
guild_id, user_id, inf_id, inf_type, reason, timestamp, mod_id:
Infraction details used to build the approval embed.
"""
try:
approval_channel = self.bot.get_channel(self.approval_channel_id)
if not approval_channel or not isinstance(approval_channel, discord.abc.Messageable):
logger.error(f"Approval channel {self.approval_channel_id} not found")
return
# Get guild, user, and moderator info
guild = self.bot.get_guild(guild_id)
if not guild:
return
try:
user = await self.bot.fetch_user(user_id)
user_tag = f"{user.name}#{user.discriminator}"
except Exception:
user_tag = f"Unknown User ({user_id})"
try:
moderator = await self.bot.fetch_user(mod_id)
mod_tag = f"{moderator.name}#{moderator.discriminator}"
except Exception:
mod_tag = f"Unknown Mod ({mod_id})"
# Create approval embed
embed = discord.Embed(
title="🔔 Infraction Eligible for Auto-Removal",
description=f"This user has stayed clean for 4 months. Should this infraction be removed?",
@@ -212,10 +262,9 @@ class InfractionCommand(ModerationBase):
embed.add_field(name="Original Date", value=timestamp.replace("T", " ")[:19], inline=True)
embed.add_field(name="Original Moderator", value=mod_tag, inline=True)
embed.set_footer(text=f"User ID: {user_id} | Guild ID: {guild_id}")
# Create approval view
view = InfractionRemovalView(self, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp)
msg = await approval_channel.send(embed=embed, view=view)
# Mark infraction as pending and store message ID for view re-registration on restart
@@ -225,13 +274,22 @@ class InfractionCommand(ModerationBase):
WHERE id=?
""", (msg.id, inf_id))
self.conn.commit()
except Exception as e:
logger.error(f"Error sending removal approval: {e}", exc_info=True)
@commands.command(name="inf")
@ModerationBase.is_admin()
async def inf(self, ctx, action: str, *args):
"""Query and manage infraction records.
Parameters
----------
action:
One of: ``search``, ``search_full``, ``list``, ``delete``, ``resend``.
args:
Additional arguments depending on the action (user_id or infraction_id).
"""
action = action.lower()
if action == "search":
@@ -307,11 +365,10 @@ class InfractionCommand(ModerationBase):
mod_tag = f"{moderator.name}#{moderator.discriminator}"
timestamp = row[5].replace("T", " ")[:19]
reason = row[3] or "None"
# Check if removed
is_removed = row[6] if len(row) > 6 else 0
removed_date = row[7] if len(row) > 7 and row[7] else ""
if is_removed:
status = f"Removed ({removed_date.replace('T', ' ')[:19]})" if removed_date else "Removed"
else:
@@ -560,6 +617,13 @@ class InfractionDeleteView(discord.ui.View):
self.guild = guild
async def _do_delete(self, interaction: discord.Interaction, notify: bool):
"""Perform the deletion, optionally DM-ing the user, and log to the mod audit trail.
Parameters
----------
notify:
If True, attempt to send the user a DM with infraction details.
"""
try:
self.cog.c.execute("DELETE FROM infractions WHERE id=? AND guild_id=?", (self.inf_id, self.guild.id))
self.cog.conn.commit()
@@ -593,22 +657,47 @@ class InfractionDeleteView(discord.ui.View):
await interaction.response.edit_message(content=result_text, embed=None, view=None)
# Log the deletion to the mod log for audit trail
log_cog = self.cog.bot.get_cog("Logger")
if log_cog:
audit_embed = discord.Embed(
title="Infraction Manually Deleted",
color=discord.Color.orange(),
timestamp=datetime.utcnow()
)
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
audit_embed.add_field(name="Deleted By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
audit_embed.add_field(name="User Notified", value="Yes" if notify else "No", inline=True)
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
await log_cog.send_log(self.guild.id, "infraction_modify", audit_embed)
@discord.ui.button(label="Delete & Notify", style=discord.ButtonStyle.green)
async def delete_notify(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Delete the infraction and DM the user."""
await self._do_delete(interaction, notify=True)
@discord.ui.button(label="Delete Silently", style=discord.ButtonStyle.grey)
async def delete_silent(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Delete the infraction without notifying the user."""
await self._do_delete(interaction, notify=False)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Abort the deletion."""
await interaction.response.edit_message(content="Deletion cancelled.", embed=None, view=None)
class InfractionRemovalView(discord.ui.View):
"""View for approving or denying infraction auto-removals."""
"""View for approving or denying infraction auto-removals.
Uses timeout=None so the view stays active indefinitely after restarts
(it is manually re-registered via cog_load using the stored message ID).
"""
def __init__(self, cog, inf_id, user_id, guild_id, user_tag, inf_type, reason, timestamp):
super().__init__(timeout=None)
self.cog = cog
@@ -619,10 +708,10 @@ class InfractionRemovalView(discord.ui.View):
self.inf_type = inf_type
self.reason = reason
self.timestamp = timestamp
@discord.ui.button(label="Remove Infraction", style=discord.ButtonStyle.green, custom_id="approve_removal")
async def approve_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Approve the removal - mark infraction as removed."""
"""Approve the removal mark infraction as removed and notify the user."""
try:
import sqlite3
conn = sqlite3.connect(self.cog.db_path)
@@ -635,8 +724,7 @@ class InfractionRemovalView(discord.ui.View):
conn.commit()
finally:
conn.close()
# Update embed
embed = discord.Embed(
title="✅ Infraction Removed",
description="This infraction has been removed from the user's active record.",
@@ -649,10 +737,27 @@ class InfractionRemovalView(discord.ui.View):
embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
embed.add_field(name="Removed By", value=interaction.user.mention, inline=True)
embed.add_field(name="Removed At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True)
await interaction.response.edit_message(embed=embed, view=None)
# Notify user
# Log to mod audit trail
log_cog = self.cog.bot.get_cog("Logger")
if log_cog:
audit_embed = discord.Embed(
title="Infraction Auto-Removal Approved",
color=discord.Color.green(),
timestamp=datetime.utcnow()
)
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
audit_embed.add_field(name="Approved By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
await log_cog.send_log(self.guild_id, "infraction_modify", audit_embed)
# Notify user of the good news
try:
user = await self.cog.bot.fetch_user(self.user_id)
guild = self.cog.bot.get_guild(self.guild_id)
@@ -669,17 +774,17 @@ class InfractionRemovalView(discord.ui.View):
notify_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
notify_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
notify_embed.set_footer(text=f"You stayed clean for 4 months! Keep up the good behavior.")
await user.send(embed=notify_embed)
except Exception:
pass # User has DMs disabled or bot can't reach them
except Exception as e:
await interaction.response.send_message(f"Error removing infraction: {e}", ephemeral=True)
@discord.ui.button(label="Keep Infraction", style=discord.ButtonStyle.red, custom_id="deny_removal")
async def deny_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Deny the removal - mark to skip future auto-removal checks."""
"""Deny the removal mark to skip future auto-removal checks for this infraction."""
try:
import sqlite3
conn = sqlite3.connect(self.cog.db_path)
@@ -692,8 +797,7 @@ class InfractionRemovalView(discord.ui.View):
conn.commit()
finally:
conn.close()
# Update embed
embed = discord.Embed(
title="❌ Removal Denied",
description="This infraction will remain active and will not be checked for auto-removal again.",
@@ -706,12 +810,29 @@ class InfractionRemovalView(discord.ui.View):
embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
embed.add_field(name="Decision By", value=interaction.user.mention, inline=True)
embed.add_field(name="Decision At", value=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), inline=True)
await interaction.response.edit_message(embed=embed, view=None)
# Log to mod audit trail
log_cog = self.cog.bot.get_cog("Logger")
if log_cog:
audit_embed = discord.Embed(
title="Infraction Auto-Removal Denied",
color=discord.Color.red(),
timestamp=datetime.utcnow()
)
audit_embed.add_field(name="Infraction ID", value=str(self.inf_id), inline=True)
audit_embed.add_field(name="Type", value=self.inf_type, inline=True)
audit_embed.add_field(name="Original Reason", value=self.reason or "None", inline=False)
audit_embed.add_field(name="Original Date", value=self.timestamp.replace("T", " ")[:19], inline=True)
audit_embed.add_field(name="Target User ID", value=str(self.user_id), inline=True)
audit_embed.add_field(name="Denied By", value=f"{interaction.user.mention} ({interaction.user})", inline=False)
audit_embed.set_footer(text=f"Mod ID: {interaction.user.id} | User ID: {self.user_id}")
await log_cog.send_log(self.guild_id, "infraction_modify", audit_embed)
except Exception as e:
await interaction.response.send_message(f"Error denying removal: {e}", ephemeral=True)
async def setup(bot: commands.Bot):
await bot.add_cog(InfractionCommand(bot))
await bot.add_cog(InfractionCommand(bot))

View File

@@ -3,11 +3,25 @@ from discord.ext import commands
from discord.ui import View, Button
from .loader import ModerationBase
class KickCommand(ModerationBase):
"""Cog providing the !kick prefix command."""
@commands.command(name="kick")
@ModerationBase.is_admin()
async def kick(self, ctx, user: discord.Member, *, reason: str | None = None):
"""Kick a user with confirmation and log infraction"""
"""Kick a member from the server with a confirmation prompt.
Sends a DM to the user before kicking and writes an infraction record
to the database.
Parameters
----------
user:
The server member to kick.
reason:
Optional reason for the kick.
"""
view = View(timeout=30)
confirmed = {"value": False}
@@ -34,7 +48,10 @@ class KickCommand(ModerationBase):
view.add_item(yes_button)
view.add_item(no_button)
await ctx.send(f"Are you sure you want to kick {user.mention}? Reason: {reason or 'No reason provided'}", view=view)
await ctx.send(
f"Are you sure you want to kick {user.mention}? Reason: {reason or 'No reason provided'}",
view=view
)
await view.wait()
if not confirmed["value"]:
return
@@ -43,20 +60,21 @@ class KickCommand(ModerationBase):
return
try:
await user.send(f"You have been **kicked** from **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}")
except Exception:
await user.send(
f"You have been **kicked** from **{ctx.guild.name}**.\n"
f"Reason: {reason or 'No reason provided'}"
)
except (discord.Forbidden, discord.HTTPException):
await ctx.send("Could not DM the user.")
await ctx.guild.kick(user, reason=reason)
await ctx.send(f"{user.mention} has been kicked.")
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "kick", reason)
# Log to logging system
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(
ctx.guild.id, "kick", user, ctx.author, reason
)
await logger.log_moderation_action(ctx.guild.id, "kick", user, ctx.author, reason)
async def setup(bot: commands.Bot):
await bot.add_cog(KickCommand(bot))
await bot.add_cog(KickCommand(bot))

View File

@@ -8,30 +8,37 @@ from utils.constants import LILAC_ID
load_dotenv()
# Load multiple admin role IDs from env (comma-separated)
# Load admin role IDs from env (comma-separated list of integer IDs)
ADMIN_ROLE_IDS = {
int(role_id.strip())
for role_id in os.getenv("ADMIN_ROLE_IDS", "").split(",")
if role_id.strip().isdigit()
}
class ModerationBase(commands.Cog):
"""Base cog for moderation commands with shared DB and utilities"""
"""Base cog for all moderation commands.
Provides a shared SQLite connection, the infractions table schema,
the is_admin() permission check decorator, and log_infraction().
All moderation cogs inherit from this class.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
# one shared connection per cog instance — closed in cog_unload
# One shared connection per cog instance — closed in cog_unload
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
self.c = self.conn.cursor()
self.initialize_db()
async def cog_unload(self):
"""Ensure database connection closes when the cog unloads."""
"""Close the database connection when the cog unloads."""
self.conn.close()
def initialize_db(self):
"""Create the infractions table if it does not already exist."""
self.c.execute("""
CREATE TABLE IF NOT EXISTS infractions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -47,13 +54,18 @@ class ModerationBase(commands.Cog):
@staticmethod
def is_admin():
"""Decorator that works for both prefix and slash commands."""
"""Permission check decorator that works for both prefix and slash commands.
Checks whether the invoking user has an admin role (from ADMIN_ROLE_IDS)
or is the bot owner (LILAC_ID). Sends an error message and raises
CheckFailure if the check fails.
"""
async def predicate(target):
# target is either a Context (prefix) or Interaction (slash)
# target is either a Context (prefix command) or Interaction (slash command)
user = getattr(target, "author", None) or getattr(target, "user", None)
is_interaction = hasattr(target, "response")
# unified send helper so we don't have to branch everywhere
# Unified send helper so we don't have to branch on interaction type everywhere
async def send_message(msg, ephemeral=False):
if is_interaction:
try:
@@ -73,7 +85,7 @@ class ModerationBase(commands.Cog):
await send_message("Unable to check permissions in this context.", ephemeral=is_interaction)
return False
is_lilac = user.id == LILAC_ID # owner bypass
is_lilac = user.id == LILAC_ID # owner always passes
has_admin_role = any(
role.id in ADMIN_ROLE_IDS
@@ -90,7 +102,7 @@ class ModerationBase(commands.Cog):
from discord import app_commands
from discord.ext import commands
# apply both checks so it works regardless of command type
# Apply both checks so the decorator works regardless of command type
def decorator(func):
func = commands.check(predicate)(func)
func = app_commands.check(predicate)(func)
@@ -106,12 +118,27 @@ class ModerationBase(commands.Cog):
type_: str,
reason: str | None
):
"""Log an infraction to the database."""
"""Insert an infraction record into the database.
Parameters
----------
guild_id:
The guild the infraction occurred in.
user_id:
The user who received the infraction.
mod_id:
The moderator who issued the infraction.
type_:
The infraction type (e.g. 'ban', 'warn', 'kick').
reason:
Optional reason text.
"""
self.c.execute("""
INSERT INTO infractions (user_id, guild_id, type, reason, moderator_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
""", (user_id, guild_id, type_, reason, mod_id, datetime.utcnow().isoformat()))
self.conn.commit()
async def setup(bot: commands.Bot):
await bot.add_cog(ModerationBase(bot))

View File

@@ -2,6 +2,7 @@ import discord
from discord.ext import commands
from .loader import ModerationBase
from pathlib import Path
from datetime import datetime, timezone
import sqlite3
import json
import asyncio
@@ -13,9 +14,22 @@ logger = get_logger(__name__)
class LockCog(commands.Cog):
"""Cog providing the !lock, !unlock, and !checkperms prefix commands.
Before locking a channel, the original permission overwrites are serialised
to JSON and stored in the `locked_channels` table so they can be restored
exactly on !unlock. All database operations run in a thread pool executor
to avoid blocking the event loop with synchronous sqlite3 calls.
The Ritual Member role (hardcoded ID) is the only role granted send access
during a lock — everyone else is denied.
"""
def __init__(self, bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
# Persistent connection shared across all DB helpers; check_same_thread=False
# is safe here because all writes are serialised through _run_in_executor.
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
self.conn.row_factory = sqlite3.Row
self.c = self.conn.cursor()
@@ -36,7 +50,10 @@ class LockCog(commands.Cog):
self.conn.commit()
async def _run_in_executor(self, func, *args):
"""Run a blocking function in a thread pool executor."""
"""Run a blocking function in a thread pool executor.
Used to offload all sqlite3 calls so they don't block the async event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, partial(func, *args))
@@ -44,16 +61,20 @@ class LockCog(commands.Cog):
"""Synchronous database write operation."""
overwrites_json = json.dumps(overwrites_data)
self.c.execute("""
INSERT OR REPLACE INTO locked_channels
INSERT OR REPLACE INTO locked_channels
(channel_id, overwrites_json)
VALUES (?, ?)
""", (channel_id, overwrites_json))
self.conn.commit()
async def store_permissions(self, channel):
"""Store all channel permission overwrites in the database as JSON BEFORE locking."""
"""Store all channel permission overwrites in the database as JSON BEFORE locking.
Captures allow/deny bitmask values plus role/member type so they can be
reconstructed into PermissionOverwrite objects on unlock.
"""
overwrites_data = {}
for target, overwrite in channel.overwrites.items():
allow, deny = overwrite.pair()
overwrites_data[str(target.id)] = {
@@ -62,7 +83,7 @@ class LockCog(commands.Cog):
'allow': allow.value,
'deny': deny.value
}
# Run database operation in thread pool
await self._run_in_executor(self._store_permissions_sync, channel.id, overwrites_data)
@@ -73,38 +94,42 @@ class LockCog(commands.Cog):
FROM locked_channels WHERE channel_id = ?
""", (channel_id,))
row = self.c.fetchone()
if not row:
return None
return json.loads(row['overwrites_json'])
async def get_stored_permissions(self, channel):
"""Retrieve stored permissions from the database."""
"""Retrieve stored permissions from the database and reconstruct overwrite objects.
Targets that can no longer be resolved (e.g., deleted roles) are silently
skipped rather than causing an error on unlock.
"""
# Run database operation in thread pool
overwrites_data = await self._run_in_executor(self._get_stored_permissions_sync, channel.id)
if not overwrites_data:
return None
restored_overwrites = {}
for target_id, data in overwrites_data.items():
target_id = int(target_id)
# Get the target (role or member)
if data['type'] == 'role':
target = channel.guild.get_role(target_id)
else:
target = channel.guild.get_member(target_id)
if target:
overwrite = discord.PermissionOverwrite.from_pair(
discord.Permissions(data['allow']),
discord.Permissions(data['deny'])
)
restored_overwrites[target] = overwrite
return restored_overwrites
def _remove_stored_permissions_sync(self, channel_id):
@@ -119,7 +144,16 @@ class LockCog(commands.Cog):
@commands.command(name="checkperms")
@ModerationBase.is_admin()
async def checkperms(self, ctx, channel: Optional[discord.TextChannel] = None):
"""Check what permissions the bot has in a channel."""
"""Check what permissions the bot has in a channel.
Also displays the role hierarchy positions for @everyone and the Ritual
Member role, so you can diagnose why !lock may be failing.
Parameters
----------
channel:
The channel to inspect. Defaults to the current channel.
"""
if not ctx.guild:
return
@@ -128,18 +162,18 @@ class LockCog(commands.Cog):
perms = channel.permissions_for(ctx.guild.me)
bot_top_role = ctx.guild.me.top_role
msg = f"Bot permissions in #{channel.name}:\n"
msg += f"Manage Channels: {perms.manage_channels}\n"
msg += f"Manage Roles: {perms.manage_roles}\n"
msg += f"Administrator: {perms.administrator}\n"
msg += f"\nBot's highest role: {bot_top_role.name} (position {bot_top_role.position})\n"
# Check role hierarchy for everyone and ritualist roles
everyone_role = ctx.guild.default_role
ritual_member_id = 952560403970416722
ritual_member_role = ctx.guild.get_role(ritual_member_id)
msg += f"\nRole Hierarchy Check:\n"
msg += f"- everyone position: {everyone_role.position}\n"
if ritual_member_role:
@@ -147,19 +181,29 @@ class LockCog(commands.Cog):
msg += f"- Can bot manage Ritual Member role? {bot_top_role.position > ritual_member_role.position}\n"
else:
msg += f"- Ritual Member role (ID: {ritual_member_id}): NOT FOUND\n"
msg += f"\nChannel overwrites:\n"
for target, overwrite in channel.overwrites.items():
if len(msg) < 1900:
allow, deny = overwrite.pair()
msg += f"- {target.name} (ID: {target.id}, Pos: {target.position if hasattr(target, 'position') else 'N/A'}): Allow={allow.value}, Deny={deny.value}\n"
await ctx.send(msg, allowed_mentions=discord.AllowedMentions.none())
@commands.command(name="lock")
@ModerationBase.is_admin()
async def lock(self, ctx, channel: Optional[discord.TextChannel] = None):
"""Lock a channel so only Ritual Members can talk."""
"""Lock a channel so only Ritual Members can talk.
Saves the existing overwrites to the DB first, clears them all, then
sets two new overwrites: deny @everyone send_messages, allow Ritual
Member send_messages. Reacts with ✅/❌ to signal success/failure.
Parameters
----------
channel:
The channel to lock. Defaults to the current channel.
"""
try:
if not ctx.guild:
return
@@ -169,32 +213,32 @@ class LockCog(commands.Cog):
perms = channel.permissions_for(ctx.guild.me)
bot_top_role = ctx.guild.me.top_role
if not perms.manage_roles:
await ctx.message.add_reaction("")
return await ctx.send(f"I need the 'Manage Roles' permission!", allowed_mentions=discord.AllowedMentions.none())
everyone_role = ctx.guild.default_role
# Get Ritual Member role by ID
ritual_member_id = 952560403970416722
ritual_member_role = ctx.guild.get_role(ritual_member_id)
if not ritual_member_role:
await ctx.message.add_reaction("")
# List available roles to help debug
role_list = "\n".join([f"- {role.name} (ID: {role.id})" for role in ctx.guild.roles[:10]])
return await ctx.send(f"Could not find Ritual Member role with ID {ritual_member_id}!\n\nFirst 10 roles in server:\n{role_list}", allowed_mentions=discord.AllowedMentions.none())
# Check role hierarchy - bot's role must be HIGHER than the roles it's trying to modify
# Note: We don't check 'everyone' since it's always position 0 and manageable
if bot_top_role.position <= ritual_member_role.position:
await ctx.message.add_reaction("")
return await ctx.send(f"My role ({bot_top_role.name}) must be HIGHER than {ritual_member_role.name} role to manage permissions!", allowed_mentions=discord.AllowedMentions.none())
# Store ALL original permissions BEFORE making any changes (runs in thread pool)
await self.store_permissions(channel)
# Try to clear overwrites one by one with error handling
failed_targets = []
for target in list(channel.overwrites.keys()):
@@ -202,10 +246,10 @@ class LockCog(commands.Cog):
await channel.set_permissions(target, overwrite=None)
except discord.Forbidden:
failed_targets.append(target.name)
if failed_targets:
logger.warning(f"Could not clear permissions for: {', '.join(failed_targets)}")
# Deny everyone from talking
try:
everyone_overwrite = discord.PermissionOverwrite()
@@ -214,7 +258,7 @@ class LockCog(commands.Cog):
except discord.Forbidden as e:
await ctx.message.add_reaction("")
return await ctx.send(f"Cannot modify 'everyone' role permissions! Missing permissions.", allowed_mentions=discord.AllowedMentions.none())
# Allow Ritual Member role to talk
try:
ritual_member_overwrite = discord.PermissionOverwrite()
@@ -228,16 +272,29 @@ class LockCog(commands.Cog):
pass
await ctx.message.add_reaction("")
return await ctx.send(f"Cannot modify {ritual_member_role.name} permissions! My role needs to be higher than that role.", allowed_mentions=discord.AllowedMentions.none())
# React with checkmark
await ctx.message.add_reaction("")
# Try to send in channel
try:
await ctx.send(f"#{channel.name} has been locked! Only {ritual_member_role.name} can talk.", allowed_mentions=discord.AllowedMentions.none())
except Exception:
pass
# Log to mod log
log_cog = self.bot.get_cog("Logger")
if log_cog:
embed = discord.Embed(
title="Channel Locked",
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Channel", value=f"{channel.mention} (`{channel.name}`)", inline=False)
embed.add_field(name="Locked By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
embed.set_footer(text=f"Mod ID: {ctx.author.id} | Channel ID: {channel.id}")
await log_cog.send_log(ctx.guild.id, "channel_lock", embed)
except discord.Forbidden as e:
await ctx.message.add_reaction("")
try:
@@ -256,21 +313,30 @@ class LockCog(commands.Cog):
@commands.command(name="unlock")
@ModerationBase.is_admin()
async def unlock(self, ctx, channel: Optional[discord.TextChannel] = None):
"""Unlock a channel."""
"""Unlock a channel and restore its original permission overwrites.
Looks up the saved overwrites from the DB and restores them in a single
channel.edit() call. Fails if the channel was not locked via !lock.
Parameters
----------
channel:
The channel to unlock. Defaults to the current channel.
"""
try:
if not ctx.guild:
return
if channel is None:
channel = ctx.channel
# Check if we have stored permissions for this channel (runs in thread pool)
stored_overwrites = await self.get_stored_permissions(channel)
if stored_overwrites is None:
await ctx.message.add_reaction("")
return await ctx.send("This channel wasn't locked with !lock, so I can't restore its permissions.", allowed_mentions=discord.AllowedMentions.none())
# Restore all original overwrites in a single API call
failed_targets = []
try:
@@ -280,10 +346,10 @@ class LockCog(commands.Cog):
# Remove from database (runs in thread pool)
await self.remove_stored_permissions(channel.id)
# React with checkmark
await ctx.message.add_reaction("")
# Try to send in channel
try:
msg = f"#{channel.name} has been unlocked!"
@@ -292,7 +358,20 @@ class LockCog(commands.Cog):
await ctx.send(msg, allowed_mentions=discord.AllowedMentions.none())
except Exception:
pass
# Log to mod log
log_cog = self.bot.get_cog("Logger")
if log_cog:
embed = discord.Embed(
title="Channel Unlocked",
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Channel", value=f"{channel.mention} (`{channel.name}`)", inline=False)
embed.add_field(name="Unlocked By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
embed.set_footer(text=f"Mod ID: {ctx.author.id} | Channel ID: {channel.id}")
await log_cog.send_log(ctx.guild.id, "channel_lock", embed)
except discord.Forbidden as e:
await ctx.message.add_reaction("")
try:
@@ -310,4 +389,4 @@ class LockCog(commands.Cog):
async def setup(bot):
await bot.add_cog(LockCog(bot))
await bot.add_cog(LockCog(bot))

View File

@@ -2,7 +2,7 @@ import discord
from discord.ext import commands
from .loader import ModerationBase
# All available log types
# All log type keys recognised by the logging system
LOG_TYPES = [
"message_delete",
"message_edit",
@@ -17,6 +17,7 @@ LOG_TYPES = [
"unmute",
"kick",
"ban",
"cleanban",
"unban",
"role_add",
"role_remove",
@@ -24,6 +25,8 @@ LOG_TYPES = [
"username_change",
"timeout",
"timeout_remove",
"channel_lock",
"infraction_modify",
"voice_join",
"voice_leave",
"voice_move",
@@ -33,14 +36,19 @@ LOG_TYPES = [
"role_create",
"role_delete",
"role_update",
"server_update"
"server_update",
]
class LogConfig(ModerationBase):
"""Commands to configure logging settings"""
"""Admin commands for configuring which Discord channel each log type
is routed to, and for managing channel exclusions.
"""
def __init__(self, bot):
super().__init__(bot)
# Ensure the exclusion table exists; this is also created in logger.py
# but we create it here defensively since LogConfig may be loaded first
self.c.execute("""
CREATE TABLE IF NOT EXISTS log_excluded_channels (
guild_id INTEGER NOT NULL,
@@ -49,22 +57,24 @@ class LogConfig(ModerationBase):
)
""")
self.conn.commit()
@commands.group(name="log", invoke_without_command=True)
@ModerationBase.is_admin()
async def log(self, ctx):
"""Logging configuration commands"""
"""Logging configuration command group.
Use a subcommand: set, list, types, exclude, unexclude, excluded, remove, clear.
"""
await ctx.send("Use `!log set`, `!log list`, `!log exclude`, or `!log remove` to configure logging.")
@log.command(name="set")
@ModerationBase.is_admin()
async def log_set(self, ctx, channel: discord.TextChannel, log_type: str):
"""
Set a log type to a specific channel.
Usage: !log set #channel log_type
"""Route a log type to a Discord channel.
Usage: !log set #channel <log_type>
Example: !log set #mod-logs message_delete
Use !log types to see all available log types.
"""
log_type = log_type.lower()
@@ -80,26 +90,26 @@ class LogConfig(ModerationBase):
if not permissions.send_messages or not permissions.embed_links:
await ctx.send(f"❌ I don't have permission to send messages and embeds in {channel.mention}!")
return
# Upsert — update channel if already configured, otherwise insert
self.c.execute("""
INSERT INTO log_config (guild_id, log_type, channel_id)
VALUES (?, ?, ?)
ON CONFLICT(guild_id, log_type) DO UPDATE SET channel_id = ?
""", (ctx.guild.id, log_type, channel.id, channel.id))
self.conn.commit()
await ctx.send(f"✅ Set `{log_type}` logging to {channel.mention}")
@log.command(name="exclude")
@ModerationBase.is_admin()
async def log_exclude(self, ctx, channel_id: int):
"""
Exclude a channel from being logged.
"""Exclude a channel from being logged.
Events in excluded channels will not appear in any log, regardless of
the log type configuration.
Usage: !log exclude <channel_id>
Example: !log exclude 123456789012345678
Events from this channel will not be logged.
"""
if not ctx.guild:
return
@@ -108,34 +118,38 @@ class LogConfig(ModerationBase):
if not channel:
await ctx.send(f"❌ Channel with ID `{channel_id}` not found in this server.")
return
self.c.execute("SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(ctx.guild.id, channel_id))
self.c.execute(
"SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(ctx.guild.id, channel_id)
)
if self.c.fetchone():
await ctx.send(f"{channel.mention} (`{channel_id}`) is already excluded from logging.")
return
self.c.execute("INSERT INTO log_excluded_channels (guild_id, channel_id) VALUES (?, ?)",
(ctx.guild.id, channel_id))
self.c.execute(
"INSERT INTO log_excluded_channels (guild_id, channel_id) VALUES (?, ?)",
(ctx.guild.id, channel_id)
)
self.conn.commit()
await ctx.send(f"✅ Excluded {channel.mention} (`{channel_id}`) from logging.")
@log.command(name="unexclude")
@ModerationBase.is_admin()
async def log_unexclude(self, ctx, channel_id: int):
"""
Remove a channel from the exclusion list.
"""Remove a channel from the log exclusion list.
Usage: !log unexclude <channel_id>
Example: !log unexclude 123456789012345678
"""
if not ctx.guild:
return
self.c.execute("DELETE FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(ctx.guild.id, channel_id))
self.c.execute(
"DELETE FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(ctx.guild.id, channel_id)
)
if self.c.rowcount == 0:
await ctx.send(f"❌ Channel ID `{channel_id}` is not in the exclusion list.")
else:
@@ -143,94 +157,96 @@ class LogConfig(ModerationBase):
channel = ctx.guild.get_channel(channel_id)
channel_name = channel.mention if channel else f"Channel ID `{channel_id}`"
await ctx.send(f"✅ Removed {channel_name} from the exclusion list.")
@log.command(name="excluded")
@ModerationBase.is_admin()
async def log_excluded(self, ctx):
"""List all excluded channels"""
"""List all channels currently excluded from logging."""
if not ctx.guild:
return
self.c.execute("SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? ORDER BY channel_id",
(ctx.guild.id,))
self.c.execute(
"SELECT channel_id FROM log_excluded_channels WHERE guild_id = ? ORDER BY channel_id",
(ctx.guild.id,)
)
results = self.c.fetchall()
if not results:
await ctx.send("❌ No channels are excluded from logging.")
return
embed = discord.Embed(
title=f"Excluded Channels - {ctx.guild.name}",
description="Events from these channels will not be logged.",
color=discord.Color.orange()
)
channels_list = []
for (channel_id,) in results:
channel = ctx.guild.get_channel(channel_id)
if channel:
channels_list.append(f"{channel.mention} (`{channel_id}`)")
for (ch_id,) in results:
ch = ctx.guild.get_channel(ch_id)
if ch:
channels_list.append(f"{ch.mention} (`{ch_id}`)")
else:
channels_list.append(f"Deleted Channel (`{channel_id}`)")
channels_list.append(f"Deleted Channel (`{ch_id}`)")
embed.add_field(
name=f"Excluded Channels ({len(channels_list)})",
value="\n".join(channels_list) if channels_list else "None",
inline=False
)
await ctx.send(embed=embed)
@log.command(name="remove")
@ModerationBase.is_admin()
async def log_remove(self, ctx, log_type: str):
"""
Remove a log type configuration.
"""Remove the channel assignment for a log type.
Usage: !log remove log_type
Example: !log remove message_delete
Usage: !log remove <log_type>
"""
if not ctx.guild:
return
log_type = log_type.lower()
self.c.execute("DELETE FROM log_config WHERE guild_id = ? AND log_type = ?",
(ctx.guild.id, log_type))
self.c.execute(
"DELETE FROM log_config WHERE guild_id = ? AND log_type = ?",
(ctx.guild.id, log_type)
)
if self.c.rowcount == 0:
await ctx.send(f"❌ No logging configured for `{log_type}`.")
else:
self.conn.commit()
await ctx.send(f"✅ Removed `{log_type}` logging.")
@log.command(name="list")
@ModerationBase.is_admin()
async def log_list(self, ctx):
"""List all configured logging for this server"""
"""Show all configured log types and their channels for this server."""
if not ctx.guild:
return
self.c.execute("SELECT log_type, channel_id FROM log_config WHERE guild_id = ? ORDER BY log_type",
(ctx.guild.id,))
self.c.execute(
"SELECT log_type, channel_id FROM log_config WHERE guild_id = ? ORDER BY log_type",
(ctx.guild.id,)
)
results = self.c.fetchall()
if not results:
await ctx.send("❌ No logging configured for this server.\nUse `!log set #channel log_type` to set up logging.")
return
embed = discord.Embed(
title=f"Logging Configuration - {ctx.guild.name}",
color=discord.Color.blue()
)
# Group by channel
# Group log types by their target channel for a cleaner display
channel_logs = {}
for log_type, channel_id in results:
if channel_id not in channel_logs:
channel_logs[channel_id] = []
channel_logs[channel_id].append(log_type)
channel_logs.setdefault(channel_id, []).append(log_type)
for channel_id, log_types in channel_logs.items():
channel = ctx.guild.get_channel(channel_id)
channel_name = channel.mention if channel else f"Deleted Channel ({channel_id})"
@@ -239,24 +255,24 @@ class LogConfig(ModerationBase):
value=f"```{', '.join(sorted(log_types))}```",
inline=False
)
embed.set_footer(text=f"Total: {len(results)} log types configured")
await ctx.send(embed=embed)
@log.command(name="types")
async def log_types(self, ctx):
"""Show all available log types"""
"""Show all available log type keys, grouped by category."""
embed = discord.Embed(
title="Available Log Types",
description="Use these with `!log set #channel <type>`",
color=discord.Color.blue()
)
categories = {
"Message Events": [
"message_delete",
"message_edit",
"message_bulk_delete"
"message_bulk_delete",
],
"Member Events": [
"member_join",
@@ -264,7 +280,7 @@ class LogConfig(ModerationBase):
"member_ban",
"member_unban",
"nickname_change",
"username_change"
"username_change",
],
"Moderation Actions": [
"warn",
@@ -272,60 +288,63 @@ class LogConfig(ModerationBase):
"unmute",
"kick",
"ban",
"cleanban",
"unban",
"timeout",
"timeout_remove"
"timeout_remove",
"channel_lock",
"infraction_modify",
],
"Role Events": [
"role_add",
"role_remove",
"role_create",
"role_delete",
"role_update"
"role_update",
],
"Voice Events": [
"voice_join",
"voice_leave",
"voice_move"
"voice_move",
],
"Channel Events": [
"channel_create",
"channel_delete",
"channel_update"
"channel_update",
],
"Server Events": [
"server_update"
]
"server_update",
],
}
for category, types in categories.items():
embed.add_field(
name=category,
value=f"```{', '.join(types)}```",
inline=False
)
embed.set_footer(text=f"Total: {len(LOG_TYPES)} log types available")
await ctx.send(embed=embed)
@log.command(name="clear")
@ModerationBase.is_admin()
async def log_clear(self, ctx):
"""Remove ALL logging configurations for this server"""
"""Remove all logging configurations for this server (requires confirmation)."""
if not ctx.guild:
return
self.c.execute("SELECT COUNT(*) FROM log_config WHERE guild_id = ?", (ctx.guild.id,))
count = self.c.fetchone()[0]
if count == 0:
await ctx.send("❌ No logging configured to clear.")
return
from discord.ui import View, Button
view = View(timeout=30)
confirmed = {"value": False}
async def yes_callback(interaction: discord.Interaction):
if interaction.user != ctx.author:
await interaction.response.send_message("You can't confirm this action.", ephemeral=True)
@@ -333,7 +352,7 @@ class LogConfig(ModerationBase):
confirmed["value"] = True
await interaction.response.edit_message(content="✅ Confirmed.", view=None)
view.stop()
async def no_callback(interaction: discord.Interaction):
if interaction.user != ctx.author:
await interaction.response.send_message("You can't cancel this action.", ephemeral=True)
@@ -341,24 +360,28 @@ class LogConfig(ModerationBase):
confirmed["value"] = False
await interaction.response.edit_message(content="❌ Cancelled.", view=None)
view.stop()
yes_button = Button(label="Yes", style=discord.ButtonStyle.danger)
no_button = Button(label="No", style=discord.ButtonStyle.secondary)
yes_button.callback = yes_callback
no_button.callback = no_callback
view.add_item(yes_button)
view.add_item(no_button)
await ctx.send(f"⚠️ Are you sure you want to remove **all {count}** logging configurations?", view=view)
await ctx.send(
f"⚠️ Are you sure you want to remove **all {count}** logging configurations?",
view=view
)
await view.wait()
if not confirmed["value"]:
return
self.c.execute("DELETE FROM log_config WHERE guild_id = ?", (ctx.guild.id,))
self.conn.commit()
await ctx.send(f"✅ Cleared all logging configurations ({count} removed).")
async def setup(bot: commands.Bot):
await bot.add_cog(LogConfig(bot))
await bot.add_cog(LogConfig(bot))

View File

@@ -1,3 +1,4 @@
import io
import discord
from discord.ext import commands
import sqlite3
@@ -5,21 +6,26 @@ from pathlib import Path
from datetime import datetime, timezone
from typing import Optional
from utils.logger import get_logger
from utils.constants import GUILD_ID
_logger = get_logger(__name__)
class Logger(commands.Cog):
"""Core logging system that listens to Discord events and logs them"""
"""Core logging cog that listens to Discord gateway events and forwards
structured embed logs to configured channels.
Channel routing is stored per-guild in the log_config table. Individual
channels can be excluded from logging via log_excluded_channels.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "moderation.db"
self.initialize_db()
# Cache for deleted messages (for bulk delete context)
self.message_cache = {}
def initialize_db(self):
"""Create log_config and log_excluded_channels tables"""
"""Create log_config and log_excluded_channels tables, and seed default routes."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("""
@@ -37,135 +43,160 @@ class Logger(commands.Cog):
PRIMARY KEY (guild_id, channel_id)
)
""")
# Seed defaults for log types that need a channel from day one.
# INSERT OR IGNORE so this never overwrites an admin's custom routing.
MOD_LOG_CHANNEL = 982644273960873994
for log_type in ("channel_lock", "infraction_modify", "cleanban"):
c.execute("""
INSERT OR IGNORE INTO log_config (guild_id, log_type, channel_id)
VALUES (?, ?, ?)
""", (GUILD_ID, log_type, MOD_LOG_CHANNEL))
conn.commit()
conn.close()
def is_channel_excluded(self, guild_id: int, channel_id: int) -> bool:
"""Check if a channel is excluded from logging"""
"""Return True if the given channel is excluded from logging."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT 1 FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(guild_id, channel_id))
c.execute(
"SELECT 1 FROM log_excluded_channels WHERE guild_id = ? AND channel_id = ?",
(guild_id, channel_id)
)
result = c.fetchone()
conn.close()
return result is not None
def get_log_channel(self, guild_id: int, log_type: str) -> Optional[int]:
"""Get the channel ID for a specific log type in a guild"""
"""Return the configured channel ID for a log type, or None if not set."""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT channel_id FROM log_config WHERE guild_id = ? AND log_type = ?",
(guild_id, log_type))
c.execute(
"SELECT channel_id FROM log_config WHERE guild_id = ? AND log_type = ?",
(guild_id, log_type)
)
result = c.fetchone()
conn.close()
return result[0] if result else None
async def send_log(self, guild_id: int, log_type: str, embed: discord.Embed, source_channel_id: Optional[int] = None):
"""Send a log embed to the configured channel (if source channel is not excluded)"""
# Check if the source channel is excluded
async def send_log(
self,
guild_id: int,
log_type: str,
embed: discord.Embed,
source_channel_id: Optional[int] = None
):
"""Send a log embed to the channel configured for the given log type.
Does nothing if the source channel is excluded, the log type has no
configured channel, or the channel cannot be found.
"""
if source_channel_id and self.is_channel_excluded(guild_id, source_channel_id):
return
channel_id = self.get_log_channel(guild_id, log_type)
if not channel_id:
return
channel = self.bot.get_channel(channel_id)
if not channel or not isinstance(channel, discord.abc.Messageable):
return
try:
msg = await channel.send(embed=embed)
await channel.send(embed=embed)
except discord.Forbidden as e:
_logger.error(f"Missing permissions to send log in channel {channel_id}: {e}")
except Exception as e:
_logger.error(f"Error sending log: {e}", exc_info=True)
# ── Message events ────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_message_delete(self, message: discord.Message):
"""Log message deletions"""
"""Log a single message deletion."""
if message.author.bot or not message.guild:
return
embed = discord.Embed(
title="Message Deleted",
color=discord.Color.red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Author", value=f"{message.author.mention} ({message.author})", inline=False)
embed.add_field(name="Channel", value=message.channel.mention, inline=True)
embed.add_field(name="Message ID", value=message.id, inline=True)
content = message.content[:1024] if message.content else "*No text content*"
embed.add_field(name="Content", value=content, inline=False)
if message.attachments:
attachment_list = "\n".join([f"[{a.filename}]({a.url})" for a in message.attachments])
embed.add_field(name="Attachments", value=attachment_list, inline=False)
embed.set_footer(text=f"User ID: {message.author.id}")
await self.send_log(message.guild.id, "message_delete", embed, message.channel.id)
@commands.Cog.listener()
async def on_bulk_message_delete(self, messages):
"""Log bulk message deletions"""
"""Log a bulk message deletion, showing a sample of the affected messages."""
if not messages or not messages[0].guild:
return
guild = messages[0].guild
channel = messages[0].channel
embed = discord.Embed(
title="Bulk Message Delete",
description=f"**{len(messages)}** messages deleted in {channel.mention}",
color=discord.Color.dark_red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
# Show sample of deleted messages
# Show up to 5 messages as a preview
sample = []
for msg in messages[:5]: # Show first 5
for msg in messages[:5]:
content = msg.content[:100] if msg.content else "*No content*"
sample.append(f"**{msg.author}**: {content}")
if sample:
embed.add_field(name="Sample Messages", value="\n".join(sample), inline=False)
if len(messages) > 5:
embed.add_field(name="Note", value=f"Showing 5 of {len(messages)} deleted messages", inline=False)
await self.send_log(guild.id, "message_bulk_delete", embed, channel.id)
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message):
"""Log message edits"""
"""Log a message edit (only fires when the content actually changes)."""
if before.author.bot or not before.guild or before.content == after.content:
return
embed = discord.Embed(
title="Message Edited",
color=discord.Color.orange(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Author", value=f"{before.author.mention} ({before.author})", inline=False)
embed.add_field(name="Channel", value=before.channel.mention, inline=True)
embed.add_field(name="Message ID", value=before.id, inline=True)
before_content = before.content[:1024] if before.content else "*No text content*"
after_content = after.content[:1024] if after.content else "*No text content*"
embed.add_field(name="Before", value=before_content, inline=False)
embed.add_field(name="After", value=after_content, inline=False)
embed.add_field(name="Jump to Message", value=f"[Click here]({after.jump_url})", inline=False)
embed.set_footer(text=f"User ID: {before.author.id}")
await self.send_log(before.guild.id, "message_edit", embed, before.channel.id)
# ── Member events ─────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
async def on_member_join(self, member: discord.Member):
"""Log a member join, including account age and member count."""
try:
embed = discord.Embed(
title="Member Joined",
@@ -173,7 +204,7 @@ class Logger(commands.Cog):
color=discord.Color.green(),
timestamp=datetime.now(timezone.utc)
)
account_age = (datetime.now(timezone.utc) - member.created_at).days
embed.add_field(
name="Account Created",
@@ -181,17 +212,16 @@ class Logger(commands.Cog):
inline=False
)
embed.add_field(name="Member Count", value=member.guild.member_count, inline=True)
embed.set_thumbnail(url=member.display_avatar.url)
embed.set_footer(text=f"User ID: {member.id}")
await self.send_log(member.guild.id, "member_join", embed)
except Exception as e:
error_text = f"⚠️ **Error while logging member join:**\n`{type(e).__name__}: {e}`"
_logger.error(f"Error logging member join: {e}", exc_info=True)
# Attempt to send the error to the designated debug channel
# Attempt to report the error to the designated debug channel
try:
channel = member.guild.get_channel(1424145004976275617)
if channel and isinstance(channel, discord.abc.Messageable):
@@ -200,284 +230,333 @@ class Logger(commands.Cog):
_logger.error("Could not find error logging channel (1424145004976275617).")
except Exception as send_err:
_logger.error(f"Failed to send error message to debug channel: {send_err}")
@commands.Cog.listener()
async def on_member_remove(self, member: discord.Member):
"""Log member leaves/kicks"""
"""Log a member leave or kick, including their roles and join date."""
embed = discord.Embed(
title="Member Left",
description=f"{member.mention} {member}",
color=discord.Color.light_gray(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
join_date = member.joined_at.strftime('%Y-%m-%d %H:%M:%S UTC') if member.joined_at else "Unknown"
embed.add_field(name="Joined Server", value=join_date, inline=False)
roles = [role.mention for role in member.roles if role.name != "@everyone"]
if roles:
embed.add_field(name="Roles", value=", ".join(roles), inline=False)
embed.add_field(name="Member Count", value=member.guild.member_count, inline=True)
embed.set_thumbnail(url=member.display_avatar.url)
embed.set_footer(text=f"User ID: {member.id}")
await self.send_log(member.guild.id, "member_leave", embed)
@commands.Cog.listener()
async def on_member_ban(self, guild: discord.Guild, user: discord.User):
"""Log member bans"""
"""Log a member ban, fetching the reason from the audit log when possible."""
embed = discord.Embed(
title="Member Banned",
description=f"{user.mention} {user}",
color=discord.Color.dark_red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
# Try to get ban reason from audit log
try:
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.ban):
if entry.target and entry.target.id == user.id:
embed.add_field(name="Banned By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
embed.add_field(
name="Banned By",
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
inline=False
)
if entry.reason:
embed.add_field(name="Reason", value=entry.reason, inline=False)
break
except discord.Forbidden:
pass
pass # Missing audit log access — omit the moderator field
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"User ID: {user.id}")
await self.send_log(guild.id, "member_ban", embed)
@commands.Cog.listener()
async def on_member_unban(self, guild: discord.Guild, user: discord.User):
"""Log member unbans"""
"""Log a member unban, fetching the moderator from the audit log when possible."""
embed = discord.Embed(
title="Member Unbanned",
description=f"{user.mention} {user}",
color=discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
# Try to get unban info from audit log
try:
async for entry in guild.audit_logs(limit=5, action=discord.AuditLogAction.unban):
if entry.target and entry.target.id == user.id:
embed.add_field(name="Unbanned By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
embed.add_field(
name="Unbanned By",
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
inline=False
)
if entry.reason:
embed.add_field(name="Reason", value=entry.reason, inline=False)
break
except discord.Forbidden:
pass
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"User ID: {user.id}")
await self.send_log(guild.id, "member_unban", embed)
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
"""Log nickname changes, role changes, and timeouts"""
"""Log nickname changes, role additions/removals, and timeout changes."""
# Nickname change
if before.nick != after.nick:
embed = discord.Embed(
title="Nickname Changed",
color=discord.Color.blue(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
embed.add_field(name="Before", value=before.nick or "*No nickname*", inline=True)
embed.add_field(name="After", value=after.nick or "*No nickname*", inline=True)
embed.set_footer(text=f"User ID: {after.id}")
await self.send_log(after.guild.id, "nickname_change", embed)
# Role changes
# Role additions and removals
before_roles = set(before.roles)
after_roles = set(after.roles)
added_roles = after_roles - before_roles
removed_roles = before_roles - after_roles
if added_roles:
embed = discord.Embed(
title="Role Added",
color=discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
embed.add_field(name="Roles Added", value=", ".join([r.mention for r in added_roles]), inline=False)
embed.set_footer(text=f"User ID: {after.id}")
await self.send_log(after.guild.id, "role_add", embed)
if removed_roles:
embed = discord.Embed(
title="Role Removed",
color=discord.Color.red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
embed.add_field(name="Roles Removed", value=", ".join([r.mention for r in removed_roles]), inline=False)
embed.set_footer(text=f"User ID: {after.id}")
await self.send_log(after.guild.id, "role_remove", embed)
# Timeout changes
if before.timed_out_until != after.timed_out_until:
if after.timed_out_until:
# Member was timed out
embed = discord.Embed(
title="Member Timed Out",
color=discord.Color.dark_orange(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
embed.add_field(name="Until", value=f"<t:{int(after.timed_out_until.timestamp())}:F>", inline=False)
# Try to get timeout reason from audit log
# Try to fetch the moderator who issued the timeout from the audit log
try:
async for entry in after.guild.audit_logs(limit=5, action=discord.AuditLogAction.member_update):
if entry.target and entry.target.id == after.id and entry.after.timed_out_until:
embed.add_field(name="Timed Out By", value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown", inline=False)
embed.add_field(
name="Timed Out By",
value=f"{entry.user.mention} ({entry.user})" if entry.user else "Unknown",
inline=False
)
if entry.reason:
embed.add_field(name="Reason", value=entry.reason, inline=False)
break
except discord.Forbidden:
pass
embed.set_footer(text=f"User ID: {after.id}")
await self.send_log(after.guild.id, "timeout", embed)
else:
# Timeout was removed
# Timeout was lifted
embed = discord.Embed(
title="Timeout Removed",
color=discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Member", value=f"{after.mention} ({after})", inline=False)
embed.set_footer(text=f"User ID: {after.id}")
await self.send_log(after.guild.id, "timeout_remove", embed)
@commands.Cog.listener()
async def on_user_update(self, before: discord.User, after: discord.User):
"""Log username changes"""
"""Log username or discriminator changes across all guilds the user shares with the bot."""
if before.name != after.name or before.discriminator != after.discriminator:
embed = discord.Embed(
title="Username Changed",
color=discord.Color.blue(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="User", value=f"{after.mention}", inline=False)
embed.add_field(name="Before", value=str(before), inline=True)
embed.add_field(name="After", value=str(after), inline=True)
embed.set_thumbnail(url=after.display_avatar.url)
embed.set_footer(text=f"User ID: {after.id}")
# Send to all guilds the user is in
# Fan the log out to every guild this user is a member of
for guild in self.bot.guilds:
if guild.get_member(after.id):
await self.send_log(guild.id, "username_change", embed)
# ── Voice events ──────────────────────────────────────────────────────────
@commands.Cog.listener()
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
"""Log voice channel activities"""
# Member joined a voice channel
async def on_voice_state_update(
self,
member: discord.Member,
before: discord.VoiceState,
after: discord.VoiceState
):
"""Log voice channel joins, leaves, and moves."""
if before.channel is None and after.channel is not None:
# Member joined a voice channel
embed = discord.Embed(
title="Voice Channel Join",
description=f"{member.mention} joined {after.channel.mention}",
color=discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.set_footer(text=f"User ID: {member.id}")
await self.send_log(member.guild.id, "voice_join", embed, after.channel.id)
# Member left a voice channel
elif before.channel is not None and after.channel is None:
# Member left a voice channel
embed = discord.Embed(
title="Voice Channel Leave",
description=f"{member.mention} left {before.channel.mention}",
color=discord.Color.red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.set_footer(text=f"User ID: {member.id}")
await self.send_log(member.guild.id, "voice_leave", embed, before.channel.id)
# Member moved between voice channels
elif before.channel is not None and after.channel is not None and before.channel != after.channel:
elif (
before.channel is not None
and after.channel is not None
and before.channel != after.channel
):
# Member moved between voice channels
embed = discord.Embed(
title="Voice Channel Move",
description=f"{member.mention} moved from {before.channel.mention} to {after.channel.mention}",
color=discord.Color.blue(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.set_footer(text=f"User ID: {member.id}")
# For moves, check if either channel is excluded
if not self.is_channel_excluded(member.guild.id, before.channel.id) and not self.is_channel_excluded(member.guild.id, after.channel.id):
# Only log the move if neither of the involved channels is excluded
if not self.is_channel_excluded(member.guild.id, before.channel.id) \
and not self.is_channel_excluded(member.guild.id, after.channel.id):
await self.send_log(member.guild.id, "voice_move", embed)
# ── Channel / role events ─────────────────────────────────────────────────
@commands.Cog.listener()
async def on_guild_channel_create(self, channel):
"""Log channel creation"""
"""Log the creation of a guild channel."""
embed = discord.Embed(
title="Channel Created",
description=f"{channel.mention} (`{channel.name}`)",
color=discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Type", value=str(channel.type).title(), inline=True)
embed.add_field(name="Channel ID", value=channel.id, inline=True)
await self.send_log(channel.guild.id, "channel_create", embed)
@commands.Cog.listener()
async def on_guild_channel_delete(self, channel):
"""Log channel deletion"""
"""Log the deletion of a guild channel."""
embed = discord.Embed(
title="Channel Deleted",
description=f"`{channel.name}`",
color=discord.Color.red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Type", value=str(channel.type).title(), inline=True)
embed.add_field(name="Channel ID", value=channel.id, inline=True)
await self.send_log(channel.guild.id, "channel_delete", embed)
@commands.Cog.listener()
async def on_guild_role_create(self, role: discord.Role):
"""Log role creation"""
"""Log the creation of a guild role."""
embed = discord.Embed(
title="Role Created",
description=f"{role.mention} (`{role.name}`)",
color=role.color if role.color != discord.Color.default() else discord.Color.green(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Role ID", value=role.id, inline=True)
embed.add_field(name="Color", value=str(role.color), inline=True)
embed.add_field(name="Hoisted", value="Yes" if role.hoist else "No", inline=True)
await self.send_log(role.guild.id, "role_create", embed)
@commands.Cog.listener()
async def on_guild_role_delete(self, role: discord.Role):
"""Log role deletion"""
"""Log the deletion of a guild role."""
embed = discord.Embed(
title="Role Deleted",
description=f"`{role.name}`",
color=discord.Color.red(),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Role ID", value=role.id, inline=True)
await self.send_log(role.guild.id, "role_delete", embed)
async def log_moderation_action(self, guild_id: int, action_type: str, user: discord.User, moderator: discord.User, reason: Optional[str] = None, duration: Optional[str] = None):
"""
Public method to log moderation actions from commands.
# ── Public API for other cogs ─────────────────────────────────────────────
async def log_moderation_action(
self,
guild_id: int,
action_type: str,
user: discord.User,
moderator: discord.User,
reason: Optional[str] = None,
duration: Optional[str] = None
):
"""Build and send a moderation action embed to the appropriate log channel.
Called by moderation commands (ban, kick, warn, mute, etc.) after they
execute their action.
Parameters
----------
guild_id:
The guild the action was taken in.
action_type:
The action key (e.g. 'ban', 'warn', 'mute'). Controls embed color
and is used to look up the target log channel.
user:
The user the action was taken against.
moderator:
The moderator who performed the action.
reason:
Optional reason text.
duration:
Optional duration string for timed actions like mutes.
"""
color_map = {
"warn": discord.Color.yellow(),
@@ -485,30 +564,108 @@ class Logger(commands.Cog):
"unmute": discord.Color.green(),
"kick": discord.Color.red(),
"ban": discord.Color.dark_red(),
"unban": discord.Color.green()
"cleanban": discord.Color.dark_red(),
"unban": discord.Color.green(),
"timeout": discord.Color.dark_orange(),
"untimeout": discord.Color.green(),
"lock": discord.Color.orange(),
"unlock": discord.Color.green(),
}
embed = discord.Embed(
title=f"Moderation: {action_type.title()}",
color=color_map.get(action_type, discord.Color.blue()),
timestamp = datetime.now(timezone.utc)
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="User", value=f"{user.mention} ({user})", inline=False)
embed.add_field(name="Moderator", value=f"{moderator.mention} ({moderator})", inline=False)
if duration:
embed.add_field(name="Duration", value=duration, inline=True)
if reason:
embed.add_field(name="Reason", value=reason, inline=False)
else:
embed.add_field(name="Reason", value="*No reason provided*", inline=False)
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"User ID: {user.id} | Mod ID: {moderator.id}")
await self.send_log(guild_id, action_type, embed)
async def log_ban_messages(
self,
guild_id: int,
user: discord.User,
deleted_messages: list,
days: int
):
"""Send a text-file attachment to the ban log channel containing all
messages that were deleted during a cleanban.
Parameters
----------
guild_id:
The guild the cleanban occurred in.
user:
The banned user.
deleted_messages:
List of dicts with keys: channel, channel_id, message_id,
timestamp, content, attachments.
days:
The message history window (in days) that was scanned.
"""
channel_id = self.get_log_channel(guild_id, "member_ban")
if not channel_id:
return
channel = self.bot.get_channel(channel_id)
if not channel or not isinstance(channel, discord.abc.Messageable):
return
embed = discord.Embed(
title=f"Cleanban Deleted Messages — {user}",
color=discord.Color.dark_red(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="User", value=f"{user.mention} ({user})", inline=False)
embed.add_field(name="Messages Deleted", value=str(len(deleted_messages)), inline=True)
embed.add_field(name="Window", value=f"Past {days} day(s)", inline=True)
embed.set_footer(text=f"User ID: {user.id}")
if not deleted_messages:
embed.description = "*No messages found in accessible channels for this time window.*"
try:
await channel.send(embed=embed)
except Exception as e:
_logger.error(f"Error sending ban message log: {e}")
return
# Build a human-readable plaintext log attached as a file
lines = [
f"Deleted messages for {user} (ID: {user.id})",
f"Window: past {days} day(s)",
f"Total: {len(deleted_messages)} message(s)",
"=" * 70,
"",
]
for msg in deleted_messages:
line = f"[{msg['timestamp']}] #{msg['channel']} (msg {msg['message_id']}): {msg['content']}"
if msg["attachments"]:
line += f"\n Attachments: {', '.join(msg['attachments'])}"
lines.append(line)
content = "\n".join(lines)
file = discord.File(
io.BytesIO(content.encode("utf-8")),
filename=f"deleted_messages_{user.id}.txt"
)
try:
await channel.send(embed=embed, file=file)
except Exception as e:
_logger.error(f"Error sending ban message log: {e}", exc_info=True)
async def setup(bot: commands.Bot):
await bot.add_cog(Logger(bot))
await bot.add_cog(Logger(bot))

View File

@@ -4,12 +4,19 @@ from .loader import ModerationBase
class ModHelp(ModerationBase):
"""Cog providing the !modhelp command.
Sends five embed pages covering every moderation command available to staff.
"""
@commands.command(name="modhelp")
@ModerationBase.is_admin()
async def modhelp(self, ctx):
"""Send a multi-embed reference covering all moderation commands."""
embeds = []
def embed(title, color=discord.Color.blurple()):
"""Create a titled Discord embed with the given colour."""
e = discord.Embed(title=title, color=color)
return e

View File

@@ -7,28 +7,55 @@ import sqlite3
from pathlib import Path
from datetime import timedelta, datetime
from .loader import ModerationBase
from utils.logger import get_logger
_logger = get_logger(__name__)
DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db"
# Role ID for the server's muted role
MUTE_ROLE_ID = 982702037517090836
class MuteCommand(ModerationBase):
"""Cog providing the !mute prefix command with automatic expiry.
Mutes are tracked in a persistent `mutes` table so they survive bot
restarts. A background task checks for expired mutes every minute.
"""
def __init__(self, bot):
super().__init__(bot)
self.bot = bot
self.check_mutes.start()
async def cog_unload(self):
"""Cancel the background task and close the DB when the cog unloads."""
self.check_mutes.cancel()
await super().cog_unload()
@commands.command(name="mute")
@ModerationBase.is_admin()
async def mute(self, ctx, user: discord.Member, duration: str, *, reason: str | None = None):
"""Mute a member for a specified duration.
Applies the mute role and schedules automatic removal. The mute is
persisted to the database so the bot can restore it after a restart.
Parameters
----------
user:
The server member to mute.
duration:
Duration string using suffix notation: w (weeks), d (days),
h (hours), m (minutes). Example: 1w, 5d, 12h, 30m.
reason:
Optional reason for the mute.
"""
match = re.match(r"(\d+)([wdhm])", duration.lower())
if not match:
await ctx.send("Invalid duration format. Use **1w**, **5d**, **12h**, **30m**, etc.")
return
value, unit = match.groups()
value = int(value)
if unit == "w":
@@ -66,7 +93,11 @@ class MuteCommand(ModerationBase):
view.add_item(yes_button)
view.add_item(no_button)
await ctx.send(f"Are you sure you want to mute {user.mention} for **{duration}**? Reason: {reason or 'No reason provided'}", view=view)
await ctx.send(
f"Are you sure you want to mute {user.mention} for **{duration}**? "
f"Reason: {reason or 'No reason provided'}",
view=view
)
await view.wait()
if not confirmed["value"]:
return
@@ -81,13 +112,17 @@ class MuteCommand(ModerationBase):
await user.add_roles(mute_role, reason=reason)
try:
await user.send(f"You have been muted in **{ctx.guild.name}** for **{duration}**.\nReason: {reason or 'No reason provided'}")
await user.send(
f"You have been muted in **{ctx.guild.name}** for **{duration}**.\n"
f"Reason: {reason or 'No reason provided'}"
)
except Exception:
await ctx.send("Could not DM the user.")
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "mute", reason)
await ctx.send(f"{user.mention} has been muted for **{duration}**.")
# Persist the mute so it survives a restart
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
@@ -99,32 +134,40 @@ class MuteCommand(ModerationBase):
)
""")
unmute_time = (datetime.utcnow() + delta).isoformat()
c.execute("INSERT INTO mutes (user_id, guild_id, channel_id, unmute_time) VALUES (?, ?, ?, ?)",
(user.id, ctx.guild.id, ctx.channel.id, unmute_time))
c.execute(
"INSERT INTO mutes (user_id, guild_id, channel_id, unmute_time) VALUES (?, ?, ?, ?)",
(user.id, ctx.guild.id, ctx.channel.id, unmute_time)
)
conn.commit()
conn.close()
# Log to logging system
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(
ctx.guild.id, "mute", user, ctx.author, reason, duration
)
await logger.log_moderation_action(ctx.guild.id, "mute", user, ctx.author, reason, duration)
# Schedule the automatic unmute as a fire-and-forget coroutine
asyncio.create_task(self.schedule_unmute(user.id, ctx.guild.id, ctx.channel.id, delta.total_seconds()))
async def schedule_unmute(self, user_id, guild_id, channel_id, delay):
"""Wait for the mute duration to expire, then remove the mute role.
Also handles the case where the mute was manually removed before
expiry — if the DB entry is already gone, this exits without action.
"""
await asyncio.sleep(delay)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
exists = c.fetchone()
if not exists:
try:
c = conn.cursor()
c.execute("SELECT 1 FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
if not c.fetchone():
# Mute was manually removed before expiry
return
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
conn.commit()
finally:
conn.close()
return
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id))
conn.commit()
conn.close()
guild = self.bot.get_guild(guild_id)
if not guild:
return
@@ -134,28 +177,41 @@ class MuteCommand(ModerationBase):
mute_role = guild.get_role(MUTE_ROLE_ID)
if not mute_role:
return
try:
await member.remove_roles(mute_role, reason="Mute duration expired")
# Log automatic unmute
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(
log_cog = self.bot.get_cog("Logger")
if log_cog:
await log_cog.log_moderation_action(
guild_id, "unmute", member, self.bot.user, "Mute duration expired"
)
except Exception:
pass
except Exception as e:
_logger.error(f"Failed to remove mute role from {user_id}: {e}", exc_info=True)
@tasks.loop(minutes=1)
async def check_mutes(self):
"""Background task: check for expired mutes every minute and remove them.
This ensures mutes are cleaned up even if the bot was restarted and
the scheduled_unmute task was never created for a given entry.
"""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
now = datetime.utcnow().isoformat()
c.execute("SELECT user_id, guild_id, channel_id, unmute_time FROM mutes WHERE unmute_time <= ?", (now,))
expired = c.fetchall()
try:
c = conn.cursor()
now = datetime.utcnow().isoformat()
c.execute(
"SELECT user_id, guild_id, channel_id, unmute_time FROM mutes WHERE unmute_time <= ?",
(now,)
)
expired = c.fetchall()
finally:
conn.close()
for user_id, guild_id, channel_id, _ in expired:
# Delay of 0 so they run immediately on the next event loop iteration
asyncio.create_task(self.schedule_unmute(user_id, guild_id, channel_id, 0))
conn.close()
async def setup(bot: commands.Bot):
await bot.add_cog(MuteCommand(bot))
await bot.add_cog(MuteCommand(bot))

View File

@@ -1,44 +1,140 @@
import asyncio
import time
from datetime import datetime, timezone, timedelta
import discord
from discord.ext import commands
from .loader import ModerationBase
from utils.constants import LILAC_ID
SCAN_WORKERS = 5 # Parallel time-window workers per channel
PROGRESS_EVERY = 500 # Call on_progress every N messages scanned
async def safe_delete_user_messages(channel: discord.TextChannel, user_id: int, timeout: int = 20):
"""Safely delete all messages from a user in one channel with a timeout."""
async def safe_delete_user_messages(channel: discord.TextChannel, user_id: int, on_progress=None):
"""Delete all messages from a user in one channel.
Uses a two-phase approach to handle Discord's 14-day bulk-delete limit:
Phase 1: bulk-delete recent messages (< 14 days) via channel.purge().
Phase 2: split the remaining history into parallel time windows so the
sequential fetch bottleneck is reduced by ~SCAN_WORKERS times.
Parameters
----------
channel:
The text channel to scan.
user_id:
ID of the user whose messages should be deleted.
on_progress:
Optional callback ``on_progress(scanned, deleted)`` called every
PROGRESS_EVERY messages scanned, so the caller can update a status
message without spamming Discord's API.
Returns
-------
tuple[int, str | None]
``(deleted_count, error_string)`` — error_string is None on success.
"""
deleted_count = 0
scanned_count = 0
count_lock = asyncio.Lock()
# Cap concurrent individual deletes at 2 to stay under rate limits
delete_sem = asyncio.Semaphore(2)
async def do_purge():
nonlocal deleted_count
async for message in channel.history(limit=None, oldest_first=False):
if message.author.id == user_id:
async def scan_and_delete_window(after_dt: datetime, before_dt: datetime):
nonlocal deleted_count, scanned_count
async for message in channel.history(
limit=None, after=after_dt, before=before_dt, oldest_first=True
):
async with count_lock:
scanned_count += 1
sc, dc = scanned_count, deleted_count
if sc % PROGRESS_EVERY == 0 and on_progress:
on_progress(sc, dc)
if message.author.id != user_id:
continue
async with delete_sem:
try:
await message.delete()
deleted_count += 1
await asyncio.sleep(0.2)
except discord.Forbidden:
raise PermissionError("Missing permissions")
async with count_lock:
deleted_count += 1
await asyncio.sleep(0.5)
except discord.NotFound:
pass
except discord.HTTPException as e:
if "rate limit" in str(e).lower():
await asyncio.sleep(1)
if e.status == 429:
retry_after = float(e.response.headers.get("Retry-After", 1))
await asyncio.sleep(retry_after)
else:
raise e
raise
try:
await asyncio.wait_for(do_purge(), timeout=timeout)
# Phase 1: bulk-delete messages newer than 14 days
deleted = await channel.purge(limit=None, check=lambda m: m.author.id == user_id)
deleted_count += len(deleted)
if on_progress:
on_progress(0, deleted_count)
# Phase 2: scan older history in parallel time windows
now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=14)
start = channel.created_at.replace(tzinfo=timezone.utc)
if start < cutoff:
total_seconds = (cutoff - start).total_seconds()
chunk = total_seconds / SCAN_WORKERS
windows = [
(
start + timedelta(seconds=i * chunk),
start + timedelta(seconds=(i + 1) * chunk),
)
for i in range(SCAN_WORKERS)
]
await asyncio.gather(*(scan_and_delete_window(a, b) for a, b in windows))
if on_progress:
on_progress(scanned_count, deleted_count)
return deleted_count, None
except asyncio.TimeoutError:
return deleted_count, f"Timeout after {timeout}s"
except PermissionError:
except discord.Forbidden:
return deleted_count, "No permissions"
except asyncio.CancelledError:
raise
except Exception as e:
return deleted_count, str(e)
class Purge(ModerationBase):
"""Commands for purging messages"""
"""Commands for purging messages.
Standard purge commands delete messages in the current channel up to a
given message ID anchor. The !purgememberall command is destructive — it
scans every channel in a set of allowed categories and deletes all messages
from a specific user. It is restricted to the server owner and runs as a
cancellable background task with a live progress ticker.
"""
def __init__(self, bot):
super().__init__(bot)
# Tracks active purgememberall tasks so duplicates and !cancelpurge can be managed
self._active_purges: dict[int, asyncio.Task] = {} # guild_id -> task
async def fetch_after_message(self, ctx, message_id: int):
"""Fetch a message by ID from the current channel, sending an error on failure.
Parameters
----------
message_id:
Discord message ID to anchor the purge range.
Returns
-------
discord.Message | None
The fetched message, or None if not found.
"""
try:
msg = await ctx.channel.fetch_message(message_id)
return msg
@@ -50,6 +146,21 @@ class Purge(ModerationBase):
return None
async def purge_messages(self, ctx, check=None, after_message=None, limit: int = 100):
"""Bulk-delete messages in the current channel up to a limit.
Sends a status message that is edited in-place with the outcome.
Logs the operation to the mod log on success.
Parameters
----------
check:
Optional filter function passed to channel.purge().
after_message:
If provided, only messages after this one are deleted. The anchor
message itself is also deleted.
limit:
Maximum number of messages to delete (capped at 1000).
"""
if not ctx.guild:
return
if not ctx.channel.permissions_for(ctx.guild.me).manage_messages:
@@ -83,6 +194,22 @@ class Purge(ModerationBase):
total_deleted = len(deleted) + (1 if after_message else 0)
await status_msg.edit(content=f"✅ Purge complete! Deleted **{total_deleted}** message(s).")
# Log the purge operation to the mod log
logger = self.bot.get_cog("Logger")
if logger and ctx.guild:
embed = discord.Embed(
title="Purge Executed",
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Moderator", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
embed.add_field(name="Channel", value=ctx.channel.mention, inline=True)
embed.add_field(name="Messages Deleted", value=str(total_deleted), inline=True)
if after_message:
embed.add_field(name="After Message ID", value=str(after_message.id), inline=True)
embed.set_footer(text=f"Mod ID: {ctx.author.id}")
await logger.send_log(ctx.guild.id, "message_bulk_delete", embed, ctx.channel.id)
except discord.Forbidden:
await status_msg.edit(content="❌ Forbidden: I don't have permission to delete messages!")
except discord.HTTPException as e:
@@ -93,6 +220,13 @@ class Purge(ModerationBase):
@commands.command(name="purge")
@ModerationBase.is_admin()
async def purge(self, ctx, message_id: int):
"""Delete all messages in this channel from the given message ID to now.
Parameters
----------
message_id:
ID of the oldest message to include in the purge range.
"""
status_msg = await ctx.send(f"🗑️ Purge command received for message ID {message_id}...")
after_message = await self.fetch_after_message(ctx, message_id)
if not after_message:
@@ -103,6 +237,15 @@ class Purge(ModerationBase):
@commands.command(name="purgemember", aliases=["purgeuser", "purgeu", "purgem"])
@ModerationBase.is_admin()
async def purge_member(self, ctx, member: discord.Member, message_id: int):
"""Delete messages from a specific member from the given message ID to now.
Parameters
----------
member:
The member whose messages to delete.
message_id:
ID of the oldest message to include in the purge range.
"""
status_msg = await ctx.send(f"🗑️ Purge command received for member {member} up to message ID {message_id}...")
after_message = await self.fetch_after_message(ctx, message_id)
if not after_message:
@@ -113,6 +256,13 @@ class Purge(ModerationBase):
@commands.command(name="purgebot", aliases=["purgebots", "purgeb"])
@ModerationBase.is_admin()
async def purge_bots(self, ctx, message_id: int):
"""Delete bot messages from the given message ID to now.
Parameters
----------
message_id:
ID of the oldest message to include in the purge range.
"""
status_msg = await ctx.send(f"🗑️ Purge command received for bots up to message ID {message_id}...")
after_message = await self.fetch_after_message(ctx, message_id)
if not after_message:
@@ -123,6 +273,15 @@ class Purge(ModerationBase):
@commands.command(name="purgecontains", aliases=["purgec", "purgetext"])
@ModerationBase.is_admin()
async def purge_contains(self, ctx, message_id: int, *, text: str):
"""Delete messages containing specific text from the given message ID to now.
Parameters
----------
message_id:
ID of the oldest message to include in the purge range.
text:
Case-insensitive substring to match against message content.
"""
status_msg = await ctx.send(f"🗑️ Purge command received for messages containing '{text}' up to message ID {message_id}...")
after_message = await self.fetch_after_message(ctx, message_id)
if not after_message:
@@ -133,6 +292,13 @@ class Purge(ModerationBase):
@commands.command(name="purgeembeds", aliases=["purgee", "purgeembed"])
@ModerationBase.is_admin()
async def purge_embeds(self, ctx, message_id: int):
"""Delete messages that contain embeds from the given message ID to now.
Parameters
----------
message_id:
ID of the oldest message to include in the purge range.
"""
status_msg = await ctx.send(f"🗑️ Purge command received for messages with embeds up to message ID {message_id}...")
after_message = await self.fetch_after_message(ctx, message_id)
if not after_message:
@@ -143,10 +309,25 @@ class Purge(ModerationBase):
@commands.command(name="purgememberall", aliases=["purgeuserall", "purgeua", "purgeallm"])
@ModerationBase.is_admin()
async def purge_member_all(self, ctx, user_id: int):
"""Delete all messages from a user across all text channels in the server (requires confirmation)."""
"""Delete all messages from a user across all text channels in the server (owner only, requires confirmation).
Only the server owner (LILAC_ID) may run this command — it is too
destructive for general staff use. Runs as a cancellable background task
with a live progress embed that updates every 3 seconds to avoid hitting
Discord's edit rate limit.
Parameters
----------
user_id:
Discord user ID of the target whose messages will be wiped.
"""
if not ctx.guild:
return
if ctx.author.id != LILAC_ID:
await ctx.send("❌ This command can only be used by the server owner — it's too destructive for general staff use.")
return
member = ctx.guild.get_member(user_id)
user_display = str(member) if member else f"User ID {user_id}"
@@ -158,7 +339,7 @@ class Purge(ModerationBase):
async def interaction_check(self, interaction: discord.Interaction):
if interaction.user.id != self.author.id:
await interaction.response.send_message("❌ You cant confirm someone elses purge command.", ephemeral=True)
await interaction.response.send_message("❌ You can't confirm someone else's purge command.", ephemeral=True)
return False
return True
@@ -190,43 +371,155 @@ class Purge(ModerationBase):
if view.value is False:
return
# Run purge in background task so it doesnt block bot
asyncio.create_task(self._purge_user_messages(ctx, user_id, user_display, msg))
if ctx.guild.id in self._active_purges and not self._active_purges[ctx.guild.id].done():
await msg.edit(content="❌ A purge is already running. Use `cancelpurge` to stop it first.")
return
task = asyncio.create_task(self._purge_user_messages(ctx, user_id, user_display, msg))
self._active_purges[ctx.guild.id] = task
# Auto-clean the registry when the task completes
task.add_done_callback(lambda _: self._active_purges.pop(ctx.guild.id, None))
@commands.command(name="cancelpurge", aliases=["stoppurge"])
@ModerationBase.is_admin()
async def cancel_purge(self, ctx):
"""Cancel an in-progress purgememberall."""
task = self._active_purges.get(ctx.guild.id)
if not task or task.done():
await ctx.send("❌ No purge is currently running.")
return
task.cancel()
await ctx.send("🛑 Purge cancellation requested.")
async def _purge_user_messages(self, ctx, user_id: int, user_display: str, msg: discord.Message):
"""Background purge task with timeout, per-channel progress, and async safety."""
total_deleted = 0
failed_channels = []
processed = 0
total_channels = len(ctx.guild.text_channels)
"""Background purge task — processes channels concurrently with per-channel progress.
await msg.edit(content=f"🧹 Starting purge for **{user_display}**...\nTotal channels: {total_channels}")
Restricts scanning to a hardcoded set of category IDs to avoid touching
bot-internal or announcement channels. Uses a semaphore to cap concurrency
at 5 channels at once. A ticker coroutine edits the status message every 3
seconds so staff can watch progress without hammering the API.
for channel in ctx.guild.text_channels:
processed += 1
channel_name = channel.name
Parameters
----------
user_id:
ID of the user whose messages are being deleted.
user_display:
Human-readable display name used in status messages.
msg:
The Discord message to edit in-place with live progress.
"""
CATEGORY_IDS = {
962737280735408218,
1229700264848789594,
876772600704020531,
1087353136198451241,
876772600704020532,
}
try:
await msg.edit(content=(
f"🧹 Working on **#{channel_name}** ({processed}/{total_channels})...\n"
f"Deleted so far: **{total_deleted}**"
))
except Exception:
pass
channels = [
ch for ch in ctx.guild.text_channels
if ch.category_id in CATEGORY_IDS
]
total_channels = len(channels)
lock = asyncio.Lock()
semaphore = asyncio.Semaphore(5)
# Per-channel stats: scanned, deleted, done, error
stats: dict[str, dict] = {
ch.name: {"scanned": 0, "deleted": 0, "done": False, "error": None}
for ch in channels
}
failed_channels: list[str] = []
def build_status() -> str:
done_channels = [n for n, s in stats.items() if s["done"]]
active = [n for n, s in stats.items() if not s["done"] and s["scanned"] > 0]
total_deleted = sum(s["deleted"] for s in stats.values())
completed = len(done_channels)
lines = [
f"🧹 **Purging {user_display}** — {completed}/{total_channels} channels done · {total_deleted:,} deleted",
"",
]
if active:
lines.append("**Scanning:**")
for name in active:
s = stats[name]
lines.append(f" **#{name}** — {s['scanned']:,} scanned · {s['deleted']:,} deleted")
lines.append("")
if done_channels:
# Show last 5 completed to keep message short
shown = done_channels[-5:]
summary_parts = [f"#{n} ({stats[n]['deleted']:,})" for n in shown]
prefix = f"**Done ({completed}):** " + " · ".join(summary_parts)
if completed > 5:
prefix += f" · *(+{completed - 5} more)*"
lines.append(prefix)
return "\n".join(lines)
# Ticker: edit the Discord message every 3s so we don't get rate limited
stop_ticker = asyncio.Event()
last_edit_time = [0.0]
async def ticker():
while not stop_ticker.is_set():
await asyncio.sleep(3)
if stop_ticker.is_set():
break
try:
await msg.edit(content=build_status())
last_edit_time[0] = time.monotonic()
except Exception:
pass
ticker_task = asyncio.create_task(ticker())
async def purge_channel(channel: discord.TextChannel):
if not channel.permissions_for(ctx.guild.me).manage_messages:
failed_channels.append(f"#{channel_name}: No perms")
continue
async with lock:
stats[channel.name]["done"] = True
stats[channel.name]["error"] = "No perms"
failed_channels.append(f"#{channel.name}: No perms")
return
deleted_count, error = await safe_delete_user_messages(channel, user_id)
total_deleted += deleted_count
def on_progress(scanned: int, deleted: int):
stats[channel.name]["scanned"] = scanned
stats[channel.name]["deleted"] = deleted
if error:
failed_channels.append(f"#{channel_name}: {error}")
async with semaphore:
deleted_count, error = await safe_delete_user_messages(channel, user_id, on_progress)
async with lock:
stats[channel.name]["scanned"] = max(stats[channel.name]["scanned"], deleted_count)
stats[channel.name]["deleted"] = deleted_count
stats[channel.name]["done"] = True
if error:
stats[channel.name]["error"] = error
failed_channels.append(f"#{channel.name}: {error}")
try:
await asyncio.gather(*(purge_channel(ch) for ch in channels))
except asyncio.CancelledError:
stop_ticker.set()
ticker_task.cancel()
total_deleted = sum(s["deleted"] for s in stats.values())
await msg.edit(content=(
f"🛑 **Purge cancelled.**\n"
f"🗑️ Deleted **{total_deleted:,}** message(s) before stopping."
))
return
stop_ticker.set()
ticker_task.cancel()
total_deleted = sum(s["deleted"] for s in stats.values())
summary = (
f"✅ Finished purging **{user_display}**.\n"
f"🗑️ Deleted **{total_deleted}** message(s)."
f"**Finished purging {user_display}.**\n"
f"🗑️ Deleted **{total_deleted:,}** message(s) across {total_channels} channels."
)
if failed_channels:
summary += f"\n⚠️ Skipped/Errored: {', '.join(failed_channels[:10])}"
@@ -235,6 +528,24 @@ class Purge(ModerationBase):
await msg.edit(content=summary)
# Log the purgememberall operation to the mod log
logger = self.bot.get_cog("Logger")
if logger and ctx.guild:
embed = discord.Embed(
title="Purge All — User Messages Wiped",
color=discord.Color.dark_red(),
timestamp=datetime.now(timezone.utc)
)
embed.add_field(name="Executed By", value=f"{ctx.author.mention} ({ctx.author})", inline=False)
embed.add_field(name="Target", value=user_display, inline=True)
embed.add_field(name="Target ID", value=str(user_id), inline=True)
embed.add_field(name="Messages Deleted", value=f"{total_deleted:,}", inline=True)
embed.add_field(name="Channels Scanned", value=str(total_channels), inline=True)
if failed_channels:
embed.add_field(name="Skipped Channels", value=", ".join(failed_channels[:10]), inline=False)
embed.set_footer(text=f"Mod ID: {ctx.author.id}")
await logger.send_log(ctx.guild.id, "message_bulk_delete", embed)
async def setup(bot: commands.Bot):
await bot.add_cog(Purge(bot))

View File

@@ -8,12 +8,29 @@ from .loader import ModerationBase
class SendEmbedCommand(ModerationBase):
"""Cog providing the !send_embed command.
Accepts an embed string produced by the embed builder tool — a base64url-
encoded, optionally zlib-compressed JSON payload — decodes it, previews the
resulting embed(s) in the mod channel, and sends them to the target channel
after confirmation.
"""
@commands.command(name="send_embed")
@ModerationBase.is_admin()
async def send_embed(self, ctx, channel: discord.TextChannel, *, embed_string: str):
"""
Send a Discord embed from the embed builder to a specified channel.
Usage: !send_embed <channel> <embed_string>
"""Send a Discord embed from the embed builder to a specified channel.
The embed_string is base64url-decoded and optionally zlib-decompressed,
then parsed as JSON. It may be a single embed object or a list of objects
(for embed chains). A preview is shown before sending.
Parameters
----------
channel:
The target channel to send the embed(s) to.
embed_string:
The encoded embed payload produced by the embed builder.
"""
# Decode the embed string
try:
@@ -23,30 +40,31 @@ class SendEmbedCommand(ModerationBase):
raw = zlib.decompress(raw)
except zlib.error:
pass # Legacy uncompressed string
embed_data = json.loads(raw.decode())
# Ensure embed_data is a list (for embed chains)
if not isinstance(embed_data, list):
embed_data = [embed_data]
# Convert embed data to Discord embeds
embeds = await self._build_embeds(embed_data)
if not embeds:
await ctx.send("❌ No valid embeds found in the provided data.")
return
except json.JSONDecodeError as e:
await ctx.send(f"❌ Invalid embed string. Could not decode JSON: `{e}`")
return
except Exception as e:
await ctx.send(f"❌ Failed to build embeds: `{e}`")
return
# Confirm before sending
view = View(timeout=60)
confirmed = {"value": False}
async def yes_callback(interaction: discord.Interaction):
if interaction.user != ctx.author:
await interaction.response.send_message("You can't confirm this action.", ephemeral=True)
@@ -54,7 +72,7 @@ class SendEmbedCommand(ModerationBase):
confirmed["value"] = True
await interaction.response.edit_message(content="✅ Confirmed. Sending embed...", view=None)
view.stop()
async def no_callback(interaction: discord.Interaction):
if interaction.user != ctx.author:
await interaction.response.send_message("You can't cancel this action.", ephemeral=True)
@@ -62,56 +80,70 @@ class SendEmbedCommand(ModerationBase):
confirmed["value"] = False
await interaction.response.edit_message(content="❌ Cancelled.", view=None)
view.stop()
yes_button = Button(label="Yes", style=discord.ButtonStyle.green)
no_button = Button(label="No", style=discord.ButtonStyle.red)
yes_button.callback = yes_callback
no_button.callback = no_callback
view.add_item(yes_button)
view.add_item(no_button)
# Send preview
embed_count = len(embeds)
preview_text = f"Send {'this embed' if embed_count == 1 else f'{embed_count} embeds'} to {channel.mention}?"
try:
await ctx.send(
preview_text,
embeds=embeds[:10] if len(embeds) <= 10 else embeds[:1], # Preview max 10 embeds or just first one
view=view
)
if len(embeds) > 10:
await ctx.send(f"⚠️ Preview shows only the first embed. Total embeds to send: {embed_count}")
except Exception as e:
await ctx.send(f"❌ Failed to send preview: `{e}`")
return
await view.wait()
if not confirmed["value"]:
return
# Send the embeds to the target channel
# Send the embeds to the target channel in batches of 10 (Discord API limit per message)
try:
# Discord allows max 10 embeds per message
for i in range(0, len(embeds), 10):
chunk = embeds[i:i+10]
await channel.send(embeds=chunk)
await ctx.send(f"✅ Successfully sent {'embed' if embed_count == 1 else f'{embed_count} embeds'} to {channel.mention}")
except discord.Forbidden:
await ctx.send(f"❌ I don't have permission to send messages in {channel.mention}")
except Exception as e:
await ctx.send(f"❌ Failed to send embeds: `{e}`")
async def _build_embeds(self, embed_data: list) -> list[discord.Embed]:
"""Build Discord embed objects from embed data"""
"""Build Discord embed objects from a list of embed data dicts.
Supports two embed types controlled by the ``type`` field:
- ``"image"`` — creates an embed containing only a single image.
- ``"full"`` (default) — creates a complete embed with all available fields.
Parameters
----------
embed_data:
List of embed dict payloads as decoded from the embed builder JSON.
Returns
-------
list[discord.Embed]
The constructed Discord embed objects, ready to send.
"""
embeds = []
for data in embed_data:
embed_type = data.get("type", "full")
# Handle image-only embeds
if embed_type == "image":
if data.get("image") and data["image"].get("url"):
@@ -119,10 +151,10 @@ class SendEmbedCommand(ModerationBase):
embed.set_image(url=data["image"]["url"])
embeds.append(embed)
continue
# Handle full embeds
embed = discord.Embed()
# Basic properties
if data.get("title"):
embed.title = data["title"]
@@ -132,7 +164,7 @@ class SendEmbedCommand(ModerationBase):
embed.url = data["url"]
if data.get("color"):
embed.color = data["color"]
# Author
if data.get("author"):
author = data["author"]
@@ -141,7 +173,7 @@ class SendEmbedCommand(ModerationBase):
url=author.get("url"),
icon_url=author.get("icon_url")
)
# Footer
if data.get("footer"):
footer = data["footer"]
@@ -149,13 +181,12 @@ class SendEmbedCommand(ModerationBase):
text=footer.get("text", ""),
icon_url=footer.get("icon_url")
)
# Timestamp
if data.get("timestamp"):
# Discord.py will handle the timestamp if we set it
from datetime import datetime
embed.timestamp = datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00"))
# Fields
if data.get("fields"):
for field in data["fields"]:
@@ -165,19 +196,19 @@ class SendEmbedCommand(ModerationBase):
value=field.get("value", "\u200b"),
inline=field.get("inline", False)
)
# Image
if data.get("image") and data["image"].get("url"):
embed.set_image(url=data["image"]["url"])
# Thumbnail
if data.get("thumbnail") and data["thumbnail"].get("url"):
embed.set_thumbnail(url=data["thumbnail"]["url"])
embeds.append(embed)
return embeds
async def setup(bot: commands.Bot):
await bot.add_cog(SendEmbedCommand(bot))
await bot.add_cog(SendEmbedCommand(bot))

View File

@@ -28,8 +28,23 @@ INVITE_PATTERN = re.compile(
re.IGNORECASE
)
class SpamProtection(commands.Cog):
"""Automatic spam detection and prevention system"""
"""Automatic spam detection and prevention system.
Detects three spam patterns and applies a 1-hour Discord timeout automatically:
1. Invite link spam — any non-whitelisted discord.gg link.
2. Cross-channel spam — messages in 3+ different channels within 10 seconds.
3. Same-channel flood — 10+ messages in the same channel within 5 seconds.
On detection, staff are notified in NOTIFICATIONS_CHANNEL_ID with an
action view offering Undo/Extend/Ban buttons. If no action is taken within
12 hours, a background task automatically extends the timeout to 24 hours.
Messages are processed through an asyncio queue so the on_message listener
never blocks. A cleanup task prunes stale in-memory tracking every 5 minutes.
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@@ -38,7 +53,7 @@ class SpamProtection(commands.Cog):
# user_id -> deque of (timestamp, channel_id, content)
self.user_messages = defaultdict(lambda: deque(maxlen=50))
# Track users already flagged (to avoid duplicate reports)
# Track users already flagged to avoid duplicate reports for the same burst
self.flagged_users = set()
self.message_queue = asyncio.Queue()
@@ -50,6 +65,11 @@ class SpamProtection(commands.Cog):
self.initialize_db()
def initialize_db(self):
"""Create the spam_actions table if it doesn't exist.
Stores pending moderation decisions so they survive a bot restart and
so the check_pending_actions loop can escalate them after 12 hours.
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("""
@@ -66,12 +86,19 @@ class SpamProtection(commands.Cog):
conn.close()
async def cog_unload(self):
"""Cancel all background tasks when the cog unloads."""
self.cleanup_tracking.cancel()
self.check_pending_actions.cancel()
self.process_message_queue.cancel()
@tasks.loop(minutes=5)
async def cleanup_tracking(self):
"""Prune stale per-user message history every 5 minutes.
Removes entries older than the longest detection window, then removes
users with no remaining history from both the tracking dict and the
flagged set to keep memory bounded.
"""
now = datetime.now(timezone.utc)
cutoff = now - timedelta(seconds=max(CROSS_CHANNEL_WINDOW_SECONDS, SAME_CHANNEL_WINDOW_SECONDS))
@@ -82,6 +109,7 @@ class SpamProtection(commands.Cog):
if not messages:
del self.user_messages[user_id]
# Keep only flagged users who still have tracked messages
self.flagged_users = {
uid for uid in self.flagged_users
if uid in self.user_messages
@@ -89,6 +117,12 @@ class SpamProtection(commands.Cog):
@tasks.loop(minutes=1)
async def check_pending_actions(self):
"""Escalate unresolved spam timeouts to 24 hours after 12 hours.
Checks the spam_actions table for rows whose expires_at has passed
(meaning no staff member clicked a button on the alert), and applies
the extended default action to each.
"""
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
@@ -107,7 +141,17 @@ class SpamProtection(commands.Cog):
conn.close()
async def apply_default_action(self, user_id: int, guild_id: int, spam_type: str):
"""Extend timeout to 24 hours when no staff response within 12 hours"""
"""Extend timeout to 24 hours when no staff response within 12 hours.
Parameters
----------
user_id:
ID of the member to extend the timeout for.
guild_id:
ID of the guild the member belongs to.
spam_type:
The spam pattern type string (for logging context).
"""
guild = self.bot.get_guild(guild_id)
if not guild:
return
@@ -140,9 +184,11 @@ class SpamProtection(commands.Cog):
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Queue messages for spam analysis, skipping exempt users and channels."""
if message.author.bot or not message.guild:
return
# Ritual Member role is exempt from spam detection
whitelisted_role = message.guild.get_role(WHITELISTED_ROLE_ID)
if whitelisted_role and whitelisted_role in message.author.roles:
return
@@ -151,10 +197,11 @@ class SpamProtection(commands.Cog):
if message.channel.category_id == WHITELISTED_CATEGORY_ID:
return
# Skip if already timed out
# Skip if already timed out — their messages can't cause further harm
if message.author.timed_out_until and message.author.timed_out_until > datetime.now(timezone.utc):
return
# Avoid re-reporting a user mid-burst if they're already being processed
if message.author.id in self.flagged_users:
return
@@ -162,6 +209,7 @@ class SpamProtection(commands.Cog):
@tasks.loop(seconds=0.1)
async def process_message_queue(self):
"""Drain up to 10 messages from the queue per tick to cap processing latency."""
try:
for _ in range(10):
try:
@@ -173,6 +221,7 @@ class SpamProtection(commands.Cog):
logger.error(f"Error in message queue processing: {e}", exc_info=True)
async def _process_message(self, message: discord.Message):
"""Record the message in the user's history and check for spam patterns."""
now = datetime.now(timezone.utc)
user_id = message.author.id
@@ -188,6 +237,12 @@ class SpamProtection(commands.Cog):
await self.handle_spam(message.author, message.guild, spam_detected)
async def check_spam_patterns(self, member: discord.Member, guild: discord.Guild, content: str):
"""Check the user's recent message history for any of the three spam patterns.
Returns a spam data dict on match, or None if no pattern is triggered.
The invite check fires even for a single message because a non-whitelisted
invite sent anywhere is always a violation regardless of channel count.
"""
messages = self.user_messages[member.id]
if not messages:
@@ -245,9 +300,23 @@ class SpamProtection(commands.Cog):
return None
async def handle_spam(self, member: discord.Member, guild: discord.Guild, spam_data: dict):
"""Apply a 1-hour timeout and send an alert to staff.
Also writes a spam_actions row with a 12-hour expiry so the
check_pending_actions loop can auto-escalate if no staff respond.
Parameters
----------
member:
The member who triggered spam detection.
guild:
The guild the spam occurred in.
spam_data:
Dict returned by check_spam_patterns describing the pattern matched.
"""
self.flagged_users.add(member.id)
# Apply 1-hour timeout
# Apply 1-hour timeout immediately
timeout_until = datetime.now(timezone.utc) + timedelta(hours=1)
try:
await member.timeout(timeout_until, reason="Automatic spam detection")
@@ -356,6 +425,7 @@ class SpamProtection(commands.Cog):
try:
msg = await notif_channel.send(embed=embed, view=view)
# Store the alert so check_pending_actions can escalate after 12 hours
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
expires_at = (datetime.now(timezone.utc) + timedelta(hours=12)).isoformat()
@@ -380,6 +450,13 @@ class SpamProtection(commands.Cog):
class SpamActionView(View):
"""Staff action buttons for a spam alert.
Timeout is 12 hours — matching the DB expiry for escalation. When any
button is successfully actioned, the alert row is removed from spam_actions
so the auto-escalate loop doesn't also fire.
"""
def __init__(self, bot: commands.Bot, member: discord.Member, guild: discord.Guild, spam_data: dict, db_path: str):
super().__init__(timeout=43200) # 12 hours
self.bot = bot
@@ -390,6 +467,7 @@ class SpamActionView(View):
self.alert_message_id = None
async def _remove_from_pending(self):
"""Delete the spam_actions row so the escalation loop won't fire."""
if self.alert_message_id:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
@@ -398,12 +476,14 @@ class SpamActionView(View):
conn.close()
def _check_mod(self, interaction: discord.Interaction) -> bool:
"""Return True if the interacting user has moderate_members permission."""
if not isinstance(interaction.user, discord.Member):
return False
return interaction.user.guild_permissions.moderate_members
@discord.ui.button(label="Undo Timeout", style=discord.ButtonStyle.green, emoji="")
async def undo_timeout_button(self, interaction: discord.Interaction, button: Button):
"""Remove the timeout — used when the detection was a false positive."""
if not self._check_mod(interaction):
await interaction.response.send_message("❌ You don't have permission to do that.", ephemeral=True)
return
@@ -441,6 +521,7 @@ class SpamActionView(View):
@discord.ui.button(label="Extend to 24h", style=discord.ButtonStyle.gray, emoji="⏱️")
async def extend_timeout_button(self, interaction: discord.Interaction, button: Button):
"""Extend the timeout to 24 hours — used when spam is confirmed."""
if not self._check_mod(interaction):
await interaction.response.send_message("❌ You don't have permission to do that.", ephemeral=True)
return
@@ -479,6 +560,7 @@ class SpamActionView(View):
@discord.ui.button(label="Ban User", style=discord.ButtonStyle.red, emoji="🔨")
async def ban_button(self, interaction: discord.Interaction, button: Button):
"""Ban the user outright — requires ban_members permission."""
if not isinstance(interaction.user, discord.Member) or not interaction.user.guild_permissions.ban_members:
await interaction.response.send_message("❌ You don't have permission to ban members.", ephemeral=True)
return
@@ -505,6 +587,16 @@ class SpamActionView(View):
try:
await self.guild.ban(self.member, reason=reason, delete_message_days=1)
# Write infraction to DB
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("""
INSERT INTO infractions (user_id, guild_id, type, reason, moderator_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?)
""", (self.member.id, self.guild.id, "ban", reason, interaction.user.id, datetime.utcnow().isoformat()))
conn.commit()
conn.close()
log_cog = self.bot.get_cog("Logger")
if log_cog:
await log_cog.log_moderation_action(
@@ -524,6 +616,8 @@ class SpamActionView(View):
class ConfirmView(View):
"""Generic ephemeral confirm/cancel view for spam action buttons."""
def __init__(self, user: discord.User):
super().__init__(timeout=30)
self.user = user
@@ -531,6 +625,7 @@ class ConfirmView(View):
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.green)
async def confirm_button(self, interaction: discord.Interaction, button: Button):
"""Only the moderator who invoked the parent action can confirm."""
if interaction.user.id != self.user.id:
await interaction.response.send_message("❌ Only the moderator who initiated this can confirm.", ephemeral=True)
return
@@ -540,6 +635,7 @@ class ConfirmView(View):
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.gray)
async def cancel_button(self, interaction: discord.Interaction, button: Button):
"""Only the moderator who invoked the parent action can cancel."""
if interaction.user.id != self.user.id:
await interaction.response.send_message("❌ Only the moderator who initiated this can cancel.", ephemeral=True)
return

View File

@@ -2,11 +2,26 @@ import discord
from discord.ext import commands
from .loader import ModerationBase
class UnbanCommand(ModerationBase):
"""Cog providing the !unban prefix command."""
@commands.command(name="unban")
@ModerationBase.is_admin()
async def unban(self, ctx, user: discord.User | str, *, reason: str | None = None):
"""Unban a user by mention, ID, or name."""
"""Unban a user by mention, ID, or name.
Resolves a raw mention or user ID string into a User object if needed,
then removes the ban and logs the action.
Parameters
----------
user:
The user to unban (mention, ID, or User object).
reason:
Optional reason for the unban.
"""
# Resolve a raw ID string or mention into a User object
if isinstance(user, str):
user_id = user.strip("<@!>")
try:
@@ -23,8 +38,10 @@ class UnbanCommand(ModerationBase):
await ctx.send(f"{user.mention if hasattr(user, 'mention') else user} has been unbanned.")
except Exception as e:
await ctx.send(f"❌ Failed to unban: `{e}`")
return
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "unban", reason)
# Log the action
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(ctx.guild.id, "unban", user, ctx.author, reason)

View File

@@ -7,12 +7,31 @@ from .loader import ModerationBase
DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db"
# Role ID for the server's muted role — must match mute.py
MUTE_ROLE_ID = 982702037517090836
class UnmuteCommand(ModerationBase):
"""Cog providing the !unmute prefix command.
Removes the mute role from a member immediately and clears their entry from
the persistent `mutes` table, preventing `mute.py`'s background task from
trying to unmute them again when the timer expires.
"""
@commands.command(name="unmute")
@ModerationBase.is_admin()
async def unmute(self, ctx, user: discord.Member):
"""Manually remove a mute from a member with a confirmation prompt.
Removes the mute role, deletes the DB record so the scheduled unmute
won't fire again, and sends a DM to the user.
Parameters
----------
user:
The server member to unmute.
"""
view = View(timeout=30)
confirmed = {"value": False}
@@ -51,11 +70,14 @@ class UnmuteCommand(ModerationBase):
if mute_role in user.roles:
await user.remove_roles(mute_role, reason="Manual unmute issued")
# Remove from DB so the scheduled unmute task won't fire when the timer expires
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user.id, ctx.guild.id))
conn.commit()
conn.close()
try:
await user.send(f"You have been **unmuted** in **{ctx.guild.name}**.")
except Exception:
@@ -63,8 +85,7 @@ class UnmuteCommand(ModerationBase):
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "unmute", "Manual unmute issued")
await ctx.send(f"{user.mention} has been unmuted.")
# Log to logging system
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(
@@ -73,5 +94,6 @@ class UnmuteCommand(ModerationBase):
else:
await ctx.send(f"{user.mention} is not currently muted.")
async def setup(bot: commands.Bot):
await bot.add_cog(UnmuteCommand(bot))
await bot.add_cog(UnmuteCommand(bot))

View File

@@ -3,11 +3,25 @@ from discord.ext import commands
from discord.ui import View, Button
from .loader import ModerationBase
class WarnCommand(ModerationBase):
"""Cog providing the !warn prefix command."""
@commands.command(name="warn")
@ModerationBase.is_admin()
async def warn(self, ctx, user: discord.Member, *, reason: str | None = None):
"""Warn a user with confirmation and log infraction"""
"""Warn a member with a confirmation prompt.
Sends a DM to the user with the warning reason and writes an
infraction record to the database.
Parameters
----------
user:
The server member to warn.
reason:
Optional reason for the warning.
"""
view = View(timeout=30)
confirmed = {"value": False}
@@ -34,7 +48,10 @@ class WarnCommand(ModerationBase):
view.add_item(yes_button)
view.add_item(no_button)
await ctx.send(f"Are you sure you want to warn {user.mention}? Reason: {reason or 'No reason provided'}", view=view)
await ctx.send(
f"Are you sure you want to warn {user.mention}? Reason: {reason or 'No reason provided'}",
view=view
)
await view.wait()
if not confirmed["value"]:
return
@@ -43,19 +60,20 @@ class WarnCommand(ModerationBase):
return
try:
await user.send(f"You have been **warned** in **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}")
except Exception:
await user.send(
f"You have been **warned** in **{ctx.guild.name}**.\n"
f"Reason: {reason or 'No reason provided'}"
)
except (discord.Forbidden, discord.HTTPException):
await ctx.send("Could not DM the user.")
await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "warn", reason)
await ctx.send(f"{user.mention} has been warned.")
# Log to logging system
logger = self.bot.get_cog("Logger")
if logger:
await logger.log_moderation_action(
ctx.guild.id, "warn", user, ctx.author, reason
)
await logger.log_moderation_action(ctx.guild.id, "warn", user, ctx.author, reason)
async def setup(bot: commands.Bot):
await bot.add_cog(WarnCommand(bot))
await bot.add_cog(WarnCommand(bot))

View File

@@ -16,6 +16,7 @@ import traceback
FONTS_PATH = Path(__file__).parent / "fonts"
def list_fonts():
"""Return a list of font names (without extension) from the profiles/fonts directory."""
fonts = []
if os.path.exists(FONTS_PATH):
for f in os.listdir(FONTS_PATH):
@@ -23,7 +24,20 @@ def list_fonts():
fonts.append(os.path.splitext(f)[0])
return fonts
def generate_gradient_image(colors, width, height):
"""Generate a horizontal multi-stop gradient PIL image.
Splits the total width evenly between each consecutive color pair, renders
each segment as a 2-D numpy gradient, and pastes them into a single image.
Parameters
----------
colors:
List of hex color strings (e.g. ["#FF0000", "#00FF00", "#0000FF"]).
width, height:
Pixel dimensions of the output image.
"""
try:
# source: https://note.nkmk.me/en/python-numpy-generate-gradation-image/
@@ -68,6 +82,7 @@ async def font_name_autocomplete(
interaction: discord.Interaction,
current: str,
):
"""Autocomplete callback for the font_name parameter in /profile set."""
fonts = list_fonts()
return_array = [
app_commands.Choice(name=font_name, value=font_name)
@@ -77,7 +92,19 @@ async def font_name_autocomplete(
class Profiles(commands.GroupCog, name="profile"):
"""Profile commands"""
"""GroupCog providing the /profile slash-command group.
Subcommands:
- fonts — render a visual preview image of all available fonts.
- set — update one or more profile fields (all optional, any subset).
- view — render and return a 1000×550 PNG profile card.
The profile card (view) is fully rendered in PIL via asyncio.to_thread
because image generation is CPU-bound. It features a two-panel layout:
left panel with circular avatar and top roles, right panel with profile
fields. The background supports solid colours or multi-stop gradients.
Font choice is persisted per-user in profiles.db.
"""
def __init__(self, bot):
self.bot = bot
@@ -85,6 +112,7 @@ class Profiles(commands.GroupCog, name="profile"):
@app_commands.command(name="fonts", description="List all available fonts with a visual preview.")
async def list_fonts_cmd(self, interaction: discord.Interaction):
"""Render and send a preview image showing every available font with a sample sentence."""
await interaction.response.defer(thinking=True)
fonts = list_fonts()
@@ -165,6 +193,12 @@ class Profiles(commands.GroupCog, name="profile"):
)
@app_commands.autocomplete(font_name=font_name_autocomplete)
async def setprofile(self, interaction: discord.Interaction, pronouns: str = None, about_me: str = None, fav_color: str = None, bg_color: str = None, fav_game: str = None, fav_artist: str = None, birthday: str = None, font_name: str = None):
"""Update profile fields for the caller.
Only provided (non-None) fields are written; existing values for
omitted fields are preserved. bg_color accepts space-separated hex
codes (up to 5) for gradient backgrounds.
"""
# Defer the response to show "thinking"
await interaction.response.defer(thinking=True)
@@ -248,6 +282,7 @@ class Profiles(commands.GroupCog, name="profile"):
@app_commands.command(name="view", description="View your or another user's profile.")
async def profile(self, interaction: discord.Interaction, member: discord.Member = None):
"""Render and send the member's profile card as a PNG attachment."""
# Defer immediately to show "thinking"
await interaction.response.defer(thinking=True)

View File

@@ -17,6 +17,7 @@ KEY_PATH = Path(__file__).parent.parent / "data" / "reminder.key"
def load_or_create_key() -> Fernet:
"""Load the Fernet encryption key from disk, generating and saving a new one if absent."""
if KEY_PATH.exists():
key = KEY_PATH.read_bytes()
else:
@@ -29,10 +30,17 @@ fernet = load_or_create_key()
def encrypt(text: str) -> str:
"""Encrypt a reminder message string for storage in the database."""
return fernet.encrypt(text.encode()).decode()
def decrypt(token: str) -> str:
"""Decrypt a reminder message from the database.
Falls back to returning the raw value if decryption fails, which handles
rows that were stored before encryption was introduced or if the key file
was rotated. The warning log helps identify such legacy rows.
"""
try:
return fernet.decrypt(token.strip().encode()).decode()
except InvalidToken:
@@ -105,11 +113,26 @@ def parse_datetime(when: str, tzname: str | None) -> datetime:
class ReminderCog(commands.Cog):
"""Cog providing the /reminder slash-command group.
Reminder messages are encrypted at rest using Fernet symmetric encryption
(key stored in data/reminder.key). A background task fires every 60 seconds
to deliver due reminders via DM and then delete them from the database.
Subcommands:
- set — relative duration (e.g. 10m, 2h, 3d).
- at — absolute date/time with optional IANA timezone.
- list — view all active reminders with time-remaining summary.
- remove — delete a specific reminder by ID.
- clear — delete all reminders for the caller.
"""
def __init__(self, bot):
self.bot = bot
self.db_path = Path(__file__).parent.parent / "data" / "reminders.db"
async def setup_database(self):
"""Create the reminders table if it doesn't exist."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""CREATE TABLE IF NOT EXISTS reminders (
@@ -122,11 +145,13 @@ class ReminderCog(commands.Cog):
await db.commit()
async def cog_load(self):
"""Set up the database and start the delivery loop."""
await self.setup_database()
if not self.check_reminders.is_running():
self.check_reminders.start()
async def _insert_reminder(self, user_id: int, message: str, remind_at: datetime) -> int:
"""Encrypt and insert a new reminder row, returning the auto-assigned ID."""
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO reminders (user_id, message, remind_at) VALUES (?, ?, ?)",
@@ -144,6 +169,7 @@ class ReminderCog(commands.Cog):
message="What to remind you about"
)
async def reminder_set(self, interaction: discord.Interaction, timeframe: str, message: str):
"""Set a reminder at a relative duration from now."""
try:
try:
delta = parse_timeframe(timeframe)
@@ -170,6 +196,7 @@ class ReminderCog(commands.Cog):
timezone="Your timezone, e.g. 'US/Eastern', 'UTC+5', 'Europe/London' (default: UTC)"
)
async def reminder_at(self, interaction: discord.Interaction, when: str, message: str, timezone: str | None = None):
"""Set a reminder at a specific date/time, parsed via dateutil with optional IANA timezone."""
try:
try:
remind_at = parse_datetime(when, timezone)
@@ -190,6 +217,7 @@ class ReminderCog(commands.Cog):
@reminder_group.command(name="list", description="View your active reminders")
async def reminder_list(self, interaction: discord.Interaction):
"""List all active reminders with a human-readable time-remaining string."""
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT id, message, remind_at FROM reminders WHERE user_id = ? ORDER BY remind_at",
@@ -242,6 +270,7 @@ class ReminderCog(commands.Cog):
@reminder_group.command(name="remove", description="Remove a specific reminder by ID")
@app_commands.describe(reminder_id="The ID of the reminder to remove (from /reminder list)")
async def reminder_remove(self, interaction: discord.Interaction, reminder_id: int):
"""Delete a single reminder, verifying it belongs to the caller before removing."""
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT message FROM reminders WHERE id = ? AND user_id = ?",
@@ -270,6 +299,7 @@ class ReminderCog(commands.Cog):
@reminder_group.command(name="clear", description="Remove all your active reminders")
async def reminder_clear(self, interaction: discord.Interaction):
"""Delete all of the caller's reminders in a single operation."""
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT COUNT(*) FROM reminders WHERE user_id = ?",
@@ -291,6 +321,13 @@ class ReminderCog(commands.Cog):
@tasks.loop(seconds=60)
async def check_reminders(self):
"""Deliver all due reminders and delete them.
Runs every 60 seconds. Reminders whose fire time has passed are
fetched in bulk, then delivered via DM. DM failures from Forbidden
(user blocked DMs) are treated as delivered and removed so they don't
pile up. Transient HTTPException errors are left for the next cycle.
"""
now = datetime.now(timezone.utc)
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
@@ -337,6 +374,7 @@ class ReminderCog(commands.Cog):
@check_reminders.before_loop
async def before_check_reminders(self):
"""Wait for the bot to be fully connected before starting the delivery loop."""
await self.bot.wait_until_ready()

View File

@@ -1,4 +1,5 @@
aiofiles==25.1.0
mcrcon>=0.7.0
redis>=5.0
aiohttp==3.12.15
aiosqlite==0.21.0

View File

@@ -5,6 +5,19 @@ import time
class Sparkle(commands.Cog):
"""Cog that randomly awards sparkle reactions based on message ID trailing digits.
Sparkle probability is determined by inspecting the Discord snowflake ID of
each incoming message:
- Regular (✨): ID ends in "000" → ~1/1,000
- Rare (🌟): ID ends in "0000" → ~1/10,000
- Epic (💫): ID ends in "00000" → ~1/100,000
The longer suffix takes precedence (checked most-specific first).
Both the per-user count and the timestamped event log are updated in the DB
via asyncio.to_thread to avoid blocking the event loop.
"""
def __init__(self, bot):
self.bot = bot
self.chances = {
@@ -14,7 +27,7 @@ class Sparkle(commands.Cog):
}
async def _add_sparkle(self, message, sparkle_type):
"""Add a sparkle reaction and update the database."""
"""React to the message and update both the sparkle counter and event log in the DB."""
emoji, description = self.chances[sparkle_type][1:]
# Add reaction and send notification

View File

@@ -12,6 +12,18 @@ from embed.embed_color import get_embed_color
class SparkleCommands(commands.Cog):
"""Cog providing the /sparkle slash-command group for viewing sparkle counts and stats.
Subcommands:
- check — per-user sparkle counts by type.
- info — explanation of sparkle probabilities.
- leaderboard — randomly ordered server leaderboard (up to 20 entries).
- stats — server-wide totals, message-per-sparkle ratios, and timing data.
All database reads are offloaded to a thread executor because sparkle.db
uses the synchronous sqlite3 driver.
"""
def __init__(self, bot):
self.bot = bot
self.sparkle_emojis = {
@@ -29,6 +41,7 @@ class SparkleCommands(commands.Cog):
@sparkle_group.command(name="check", description="Check your sparkle count or another user's")
@app_commands.describe(user="The user to check sparkle count for (leave empty for yourself)")
async def sparkle_check(self, interaction: discord.Interaction, user: Optional[discord.User] = None):
"""Show epic, rare, regular, and total sparkle counts for the target member."""
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
@@ -81,6 +94,7 @@ class SparkleCommands(commands.Cog):
# ========== /sparkle info ==========
@sparkle_group.command(name="info", description="Learn about sparkles and how they work")
async def sparkle_info(self, interaction: discord.Interaction):
"""Send an embed explaining each sparkle type and its probability."""
embed = discord.Embed(
title="✨ Sparkles ✨",
description=(
@@ -100,6 +114,13 @@ class SparkleCommands(commands.Cog):
@sparkle_group.command(name="leaderboard", description="Show random sparkle leaderboard")
@app_commands.describe(limit="Number of users to show (max 20, default 10)")
async def sparkle_leaderboard(self, interaction: discord.Interaction, limit: int = 10):
"""Show a randomly ordered sparkle leaderboard for current guild members.
Filters to members currently in the guild (in-memory set) before
querying to avoid showing data for users who left. Ordered randomly
(ORDER BY RANDOM()) because a fixed rank ordering would always be the
same given equal totals.
"""
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
@@ -166,6 +187,12 @@ class SparkleCommands(commands.Cog):
# ========== /sparkle stats ==========
@sparkle_group.command(name="stats", description="View server sparkle statistics")
async def sparkle_stats(self, interaction: discord.Interaction):
"""Show server-wide sparkle totals, message-per-sparkle ratios, and per-type timing.
Pulls total counts from the sparkles table and timestamped events from
sparkle_events. Also opens stats.db from a separate connection to read
the total message count for the messages-per-sparkle calculation.
"""
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return

View File

@@ -17,6 +17,13 @@ BOT_DEV_ROLE_ID = 1470439484549234866
class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
"""Modal that collects an optional deny reason from a moderator.
Opened by the Deny button on a SuggestionButtons view. On submit it
updates the DB, edits the admin embed (red, disabled buttons), DMs the
submitter, and posts a notice in the original suggestion channel.
"""
reason = discord.ui.TextInput(label="Reason (optional)", style=discord.TextStyle.long, required=False, max_length=2000)
def __init__(self, suggestion_id: int, user_id: int, suggestion_text: str, channel_id: int, admin_message_id: Optional[int], bot: commands.Bot, original_embed: discord.Embed):
@@ -30,6 +37,7 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
self.original_embed = original_embed
async def on_submit(self, interaction: discord.Interaction):
"""Process the denial: update DB, edit admin embed, DM submitter, post channel notice."""
try:
await interaction.response.defer(ephemeral=True)
@@ -48,12 +56,15 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
if admin_channel and isinstance(admin_channel, discord.abc.Messageable):
orig_msg = await admin_channel.fetch_message(self.admin_message_id)
updated_embed = self.original_embed.copy()
base_embed = self.original_embed if self.original_embed is not None else (orig_msg.embeds[0] if orig_msg.embeds else discord.Embed())
updated_embed = base_embed.copy()
updated_embed.color = discord.Color.red()
updated_embed.title = f"❌ Denied Suggestion (ID: {self.suggestion_id})"
updated_embed.set_field_at(0, name="Suggested by", value=updated_embed.fields[0].value, inline=True)
updated_embed.set_field_at(1, name="Channel", value=updated_embed.fields[1].value, inline=True)
if len(updated_embed.fields) > 0:
updated_embed.set_field_at(0, name="Suggested by", value=updated_embed.fields[0].value, inline=True)
if len(updated_embed.fields) > 1:
updated_embed.set_field_at(1, name="Channel", value=updated_embed.fields[1].value, inline=True)
updated_embed.add_field(name="Status", value="Denied", inline=False)
updated_embed.add_field(name="Denied by", value=f"{interaction.user.mention}", inline=True)
updated_embed.add_field(name="Denied at", value=f"<t:{int(datetime.utcnow().timestamp())}:F>", inline=True)
@@ -103,6 +114,17 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"):
class SuggestionButtons(discord.ui.View):
"""Persistent approval/deny/complete button view attached to admin-channel suggestion embeds.
timeout=None makes the view survive bot restarts (re-registered in cog_load).
show_complete controls which buttons are shown:
- False (default): Approve and Deny buttons (shown on Pending suggestions).
- True: Mark Complete button only (shown after a suggestion is Approved).
disabled=True renders all buttons greyed out (used after a terminal action).
"""
def __init__(self, bot, suggestion_id=None, user_id=None, suggestion_text=None, channel_id=None, admin_message_id: Optional[int] = None, disabled: bool = False, show_complete: bool = False):
super().__init__(timeout=None)
self.bot = bot
@@ -130,6 +152,7 @@ class SuggestionButtons(discord.ui.View):
self.add_item(complete_btn)
async def approve(self, interaction: discord.Interaction):
"""Approve the suggestion: update DB, swap buttons to 'Mark Complete', DM submitter."""
try:
has_permission = (
interaction.user.id == ADMIN_ID or
@@ -206,6 +229,7 @@ class SuggestionButtons(discord.ui.View):
pass
async def deny(self, interaction: discord.Interaction):
"""Open the DenyModal to collect an optional reason before denying."""
try:
has_permission = (
interaction.user.id == ADMIN_ID or
@@ -252,6 +276,7 @@ class SuggestionButtons(discord.ui.View):
pass
async def complete(self, interaction: discord.Interaction):
"""Mark an already-approved suggestion as Completed. Only valid if status == 'Approved'."""
try:
has_permission = (
interaction.user.id == ADMIN_ID or
@@ -339,6 +364,12 @@ class SuggestionButtons(discord.ui.View):
class PaginationView(discord.ui.View):
"""Simple prev/next paginator for multi-page suggestion list embeds.
Times out after 3 minutes of inactivity. Only the user who invoked the
command can interact with the buttons (others get an ephemeral error).
"""
def __init__(self, embeds, user: discord.User):
super().__init__(timeout=180)
self.embeds = embeds
@@ -352,6 +383,7 @@ class PaginationView(discord.ui.View):
self.previous_button.disabled = True
async def update_page(self, interaction: discord.Interaction):
"""Re-render the current page and update button disabled state."""
self.previous_button.disabled = self.current_page == 0
self.next_button.disabled = self.current_page == len(self.embeds) - 1
await interaction.response.edit_message(embed=self.embeds[self.current_page], view=self)
@@ -376,6 +408,26 @@ class PaginationView(discord.ui.View):
class Suggestion(commands.GroupCog, name="suggest"):
"""GroupCog providing the /suggest slash-command group.
Subcommands:
- submit — submit a new suggestion (any member).
- view — view full details of a suggestion by ID.
- list — paginated list with optional status/member filters.
- stats — per-member submission counts.
- complete — admin-only command to mark an approved suggestion as done.
- todo — admin-only to-do list of approved suggestions the caller approved.
Workflow:
1. Member submits a suggestion; it is inserted into suggestions.db and an
embed with Approve/Deny buttons is posted in the admin channel.
2. Admin approves → embed turns green, buttons swap to "Mark Complete".
3. Admin marks complete → embed turns blue, buttons disabled.
OR admin denies (via DenyModal) → embed turns red, buttons disabled.
At cog load, all Pending and Approved suggestion views are re-registered
with add_view so buttons keep working across bot restarts.
"""
def __init__(self, bot):
self.bot = bot
@@ -383,6 +435,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
self.db = None
async def cog_load(self):
"""Open the database, create the table, and re-register persistent views."""
self.db = await aiosqlite.connect(self.db_path)
await self.db.execute("""
CREATE TABLE IF NOT EXISTS suggestions (
@@ -415,11 +468,18 @@ class Suggestion(commands.GroupCog, name="suggest"):
logger.info(f"Re-registered view for suggestion #{sid} (message {admin_msg_id}, status: {status})")
async def cog_unload(self):
"""Close the persistent database connection on unload."""
if self.db:
await self.db.close()
@app_commands.command(name="submit", description="Submit a suggestion")
async def suggest(self, interaction: discord.Interaction, idea: str):
"""Insert the suggestion into the DB, confirm in-channel, and post to admin channel.
After sending the admin embed, stores the resulting message ID back into
the DB row so the embed can be edited on approve/deny/complete, and
registers a persistent view so the buttons survive restarts.
"""
await interaction.response.defer(ephemeral=False)
try:
@@ -473,6 +533,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
@app_commands.command(name="view", description="View full details of a suggestion")
async def viewsuggestion(self, interaction: discord.Interaction, suggestion_id: int):
"""Display a single suggestion's full text, status, submitter, and deny reason if any."""
await interaction.response.defer(ephemeral=False)
try:
@@ -519,6 +580,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
@app_commands.command(name="complete", description="Mark an approved suggestion as completed (Admin only)")
async def completesuggestion(self, interaction: discord.Interaction, suggestion_id: int):
"""Admin slash command to mark a suggestion complete (mirrors the button in the admin embed)."""
await interaction.response.defer(ephemeral=False)
try:
@@ -608,6 +670,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
app_commands.Choice(name="Completed", value="Completed")
])
async def listsuggestions(self, interaction: discord.Interaction, status: Optional[app_commands.Choice[str]] = None, member: Optional[discord.Member] = None):
"""Show a paginated list of suggestions, optionally filtered by status and/or member."""
await interaction.response.defer(ephemeral=False)
try:
@@ -667,6 +730,7 @@ class Suggestion(commands.GroupCog, name="suggest"):
@app_commands.command(name="stats", description="View suggestion stats for yourself or another user")
async def suggestionstats(self, interaction: discord.Interaction, member: Optional[discord.Member] = None):
"""Show total, accepted (Approved+Completed), and rejected counts for a member."""
await interaction.response.defer(ephemeral=False)
try:
@@ -707,6 +771,11 @@ class Suggestion(commands.GroupCog, name="suggest"):
@app_commands.command(name="todo", description="View your approved suggestions to-do list")
async def todolist(self, interaction: discord.Interaction, member: Optional[discord.Member] = None):
"""Admin-only: list approved suggestions the target user approved, awaiting completion.
Determines authorship by fetching each approved suggestion's admin embed
and checking the "Approved by" field for the target's mention.
"""
await interaction.response.defer(ephemeral=False)
try:

View File

@@ -1,27 +1,34 @@
"""
constants.py — Centralised Discord IDs and threshold values for the bot.
All hardcoded IDs (users, guild, channels, roles, emojis) live here so they
can be updated in one place without hunting through individual cog files.
"""
# --- Owner / Admin ---
LILAC_ID = 252130669919076352
LILAC_ID = 252130669919076352 # Bot owner; bypasses all permission checks
# --- Guild ---
GUILD_ID = 876772600704020530
GUILD_ID = 876772600704020530 # Primary server the bot operates in
# --- Channels ---
WELCOME_CHANNEL_ID = 876772600704020533
FALLBACK_CHANNEL_ID = 876772600704020533
LOG_CHANNEL_ID = 1440055015711703242
ADMIN_CHANNEL_ID = 1470441786810826884
APPROVAL_CHANNEL_ID = 1424145004976275617
BACKUP_CHANNEL_ID = 946421558778417172
NOTIFICATION_CHANNEL_ID = 1424145004976275617
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884]
WELCOME_CHANNEL_ID = 876772600704020533 # Where welcome messages are posted
FALLBACK_CHANNEL_ID = 876772600704020533 # Fallback channel when a specific channel is unavailable
LOG_CHANNEL_ID = 1440055015711703242 # Default channel for mod/system log messages
ADMIN_CHANNEL_ID = 1470441786810826884 # Staff-only admin channel
APPROVAL_CHANNEL_ID = 1424145004976275617 # Where pending approval embeds (infractions, credits) are sent
BACKUP_CHANNEL_ID = 946421558778417172 # Backup/archive channel
NOTIFICATION_CHANNEL_ID = 1424145004976275617 # General notification channel (same as approval)
COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884] # Git webhook targets
# --- Roles ---
BIRTHDAY_ROLE_ID = 1113751318918602762
BOT_TRAP_ROLE_ID = 1439354601672282335
SERVER_ADMIN_ROLE_ID = 952560403970416722
BOT_DEV_ROLE_ID = 1470439484549234866
BIRTHDAY_ROLE_ID = 1113751318918602762 # Temporary role assigned on a member's birthday
BOT_TRAP_ROLE_ID = 1439354601672282335 # Role given to suspected bots; blocks welcome message
SERVER_ADMIN_ROLE_ID = 952560403970416722 # General staff/admin role
BOT_DEV_ROLE_ID = 1470439484549234866 # Bot developer role with elevated permissions
# --- Emojis ---
SALT_EMOJI_ID = 1074583707459010560
SALT_EMOJI_ID = 1074583707459010560 # Custom :salt: emoji used in reactions
# --- Thresholds ---
NEW_MEMBER_THRESHOLD_DAYS = 7
NEW_MEMBER_THRESHOLD_DAYS = 7 # Members newer than this many days are considered "new"

View File

@@ -5,6 +5,17 @@ from .utils import get_multiplier, random_xp, can_get_xp, check_level_up
from .exclude_channels import is_channel_excluded
async def add_xp(user):
"""Award XP to a guild member for sending a message.
Called from the global on_message handler in bot.py for every non-bot
message. Skips DM users, excluded channels, and members still on the
per-message cooldown (checked against the lifetime DB).
A single random base XP value is generated once and then applied across
all five leaderboard databases (lifetime, annual, monthly, weekly, daily).
The role multiplier is only applied to the lifetime database so bonus
rates don't distort the periodic leaderboards.
"""
# Only process XP for guild members, not DM users
if not isinstance(user, discord.Member):
return

View File

@@ -6,6 +6,21 @@ DB_DIR = Path(__file__).parent.parent / "data"
DB_DIR.mkdir(parents=True, exist_ok=True)
def get_db(db_type="lifetime"):
"""Open (or create) an XP database for the given leaderboard type and return (conn, cursor).
Accepts a boolean for backwards-compatibility: True → "lifetime", False → "annual".
Each leaderboard type has its own SQLite file (e.g. lifetime.db, weekly.db).
The daily/weekly/monthly databases also include a reset_log table that
records the last time the leaderboard was wiped, so the UI can display
'last reset X ago'.
Parameters
----------
db_type:
One of "lifetime", "annual", "monthly", "weekly", "daily" (or a bool).
Defaults to "lifetime" for any unrecognised value.
"""
if isinstance(db_type, bool):
db_type = "lifetime" if db_type else "annual"
@@ -40,6 +55,7 @@ def get_db(db_type="lifetime"):
return conn, cur
def reset_leaderboard(db_type):
"""Wipe all XP rows and record the reset timestamp in reset_log."""
import time
conn, cur = get_db(db_type)
@@ -48,7 +64,9 @@ def reset_leaderboard(db_type):
conn.commit()
conn.close()
def get_last_reset(db_type):
"""Return the Unix timestamp of the last leaderboard reset, or 0 if never reset."""
conn, cur = get_db(db_type)
cur.execute("SELECT last_reset FROM reset_log WHERE id = 1")
row = cur.fetchone()