From 5214f1551f5ece00e686df154847ad82ef7bce2b Mon Sep 17 00:00:00 2001 From: blackdeathwos Date: Thu, 25 Jun 2026 20:36:58 -0600 Subject: [PATCH 1/2] Add Discipline Log and State Transfer cogs + shared Event Registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cogs/discipline.py: full infraction tracking system for alliance admins (log, view, edit, soft-delete, settings; posts to leadership channel, DMs member, stores in db/discipline.sqlite) - cogs/transfer.py: outbound state transfer manager (create event, add players by FID, set priority tiers, export ranked CSV with pass-cutoff row; stores in db/transfer.sqlite) - cogs/event_registry.py: centralised game-event registry — single source of truth for every in-game event name; fixes long-standing "Icefife" typo; discipline.py imports DISCIPLINE_EVENTS from it - cogs/bot_main_menu.py: add Discipline Log and State Transfer buttons to Settings menu (row 3) with handler methods on MainMenu cog Co-Authored-By: Claude Sonnet 4.6 --- cogs/bot_main_menu.py | 56 +- cogs/discipline.py | 1215 ++++++++++++++++++++++++++++++++++++++++ cogs/event_registry.py | 358 ++++++++++++ cogs/transfer.py | 831 +++++++++++++++++++++++++++ 4 files changed, 2459 insertions(+), 1 deletion(-) create mode 100644 cogs/discipline.py create mode 100644 cogs/event_registry.py create mode 100644 cogs/transfer.py diff --git a/cogs/bot_main_menu.py b/cogs/bot_main_menu.py index a9b85c3..75d393c 100644 --- a/cogs/bot_main_menu.py +++ b/cogs/bot_main_menu.py @@ -123,7 +123,11 @@ def build_main_menu_embed(self) -> discord.Embed: f"{theme.lockIcon} **Permissions**\n" f"└ Manage bot administrators (Global Admin only)\n\n" f"{theme.robotIcon} **Maintenance**\n" - f"└ Updates, backups, and support\n" + f"└ Updates, backups, and support\n\n" + f"{theme.shieldIcon} **Discipline Log**\n" + f"└ Log and track member infractions\n\n" + f"{theme.listIcon} **State Transfer**\n" + f"└ Manage outbound state transfer lists\n" f"{theme.lowerDivider}" ), color=theme.emColor1, @@ -411,6 +415,36 @@ async def show_maintenance(self, interaction: discord.Interaction): logger.error(f"Error in show_maintenance: {e}") print(f"Error in show_maintenance: {e}") + async def show_discipline_log(self, interaction: discord.Interaction): + """Display the Discipline Log menu.""" + try: + discipline_cog = self.bot.get_cog("DisciplineCog") + if discipline_cog: + await discipline_cog.show_disc_menu(interaction) + else: + await interaction.response.send_message( + f"{theme.deniedIcon} Discipline Log module not found.", + ephemeral=True + ) + except Exception as e: + logger.error(f"Error loading Discipline Log menu: {e}") + print(f"Error loading Discipline Log menu: {e}") + + async def show_state_transfer(self, interaction: discord.Interaction): + """Display the State Transfer menu.""" + try: + transfer_cog = self.bot.get_cog("TransferCog") + if transfer_cog: + await transfer_cog.show_transfer_menu(interaction) + else: + await interaction.response.send_message( + f"{theme.deniedIcon} State Transfer module not found.", + ephemeral=True + ) + except Exception as e: + logger.error(f"Error loading State Transfer menu: {e}") + print(f"Error loading State Transfer menu: {e}") + # ============================================================================ # Main Menu View @@ -579,6 +613,26 @@ async def permissions_top_button(self, interaction: discord.Interaction, button: async def maintenance_button(self, interaction: discord.Interaction, button: discord.ui.Button): await self.cog.show_maintenance(interaction) + @discord.ui.button( + label="Discipline Log", + emoji=theme.shieldIcon, + style=discord.ButtonStyle.primary, + custom_id="discipline_log", + row=3 + ) + async def discipline_log_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.cog.show_discipline_log(interaction) + + @discord.ui.button( + label="State Transfer", + emoji=theme.listIcon, + style=discord.ButtonStyle.primary, + custom_id="state_transfer", + row=3 + ) + async def state_transfer_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.cog.show_state_transfer(interaction) + # ============================================================================ # Alliances View diff --git a/cogs/discipline.py b/cogs/discipline.py new file mode 100644 index 0000000..2041c99 --- /dev/null +++ b/cogs/discipline.py @@ -0,0 +1,1215 @@ +""" +cogs/discipline.py — Alliance Discipline Log + +Provides a full infraction tracking system for alliance admins. Infractions +are tied to a player FID, an event, an infraction type, and a punishment. +Each record carries an optional expiry date and can be soft-deleted for a +permanent audit trail. + +Access +------ +- Via the Settings menu → Discipline Log button +- Via the /disc slash command (admin only) + +Permissions +----------- +- Alliance Admin and above: Log, view, and edit infractions +- Global Admin and above: Also delete records and configure settings + +Slash commands +-------------- +- /disc Open the main discipline menu +- /disc-setup Set the leadership channel for infraction posts +- /disc-expiry Set the default expiry period (days) + +Features +-------- +Log Infraction + Step-by-step wizard: FID → Event → Infraction type → Punishment type → + Notes & expiry → Confirm. On confirm, posts a summary embed to the + configured leadership channel and offers to DM the member directly. + +View History + Look up a player by FID. Filter by time range (30/60/90 days or all time) + and toggle expired records on/off. Each record shows event, infraction, + punishment, expiry status, who logged it, and any notes. + +Edit Expiry + Select any active record by FID and update its expiry date. Accepts + "never", a number of days from today, or a YYYY-MM-DD date. + +Delete Record (Global Admin only) + Soft-deletes a record — marks it deleted and records who deleted it and + when. The row is retained in the database for audit purposes. + +Settings (Global Admin only) + Configure the Discord channel that receives infraction posts and the + default expiry period used when logging without specifying a date. + +Database +-------- +db/discipline.sqlite + infractions — one row per infraction record + id, fid, event, infraction, punishment, notes, + logged_by_id, logged_by_name, logged_at, + expiry_date (NULL = permanent), is_deleted, deleted_by_id, deleted_at + +db/settings.sqlite + discipline_settings — key/value store + channel_id Discord channel for leadership posts + default_expiry_days Default expiry period in days (default: 30) +""" + +import discord +from discord.ext import commands +import sqlite3 +import hashlib +import time +import ssl +import aiohttp +from datetime import datetime, timedelta +from .pimp_my_bot import theme +from .permission_handler import PermissionManager +from .browser_headers import get_headers +from .bot_level_mapping import LEVEL_MAPPING +from .event_registry import DISCIPLINE_EVENTS as EVENTS + +DB_PATH = 'db/discipline.sqlite' +SETTINGS_DB = 'db/settings.sqlite' +USERS_DB = 'db/users.sqlite' + +INFRACTIONS = ["No Show", "Incorrect Heroes", "Incorrect Troop Ratio", "Other"] +PUNISHMENTS = ["Warning", "Kicked", "Banned", "Zeroed", "Other"] + + +# ── DB helpers ────────────────────────────────────────────────────────────── + +def _init_db(): + with sqlite3.connect(DB_PATH) as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS infractions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fid TEXT NOT NULL, + event TEXT NOT NULL, + infraction TEXT NOT NULL, + punishment TEXT NOT NULL, + notes TEXT DEFAULT '', + logged_by_id TEXT NOT NULL, + logged_by_name TEXT NOT NULL, + logged_at TEXT NOT NULL, + expiry_date TEXT, + is_deleted INTEGER DEFAULT 0, + deleted_by_id TEXT, + deleted_at TEXT + ) + """) + db.commit() + with sqlite3.connect(SETTINGS_DB) as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS discipline_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + db.execute("INSERT OR IGNORE INTO discipline_settings VALUES ('channel_id', NULL)") + db.execute("INSERT OR IGNORE INTO discipline_settings VALUES ('default_expiry_days', '30')") + db.commit() + + +def _get_setting(key): + with sqlite3.connect(SETTINGS_DB) as db: + row = db.execute( + "SELECT value FROM discipline_settings WHERE key = ?", (key,) + ).fetchone() + return row[0] if row else None + + +def _get_member(fid): + with sqlite3.connect(USERS_DB) as db: + return db.execute( + "SELECT fid, nickname, furnace_lv, kid FROM users WHERE fid = ?", (fid,) + ).fetchone() + + +def _parse_expiry(raw, default_days): + raw = (raw or "").strip().lower() + if raw in ("never", ""): + return None + if raw == "default": + return (datetime.utcnow() + timedelta(days=default_days)).strftime("%Y-%m-%d") + try: + days = int(raw) + return (datetime.utcnow() + timedelta(days=days)).strftime("%Y-%m-%d") + except ValueError: + try: + datetime.strptime(raw, "%Y-%m-%d") + return raw + except ValueError: + return (datetime.utcnow() + timedelta(days=default_days)).strftime("%Y-%m-%d") + + +def _level_name(lv): + return LEVEL_MAPPING.get(lv, f"Level {lv}") if lv else "Unknown" + + +def _member_card_embed(fid, nickname, furnace_lv, kid, color=None): + return discord.Embed( + title=f"{theme.userIcon} {nickname}", + description=( + f"{theme.upperDivider}\n" + f"**{theme.fidIcon} ID:** `{fid}`\n" + f"**{theme.levelIcon} Furnace Level:** `{_level_name(furnace_lv)}`\n" + f"**{theme.globeIcon} State:** `{kid}`\n" + f"{theme.lowerDivider}\n" + ), + color=color or theme.emColor1, + ) + + +def _is_expired(expiry_date): + if not expiry_date: + return False + try: + return datetime.strptime(expiry_date, "%Y-%m-%d") < datetime.utcnow() + except ValueError: + return False + + +async def _fetch_avatar(fid): + try: + secret = "tB87#kPtkxqOS2" + current_time = int(time.time() * 1000) + form = f"fid={fid}&time={current_time}" + sign = hashlib.md5((form + secret).encode("utf-8")).hexdigest() + form = f"sign={sign}&{form}" + url = "https://wos-giftcode-api.centurygame.com/api/player" + headers = get_headers("https://wos-giftcode-api.centurygame.com") + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.post(url, headers=headers, data=form, ssl=ssl_context) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("data", {}).get("avatar_image") + except Exception: + pass + return None + + +# ── Modals ─────────────────────────────────────────────────────────────────── + +class FIDModal(discord.ui.Modal, title="Enter Player ID"): + fid = discord.ui.TextInput( + label="Player FID", + placeholder="Enter the player's game ID...", + max_length=20, + ) + + def __init__(self, next_step): + super().__init__() + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + await self._next(interaction, self.fid.value.strip()) + + +class OtherTextModal(discord.ui.Modal): + value = discord.ui.TextInput( + label="Enter custom value", + placeholder="Describe the infraction / punishment...", + max_length=100, + ) + + def __init__(self, title, next_step): + super().__init__(title=title) + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + await self._next(interaction, self.value.value.strip()) + + +class NotesExpiryModal(discord.ui.Modal, title="Notes & Expiry"): + notes = discord.ui.TextInput( + label="Notes", + placeholder="Any additional details...", + style=discord.TextStyle.paragraph, + required=False, + max_length=500, + ) + expiry = discord.ui.TextInput( + label="Expiry", + placeholder="default / never / days (e.g. 60) / date (YYYY-MM-DD)", + required=False, + max_length=20, + ) + + def __init__(self, next_step): + super().__init__() + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + await self._next(interaction, self.notes.value.strip(), self.expiry.value.strip()) + + +class EditExpiryModal(discord.ui.Modal, title="Edit Expiry Date"): + expiry = discord.ui.TextInput( + label="New Expiry", + placeholder="never / days (e.g. 60) / date (YYYY-MM-DD)", + max_length=20, + ) + + def __init__(self, record_id, next_step): + super().__init__() + self.record_id = record_id + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + await self._next(interaction, self.record_id, self.expiry.value.strip()) + + +# ── Log flow views ──────────────────────────────────────────────────────────── + +class EventSelectView(discord.ui.View): + def __init__(self, state: dict, author_id: int): + super().__init__(timeout=300) + self.state = state + self.author_id = author_id + + options = [discord.SelectOption(label=e, value=e) for e in EVENTS] + select = discord.ui.Select( + placeholder=f"{theme.calendarIcon} Select event...", + options=options, + ) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + self.state["event"] = interaction.data["values"][0] + await _show_infraction_select(interaction, self.state, self.author_id) + + +async def _show_infraction_select(interaction, state, author_id): + member = state["member"] + embed = _member_card_embed(*member) + embed.add_field( + name=f"{theme.calendarIcon} Event", + value=f"`{state['event']}`", + inline=False, + ) + embed.set_footer(text="Step 2 of 4 — Select infraction") + + view = InfractionSelectView(state, author_id) + await interaction.response.edit_message(embed=embed, view=view) + + +class InfractionSelectView(discord.ui.View): + def __init__(self, state: dict, author_id: int): + super().__init__(timeout=300) + self.state = state + self.author_id = author_id + + options = [discord.SelectOption(label=i, value=i) for i in INFRACTIONS] + select = discord.ui.Select( + placeholder=f"{theme.editListIcon} Select infraction...", + options=options, + ) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + value = interaction.data["values"][0] + if value == "Other": + async def after_other(i, text): + self.state["infraction"] = text + await _show_punishment_select(i, self.state, self.author_id) + await interaction.response.send_modal(OtherTextModal("Custom Infraction", after_other)) + else: + self.state["infraction"] = value + await _show_punishment_select(interaction, self.state, self.author_id) + + +async def _show_punishment_select(interaction, state, author_id): + member = state["member"] + embed = _member_card_embed(*member) + embed.add_field(name=f"{theme.calendarIcon} Event", value=f"`{state['event']}`", inline=True) + embed.add_field(name=f"{theme.editListIcon} Infraction", value=f"`{state['infraction']}`", inline=True) + embed.set_footer(text="Step 3 of 4 — Select punishment") + + view = PunishmentSelectView(state, author_id) + if interaction.response.is_done(): + await interaction.edit_original_response(embed=embed, view=view) + else: + await interaction.response.edit_message(embed=embed, view=view) + + +class PunishmentSelectView(discord.ui.View): + def __init__(self, state: dict, author_id: int): + super().__init__(timeout=300) + self.state = state + self.author_id = author_id + + options = [discord.SelectOption(label=p, value=p) for p in PUNISHMENTS] + select = discord.ui.Select( + placeholder=f"{theme.shieldIcon} Select punishment...", + options=options, + ) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + value = interaction.data["values"][0] + if value == "Other": + async def after_other(i, text): + self.state["punishment"] = text + await _show_notes_expiry(i, self.state, self.author_id) + await interaction.response.send_modal(OtherTextModal("Custom Punishment", after_other)) + else: + self.state["punishment"] = value + await _show_notes_expiry(interaction, self.state, self.author_id) + + +async def _show_notes_expiry(interaction, state, author_id): + default_days = int(_get_setting("default_expiry_days") or 30) + + async def after_modal(i, notes, expiry_raw): + state["notes"] = notes + state["expiry_date"] = _parse_expiry(expiry_raw or "default", default_days) + await _show_confirm(i, state, author_id) + + await interaction.response.send_modal(NotesExpiryModal(after_modal)) + + +async def _show_confirm(interaction, state, author_id): + member = state["member"] + expiry_display = state["expiry_date"] if state["expiry_date"] else "Never" + + embed = discord.Embed( + title=f"{theme.warnIcon} Confirm Infraction", + description=( + f"{theme.upperDivider}\n" + f"**{theme.userIcon} Member:** `{member[1]}` (ID: `{member[0]}`)\n" + f"**{theme.calendarIcon} Event:** `{state['event']}`\n" + f"**{theme.editListIcon} Infraction:** `{state['infraction']}`\n" + f"**{theme.shieldIcon} Punishment:** `{state['punishment']}`\n" + f"**{theme.timeIcon} Expires:** `{expiry_display}`\n" + ), + color=theme.emColor4, + ) + if state.get("notes"): + embed.add_field(name=f"{theme.documentIcon} Notes", value=state["notes"], inline=False) + embed.description += theme.lowerDivider + embed.set_footer(text="Confirm to log this infraction") + + view = ConfirmLogView(state, author_id) + if interaction.response.is_done(): + await interaction.edit_original_response(embed=embed, view=view) + else: + await interaction.response.edit_message(embed=embed, view=view) + + +class ConfirmLogView(discord.ui.View): + def __init__(self, state: dict, author_id: int): + super().__init__(timeout=300) + self.state = state + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + @discord.ui.button(label="Confirm", emoji="✅", style=discord.ButtonStyle.success) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button): + state = self.state + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + member = state["member"] + + with sqlite3.connect(DB_PATH) as db: + cursor = db.execute( + """ + INSERT INTO infractions + (fid, event, infraction, punishment, notes, + logged_by_id, logged_by_name, logged_at, expiry_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + member[0], state["event"], state["infraction"], state["punishment"], + state.get("notes", ""), str(interaction.user.id), + interaction.user.display_name, now, state.get("expiry_date"), + ), + ) + record_id = cursor.lastrowid + db.commit() + + expiry_display = state["expiry_date"] if state["expiry_date"] else "Never" + + # Post to leadership channel + channel_id = _get_setting("channel_id") + if channel_id: + try: + channel = interaction.client.get_channel(int(channel_id)) + if channel: + avatar_url = await _fetch_avatar(member[0]) + log_embed = discord.Embed( + title=f"{theme.shieldIcon} New Infraction Logged", + description=( + f"{theme.upperDivider}\n" + f"**{theme.userIcon} Member:** `{member[1]}` (ID: `{member[0]}`)\n" + f"**{theme.calendarIcon} Event:** `{state['event']}`\n" + f"**{theme.editListIcon} Infraction:** `{state['infraction']}`\n" + f"**{theme.shieldIcon} Punishment:** `{state['punishment']}`\n" + f"**{theme.timeIcon} Expires:** `{expiry_display}`\n" + f"**{theme.userIcon} Logged By:** {interaction.user.mention}\n" + f"**{theme.calendarIcon} Date:** `{now}`\n" + f"{theme.lowerDivider}" + ), + color=theme.emColor2, + ) + if state.get("notes"): + log_embed.add_field( + name=f"{theme.documentIcon} Notes", value=state["notes"], inline=False + ) + if avatar_url: + log_embed.set_thumbnail(url=avatar_url) + await channel.send(embed=log_embed) + except Exception as e: + print(f"[discipline] channel post error: {e}") + + success_embed = discord.Embed( + title=f"{theme.verifiedIcon} Infraction Logged", + description=( + f"Record ID `#{record_id}` saved.\n\n" + f"Do you want to notify this member on Discord?" + ), + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=success_embed, view=NotifyView(state, record_id, self.author_id)) + + @discord.ui.button(label="Cancel", emoji="❌", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + embed = discord.Embed( + title=f"{theme.deniedIcon} Cancelled", + description="Infraction was not logged.", + color=theme.emColor4, + ) + await interaction.response.edit_message(embed=embed, view=None) + + +class NotifyView(discord.ui.View): + def __init__(self, state: dict, record_id: int, author_id: int): + super().__init__(timeout=300) + self.state = state + self.record_id = record_id + self.author_id = author_id + + user_select = discord.ui.UserSelect(placeholder="Select Discord member to notify...") + user_select.callback = self._on_user_select + self.add_item(user_select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_user_select(self, interaction: discord.Interaction): + member = interaction.data["resolved"]["users"] + user_id = list(member.keys())[0] + user_data = member[user_id] + username = user_data.get("username", "Unknown") + + state = self.state + expiry_display = state["expiry_date"] if state["expiry_date"] else "Never" + + dm_embed = discord.Embed( + title=f"{theme.shieldIcon} Infraction Notice", + description=( + f"You have received an infraction in **{interaction.guild.name if interaction.guild else 'the alliance'}**.\n\n" + f"{theme.upperDivider}\n" + f"**{theme.calendarIcon} Event:** `{state['event']}`\n" + f"**{theme.editListIcon} Infraction:** `{state['infraction']}`\n" + f"**{theme.shieldIcon} Punishment:** `{state['punishment']}`\n" + f"**{theme.timeIcon} Expires:** `{expiry_display}`\n" + f"{theme.lowerDivider}" + ), + color=theme.emColor2, + ) + if state.get("notes"): + dm_embed.add_field(name=f"{theme.documentIcon} Notes", value=state["notes"], inline=False) + + try: + discord_user = await interaction.client.fetch_user(int(user_id)) + await discord_user.send(embed=dm_embed) + msg = f"{theme.verifiedIcon} Notified **{username}** via DM." + except discord.Forbidden: + msg = f"{theme.warnIcon} Could not DM **{username}** (DMs disabled)." + except Exception as e: + msg = f"{theme.deniedIcon} DM failed: {e}" + + done_embed = discord.Embed( + title=f"{theme.verifiedIcon} Done", + description=msg, + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=done_embed, view=None) + + @discord.ui.button(label="Skip Notification", emoji="⏭️", style=discord.ButtonStyle.secondary) + async def skip(self, interaction: discord.Interaction, button: discord.ui.Button): + embed = discord.Embed( + title=f"{theme.verifiedIcon} Done", + description=f"Infraction `#{self.record_id}` logged. No notification sent.", + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=embed, view=None) + + +# ── View History flow ───────────────────────────────────────────────────────── + +class ViewHistoryOptionsView(discord.ui.View): + def __init__(self, fid: str, member, author_id: int): + super().__init__(timeout=300) + self.fid = fid + self.member = member + self.author_id = author_id + self.show_expired = False + + time_opts = [ + discord.SelectOption(label="Last 30 Days", value="30"), + discord.SelectOption(label="Last 60 Days", value="60"), + discord.SelectOption(label="Last 90 Days", value="90"), + discord.SelectOption(label="All Time", value="all"), + ] + time_select = discord.ui.Select(placeholder=f"{theme.timeIcon} Select time range...", options=time_opts) + time_select.callback = self._on_time_select + self.add_item(time_select) + self.time_select = time_select + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_time_select(self, interaction: discord.Interaction): + self.selected_range = interaction.data["values"][0] + await self._render(interaction) + + @discord.ui.button(label="Show Expired", emoji="👁️", style=discord.ButtonStyle.secondary, row=1) + async def toggle_expired(self, interaction: discord.Interaction, button: discord.ui.Button): + self.show_expired = not self.show_expired + button.label = "Hide Expired" if self.show_expired else "Show Expired" + button.style = discord.ButtonStyle.primary if self.show_expired else discord.ButtonStyle.secondary + if hasattr(self, "selected_range"): + await self._render(interaction) + else: + await interaction.response.edit_message(view=self) + + async def _render(self, interaction: discord.Interaction): + fid = self.fid + member = self.member + time_range = getattr(self, "selected_range", "all") + + params = [fid] + where = "fid = ? AND is_deleted = 0" + if time_range != "all": + cutoff = (datetime.utcnow() - timedelta(days=int(time_range))).strftime("%Y-%m-%d") + where += " AND logged_at >= ?" + params.append(cutoff) + if not self.show_expired: + where += " AND (expiry_date IS NULL OR expiry_date >= ?)" + params.append(datetime.utcnow().strftime("%Y-%m-%d")) + + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + f"SELECT id, event, infraction, punishment, notes, logged_by_name, logged_at, expiry_date " + f"FROM infractions WHERE {where} ORDER BY logged_at DESC", + params, + ).fetchall() + + embed = _member_card_embed(*member) + embed.title += f" — Infraction History" + + if not rows: + embed.add_field( + name=f"{theme.blankListIcon} No Records", + value="No infractions found for the selected filters.", + inline=False, + ) + else: + for row in rows: + rid, event, infraction, punishment, notes, logged_by, logged_at, expiry_date = row + expired = _is_expired(expiry_date) + expiry_str = expiry_date if expiry_date else "Never" + status = " `[Expired]`" if expired else "" + field_name = f"{theme.shieldIcon} #{rid}{status} • {logged_at[:10]}" + field_val = ( + f"**Event:** `{event}`\n" + f"**Infraction:** `{infraction}`\n" + f"**Punishment:** `{punishment}`\n" + f"**Expires:** `{expiry_str}`\n" + f"**Logged by:** `{logged_by}`" + ) + if notes: + field_val += f"\n**Notes:** {notes}" + embed.add_field(name=field_name, value=field_val, inline=False) + + range_label = f"Last {time_range} days" if time_range != "all" else "All time" + expired_label = " (including expired)" if self.show_expired else "" + embed.set_footer(text=f"{range_label}{expired_label} • {len(rows)} record(s)") + + await interaction.response.edit_message(embed=embed, view=self) + + +# ── Edit Expiry flow ────────────────────────────────────────────────────────── + +class EditExpirySelectView(discord.ui.View): + def __init__(self, rows, author_id: int): + super().__init__(timeout=300) + self.author_id = author_id + + options = [] + for row in rows[:25]: + rid, event, infraction, logged_at, expiry_date = row + label = f"#{rid} — {event} — {infraction}"[:100] + desc = f"Logged: {logged_at[:10]} Expires: {expiry_date or 'Never'}"[:100] + options.append(discord.SelectOption(label=label, value=str(rid), description=desc)) + + select = discord.ui.Select(placeholder=f"{theme.editListIcon} Select infraction to edit...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + record_id = int(interaction.data["values"][0]) + + async def after_modal(i, rid, raw): + default_days = int(_get_setting("default_expiry_days") or 30) + new_expiry = _parse_expiry(raw, default_days) if raw.strip().lower() != "never" else None + + with sqlite3.connect(DB_PATH) as db: + db.execute( + "UPDATE infractions SET expiry_date = ? WHERE id = ?", (new_expiry, rid) + ) + db.commit() + + expiry_display = new_expiry if new_expiry else "Never" + embed = discord.Embed( + title=f"{theme.verifiedIcon} Expiry Updated", + description=f"Record `#{rid}` expiry set to `{expiry_display}`.", + color=theme.emColor3, + ) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=None) + else: + await i.response.edit_message(embed=embed, view=None) + + await interaction.response.send_modal(EditExpiryModal(record_id, after_modal)) + + +# ── Delete flow ─────────────────────────────────────────────────────────────── + +class DeleteSelectView(discord.ui.View): + def __init__(self, rows, author_id: int): + super().__init__(timeout=300) + self.author_id = author_id + + options = [] + for row in rows[:25]: + rid, event, infraction, logged_at = row + label = f"#{rid} — {event} — {infraction}"[:100] + desc = f"Logged: {logged_at[:10]}"[:100] + options.append(discord.SelectOption(label=label, value=str(rid), description=desc)) + + select = discord.ui.Select(placeholder=f"{theme.trashIcon} Select record to delete...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + record_id = int(interaction.data["values"][0]) + + embed = discord.Embed( + title=f"{theme.warnIcon} Confirm Delete", + description=( + f"Soft-delete record `#{record_id}`?\n\n" + f"The record will be hidden from all views but **remains in the database**." + ), + color=theme.emColor2, + ) + await interaction.response.edit_message( + embed=embed, view=ConfirmDeleteView(record_id, self.author_id, interaction.user) + ) + + +class ConfirmDeleteView(discord.ui.View): + def __init__(self, record_id: int, author_id: int, deleter): + super().__init__(timeout=120) + self.record_id = record_id + self.author_id = author_id + self.deleter = deleter + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + @discord.ui.button(label="Delete", emoji="🗑️", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button): + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + with sqlite3.connect(DB_PATH) as db: + db.execute( + "UPDATE infractions SET is_deleted = 1, deleted_by_id = ?, deleted_at = ? WHERE id = ?", + (str(self.deleter.id), now, self.record_id), + ) + db.commit() + embed = discord.Embed( + title=f"{theme.verifiedIcon} Record Deleted", + description=f"Record `#{self.record_id}` has been soft-deleted.", + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=embed, view=None) + + @discord.ui.button(label="Cancel", emoji="❌", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + embed = discord.Embed( + title=f"{theme.deniedIcon} Cancelled", + description="No records were deleted.", + color=theme.emColor4, + ) + await interaction.response.edit_message(embed=embed, view=None) + + +# ── Settings flow ──────────────────────────────────────────────────────────── + +class DiscSettingsView(discord.ui.View): + def __init__(self, author_id: int): + super().__init__(timeout=300) + self.author_id = author_id + + channel_select = discord.ui.ChannelSelect( + placeholder=f"{theme.bellIcon} Select discipline log channel...", + channel_types=[discord.ChannelType.text], + ) + channel_select.callback = self._on_channel_select + self.add_item(channel_select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + async def _on_channel_select(self, interaction: discord.Interaction): + channel_id = interaction.data["values"][0] + with sqlite3.connect(SETTINGS_DB) as db: + db.execute( + "INSERT OR REPLACE INTO discipline_settings VALUES ('channel_id', ?)", + (str(channel_id),), + ) + db.commit() + + channel = interaction.guild.get_channel(int(channel_id)) if interaction.guild else None + channel_mention = channel.mention if channel else f"<#{channel_id}>" + + current_expiry = _get_setting("default_expiry_days") or "30" + embed = discord.Embed( + title=f"{theme.settingsIcon} Discipline Settings", + description=( + f"{theme.upperDivider}\n" + f"**{theme.bellIcon} Log Channel:** {channel_mention}\n" + f"**{theme.timeIcon} Default Expiry:** `{current_expiry}` days\n" + f"{theme.lowerDivider}\n\n" + f"{theme.verifiedIcon} Channel saved! Use the button below to update the default expiry." + ), + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=embed, view=ExpiryUpdateView(self.author_id)) + + @staticmethod + def settings_embed(): + channel_id = _get_setting("channel_id") + expiry = _get_setting("default_expiry_days") or "30" + channel_str = f"<#{channel_id}>" if channel_id else "`Not set`" + return discord.Embed( + title=f"{theme.settingsIcon} Discipline Settings", + description=( + f"{theme.upperDivider}\n" + f"**{theme.bellIcon} Log Channel:** {channel_str}\n" + f"**{theme.timeIcon} Default Expiry:** `{expiry}` days\n" + f"{theme.lowerDivider}\n\n" + f"Select a channel from the dropdown to update it." + ), + color=theme.emColor1, + ) + + +class ExpiryUpdateModal(discord.ui.Modal, title="Set Default Expiry"): + days = discord.ui.TextInput( + label="Default expiry (days)", + placeholder="e.g. 30", + max_length=4, + ) + + def __init__(self, author_id: int): + super().__init__() + self.author_id = author_id + + async def on_submit(self, interaction: discord.Interaction): + try: + days_val = int(self.days.value.strip()) + except ValueError: + await interaction.response.send_message( + f"{theme.deniedIcon} Enter a whole number of days.", ephemeral=True + ) + return + + with sqlite3.connect(SETTINGS_DB) as db: + db.execute( + "INSERT OR REPLACE INTO discipline_settings VALUES ('default_expiry_days', ?)", + (str(days_val),), + ) + db.commit() + + channel_id = _get_setting("channel_id") + channel_str = f"<#{channel_id}>" if channel_id else "`Not set`" + embed = discord.Embed( + title=f"{theme.settingsIcon} Discipline Settings", + description=( + f"{theme.upperDivider}\n" + f"**{theme.bellIcon} Log Channel:** {channel_str}\n" + f"**{theme.timeIcon} Default Expiry:** `{days_val}` days\n" + f"{theme.lowerDivider}\n\n" + f"{theme.verifiedIcon} Default expiry updated to `{days_val}` days." + ), + color=theme.emColor3, + ) + if interaction.response.is_done(): + await interaction.edit_original_response(embed=embed, view=None) + else: + await interaction.response.edit_message(embed=embed, view=None) + + +class ExpiryUpdateView(discord.ui.View): + def __init__(self, author_id: int): + super().__init__(timeout=300) + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + @discord.ui.button(label="Set Default Expiry", emoji="📅", style=discord.ButtonStyle.primary) + async def set_expiry(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(ExpiryUpdateModal(self.author_id)) + + +# ── Main menu view ──────────────────────────────────────────────────────────── + +class DisciplineMenuView(discord.ui.View): + def __init__(self, cog, is_global: bool, author_id: int): + super().__init__(timeout=300) + self.cog = cog + self.is_global = is_global + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", + ephemeral=True, + ) + return False + return True + + @discord.ui.button(label="Log Infraction", emoji="📝", style=discord.ButtonStyle.danger, row=0) + async def log_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + async def after_fid(i, fid): + member = _get_member(fid) + if not member: + err = discord.Embed( + title=f"{theme.deniedIcon} Member Not Found", + description=f"No member with ID `{fid}` found in the alliance list.", + color=theme.emColor2, + ) + if i.response.is_done(): + await i.edit_original_response(embed=err, view=None) + else: + await i.response.send_message(embed=err, ephemeral=True) + return + + state = {"member": member, "event": None, "infraction": None, "punishment": None} + embed = _member_card_embed(*member) + embed.set_footer(text="Step 1 of 4 — Select event") + view = EventSelectView(state, i.user.id) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=view) + else: + await i.response.edit_message(embed=embed, view=view) + + await interaction.response.send_modal(FIDModal(after_fid)) + + @discord.ui.button(label="View History", emoji="👁️", style=discord.ButtonStyle.primary, row=0) + async def view_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + async def after_fid(i, fid): + member = _get_member(fid) + if not member: + err = discord.Embed( + title=f"{theme.deniedIcon} Member Not Found", + description=f"No member with ID `{fid}` found in the alliance list.", + color=theme.emColor2, + ) + if i.response.is_done(): + await i.edit_original_response(embed=err, view=None) + else: + await i.response.send_message(embed=err, ephemeral=True) + return + + embed = _member_card_embed(*member) + embed.set_footer(text="Select time range to view history") + view = ViewHistoryOptionsView(fid, member, i.user.id) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=view) + else: + await i.response.edit_message(embed=embed, view=view) + + await interaction.response.send_modal(FIDModal(after_fid)) + + @discord.ui.button(label="Edit Expiry", emoji="📅", style=discord.ButtonStyle.secondary, row=1) + async def edit_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + async def after_fid(i, fid): + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT id, event, infraction, logged_at, expiry_date " + "FROM infractions WHERE fid = ? AND is_deleted = 0 ORDER BY logged_at DESC", + (fid,), + ).fetchall() + + if not rows: + err = discord.Embed( + title=f"{theme.deniedIcon} No Records", + description=f"No active infractions found for ID `{fid}`.", + color=theme.emColor2, + ) + if i.response.is_done(): + await i.edit_original_response(embed=err, view=None) + else: + await i.response.send_message(embed=err, ephemeral=True) + return + + embed = discord.Embed( + title=f"{theme.calendarIcon} Edit Expiry", + description=f"Select an infraction for ID `{fid}` to update its expiry date.", + color=theme.emColor1, + ) + view = EditExpirySelectView(rows, i.user.id) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=view) + else: + await i.response.edit_message(embed=embed, view=view) + + await interaction.response.send_modal(FIDModal(after_fid)) + + @discord.ui.button(label="Delete Record", emoji="🗑️", style=discord.ButtonStyle.danger, row=1) + async def delete_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.is_global: + await interaction.response.send_message( + f"{theme.deniedIcon} Only Global Admins can delete records.", + ephemeral=True, + ) + return + + async def after_fid(i, fid): + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT id, event, infraction, logged_at " + "FROM infractions WHERE fid = ? AND is_deleted = 0 ORDER BY logged_at DESC", + (fid,), + ).fetchall() + + if not rows: + err = discord.Embed( + title=f"{theme.deniedIcon} No Records", + description=f"No active infractions found for ID `{fid}`.", + color=theme.emColor2, + ) + if i.response.is_done(): + await i.edit_original_response(embed=err, view=None) + else: + await i.response.send_message(embed=err, ephemeral=True) + return + + embed = discord.Embed( + title=f"{theme.trashIcon} Delete Record", + description=f"Select an infraction for ID `{fid}` to soft-delete.", + color=theme.emColor2, + ) + view = DeleteSelectView(rows, i.user.id) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=view) + else: + await i.response.edit_message(embed=embed, view=view) + + await interaction.response.send_modal(FIDModal(after_fid)) + + @discord.ui.button(label="Settings", emoji="⚙️", style=discord.ButtonStyle.secondary, row=2) + async def settings_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + if not self.is_global: + await interaction.response.send_message( + f"{theme.deniedIcon} Only Global Admins can change discipline settings.", + ephemeral=True, + ) + return + embed = DiscSettingsView.settings_embed() + await interaction.response.edit_message(embed=embed, view=DiscSettingsView(self.author_id)) + + +# ── Cog ─────────────────────────────────────────────────────────────────────── + +class DisciplineCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + _init_db() + + async def show_disc_menu(self, interaction: discord.Interaction): + is_admin, is_global = PermissionManager.is_admin(interaction.user.id) + if not is_admin: + await interaction.response.send_message( + f"{theme.deniedIcon} You do not have permission to use this command.", + ephemeral=True, + ) + return + + embed = discord.Embed( + title=f"{theme.shieldIcon} Discipline Log", + description=( + f"Select an operation:\n\n" + f"{theme.upperDivider}\n" + f"📝 **Log Infraction** — Record a new write-up\n" + f"👁️ **View History** — View a member's infraction history\n" + f"📅 **Edit Expiry** — Change when an infraction expires\n" + + (f"🗑️ **Delete Record** — Soft-delete a record *(Global Admin)*\n" if is_global else "") + + (f"⚙️ **Settings** — Set log channel & default expiry *(Global Admin)*\n" if is_global else "") + + theme.lowerDivider + ), + color=theme.emColor1, + ) + embed.set_footer(text="Only you can see this menu") + await interaction.response.send_message( + embed=embed, + view=DisciplineMenuView(self, is_global, interaction.user.id), + ephemeral=True, + ) + + @discord.app_commands.command(name="disc", description="Discipline log management") + async def disc(self, interaction: discord.Interaction): + await self.show_disc_menu(interaction) + + @discord.app_commands.command(name="disc-setup", description="Set the discipline log channel") + async def disc_setup(self, interaction: discord.Interaction, channel: discord.TextChannel): + _, is_global = PermissionManager.is_admin(interaction.user.id) + if not is_global: + await interaction.response.send_message( + f"{theme.deniedIcon} Only Global Admins can configure the discipline channel.", + ephemeral=True, + ) + return + with sqlite3.connect(SETTINGS_DB) as db: + db.execute( + "INSERT OR REPLACE INTO discipline_settings VALUES ('channel_id', ?)", + (str(channel.id),), + ) + db.commit() + await interaction.response.send_message( + f"{theme.verifiedIcon} Discipline log channel set to {channel.mention}.", + ephemeral=True, + ) + + @discord.app_commands.command(name="disc-expiry", description="Set the default infraction expiry (days)") + async def disc_expiry(self, interaction: discord.Interaction, days: int): + _, is_global = PermissionManager.is_admin(interaction.user.id) + if not is_global: + await interaction.response.send_message( + f"{theme.deniedIcon} Only Global Admins can configure the default expiry.", + ephemeral=True, + ) + return + with sqlite3.connect(SETTINGS_DB) as db: + db.execute( + "INSERT OR REPLACE INTO discipline_settings VALUES ('default_expiry_days', ?)", + (str(days),), + ) + db.commit() + await interaction.response.send_message( + f"{theme.verifiedIcon} Default expiry set to `{days}` days.", + ephemeral=True, + ) + + +async def setup(bot): + await bot.add_cog(DisciplineCog(bot)) diff --git a/cogs/event_registry.py b/cogs/event_registry.py new file mode 100644 index 0000000..6c701b2 --- /dev/null +++ b/cogs/event_registry.py @@ -0,0 +1,358 @@ +""" +cogs/event_registry.py — Centralized game-event registry + +Single source of truth for every in-game event referenced across the bot. + +Why this file exists +-------------------- +Prior to this module, each cog maintained its own event list with inconsistent +naming. The same real-world events appeared under different names depending on +which part of the bot you were looking at: + + System Event name used + ───────────────────────────────────────────────────────────────────── + discipline.py "Foundry Battle", "Bear Hunt", + "Sun Fire Castle", "Frost Dragon Tyrant", + "State vs State", "Icefife Warhymn League" + attendance.py "Foundry", "Bear Trap", + "Castle Battle", "Frostdragon Tyrant" + notification_event_types "Foundry Battle", "Bear Trap", + "Castle Battle", "SvS" + attendance_ocr_parsers key="foundry_battle", label="Foundry Battle" + +This module defines one canonical name per event and derives the per-cog +lists from it, so a single edit here propagates everywhere. + +How to use +---------- +Replace per-cog event lists with imports from this module: + + # discipline.py + from .event_registry import DISCIPLINE_EVENTS as EVENTS + + # attendance.py (drop-in replacements) + from .event_registry import ATTENDANCE_EVENT_TYPES as EVENT_TYPES + from .event_registry import ATTENDANCE_LEGION_EVENT_TYPES as LEGION_EVENT_TYPES + + # notification_event_types.py + # The EVENT_CONFIG dict there carries too much per-event metadata to replace + # directly, but the keys should be kept in sync with display_name values here. + + # attendance_ocr_parsers.py + # The EventTypeConfig dataclass carries regex / weekday metadata beyond + # what this registry tracks. The `label` field in each EventTypeConfig + # MUST match the `display_name` here; use the shared `key` to cross-reference. + +Adding a new event +------------------ +1. Add one dict to REGISTRY below with all required fields. +2. Set the appropriate flags (discipline / attendance / notifications / ocr_key). +3. If the event has an OCR parser, add the EventTypeConfig in + attendance_ocr_parsers.py using the same `key` and matching `label`. +4. If the event has notifications, add/update the entry in + notification_event_types.py using the same `display_name` as the key. + +Data-migration note +------------------- +The event name is stored as a plain string in both db/discipline.sqlite +(infractions.event) and db/attendance.sqlite (attendance_records.event_type). +Renaming an event here does NOT automatically update existing rows. + +Where this module introduces a canonical name that differs from what a cog +previously used, a `legacy_names` list documents the old strings so that a +migration script can locate and rename them: + + UPDATE infractions SET event = 'Bear Trap' WHERE event = 'Bear Hunt'; + +Running such a migration is optional — old records will still display +correctly using whatever string was stored; only new records will use the +canonical name. +""" + +from __future__ import annotations + +# ── Registry ────────────────────────────────────────────────────────────────── +# +# Each entry is a dict with the following fields: +# +# key str Stable snake_case identifier used as the DB key in +# the OCR system and as a cross-reference handle. +# display_name str Canonical in-game name shown to users in all menus. +# short_name str Abbreviated name for space-constrained contexts +# (e.g. button labels, embeds with limited width). +# discipline bool Appears in the Discipline Log event picker. +# attendance bool Appears in the Attendance manual event picker. +# notifications bool Has a corresponding entry in notification_event_types. +# legion bool Requires legion / sub-group selection (Foundry, Canyon). +# ocr_key str|None Key in attendance_ocr_parsers.EVENT_TYPES, or None. +# legacy_names list[str] Previous names used in other cogs — kept for +# migration reference only; not used at runtime. + +REGISTRY: list[dict] = [ + # ── Foundry Battle ──────────────────────────────────────────────────────── + # notifications uses "Foundry Battle"; OCR key is "foundry_battle" + # attendance.py previously used the short name "Foundry" + { + "key": "foundry_battle", + "display_name": "Foundry Battle", + "short_name": "Foundry", + "discipline": True, + "attendance": True, + "notifications": True, + "legion": True, + "ocr_key": "foundry_battle", + "legacy_names": ["Foundry"], # attendance.py + }, + # ── Canyon Clash ────────────────────────────────────────────────────────── + # Consistent across all systems. + { + "key": "canyon_clash", + "display_name": "Canyon Clash", + "short_name": "Canyon Clash", + "discipline": True, + "attendance": True, + "notifications": True, + "legion": True, + "ocr_key": "canyon_clash", + "legacy_names": [], + }, + # ── Crazy Joe ───────────────────────────────────────────────────────────── + # Consistent across all systems. + { + "key": "crazy_joe", + "display_name": "Crazy Joe", + "short_name": "Crazy Joe", + "discipline": True, + "attendance": True, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Bear Trap ───────────────────────────────────────────────────────────── + # Canonical name is "Bear Trap" (used by attendance + notifications). + # discipline.py previously used "Bear Hunt". + { + "key": "bear_trap", + "display_name": "Bear Trap", + "short_name": "Bear Trap", + "discipline": True, + "attendance": True, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": ["Bear Hunt"], # discipline.py + }, + # ── Castle Battle ───────────────────────────────────────────────────────── + # Canonical name is "Castle Battle" (used by attendance + notifications). + # discipline.py previously used "Sun Fire Castle". + { + "key": "castle_battle", + "display_name": "Castle Battle", + "short_name": "Castle Battle", + "discipline": True, + "attendance": True, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": ["Sun Fire Castle"], # discipline.py + }, + # ── Frost Dragon Tyrant ─────────────────────────────────────────────────── + # discipline.py used "Frost Dragon Tyrant" (two words, capitalised). + # attendance.py used "Frostdragon Tyrant" (one word). + # "Frost Dragon Tyrant" is the clearer form; adopted as canonical. + { + "key": "frost_dragon_tyrant", + "display_name": "Frost Dragon Tyrant", + "short_name": "Frost Dragon", + "discipline": True, + "attendance": True, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": ["Frostdragon Tyrant"], # attendance.py + }, + # ── State vs State ──────────────────────────────────────────────────────── + # notifications uses the abbreviation "SvS"; discipline uses the full name. + # Full name adopted as canonical for clarity; short_name carries "SvS". + { + "key": "state_vs_state", + "display_name": "State vs State", + "short_name": "SvS", + "discipline": True, + "attendance": False, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": ["SvS"], # notification_event_types.py + }, + # ── Fortress Battle ─────────────────────────────────────────────────────── + # Consistent between discipline and notifications. + { + "key": "fortress_battle", + "display_name": "Fortress Battle", + "short_name": "Fortress", + "discipline": True, + "attendance": False, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Brother in Arms ─────────────────────────────────────────────────────── + { + "key": "brother_in_arms", + "display_name": "Brother in Arms", + "short_name": "BiA", + "discipline": True, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Alliance Championship ───────────────────────────────────────────────── + { + "key": "alliance_championship", + "display_name": "Alliance Championship", + "short_name": "Alliance Championship", + "discipline": True, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Icefire Warhymn League ──────────────────────────────────────────────── + # discipline.py contained a typo: "Icefife Warhymn League". + # Corrected to "Icefire Warhymn League" here. + { + "key": "icefire_warhymn_league", + "display_name": "Icefire Warhymn League", + "short_name": "Warhymn", + "discipline": True, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": ["Icefife Warhymn League"], # discipline.py typo + }, + # ── Player Behavior ─────────────────────────────────────────────────────── + # Not a game event — used in the discipline log for general conduct issues. + { + "key": "player_behavior", + "display_name": "Player Behavior", + "short_name": "Behavior", + "discipline": True, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Power Rankings ──────────────────────────────────────────────────────── + # OCR-only; does not appear in manual attendance or discipline pickers. + { + "key": "power_rankings", + "display_name": "Power Rankings", + "short_name": "Power Rankings", + "discipline": False, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": "power_rankings", + "legacy_names": [], + }, + # ── Alliance Showdown ───────────────────────────────────────────────────── + # OCR-only; does not appear in manual attendance or discipline pickers. + { + "key": "alliance_showdown", + "display_name": "Alliance Showdown", + "short_name": "Alliance Showdown", + "discipline": False, + "attendance": False, + "notifications": False, + "legion": False, + "ocr_key": "alliance_showdown", + "legacy_names": [], + }, + # ── Frostfire Mine ──────────────────────────────────────────────────────── + # Notifications only; not currently tracked in attendance or discipline. + { + "key": "frostfire_mine", + "display_name": "Frostfire Mine", + "short_name": "Frostfire Mine", + "discipline": False, + "attendance": False, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Mercenary Prestige ──────────────────────────────────────────────────── + # Notifications only; not currently tracked in attendance or discipline. + { + "key": "mercenary_prestige", + "display_name": "Mercenary Prestige", + "short_name": "Mercenary", + "discipline": False, + "attendance": False, + "notifications": True, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, + # ── Other ───────────────────────────────────────────────────────────────── + # Catch-all for manually logged attendance sessions that don't fit an + # established event type. Not a real game event. + { + "key": "other", + "display_name": "Other", + "short_name": "Other", + "discipline": False, + "attendance": True, + "notifications": False, + "legion": False, + "ocr_key": None, + "legacy_names": [], + }, +] + + +# ── Lookup indexes (built once at import time) ───────────────────────────────── + +_BY_KEY: dict[str, dict] = {e["key"]: e for e in REGISTRY} +_BY_NAME: dict[str, dict] = {e["display_name"]: e for e in REGISTRY} + + +def get_event(key: str) -> dict | None: + """Return the registry entry for a stable key (e.g. 'foundry_battle').""" + return _BY_KEY.get(key) + + +def get_event_by_name(display_name: str) -> dict | None: + """Return the registry entry for a canonical display name.""" + return _BY_NAME.get(display_name) + + +# ── Pre-filtered lists for drop-in cog compatibility ────────────────────────── + +DISCIPLINE_EVENTS: list[str] = [ + e["display_name"] for e in REGISTRY if e["discipline"] +] + +ATTENDANCE_EVENT_TYPES: list[str] = [ + e["display_name"] for e in REGISTRY if e["attendance"] +] + +ATTENDANCE_LEGION_EVENT_TYPES: list[str] = [ + e["display_name"] for e in REGISTRY if e["attendance"] and e["legion"] +] + +NOTIFICATION_EVENT_NAMES: list[str] = [ + e["display_name"] for e in REGISTRY if e["notifications"] +] + +OCR_EVENT_KEYS: list[str] = [ + e["ocr_key"] for e in REGISTRY if e["ocr_key"] is not None +] diff --git a/cogs/transfer.py b/cogs/transfer.py new file mode 100644 index 0000000..46ad3fc --- /dev/null +++ b/cogs/transfer.py @@ -0,0 +1,831 @@ +""" +cogs/transfer.py — State Transfer List Manager + +Maintains a prioritised list of players nominated for an outbound state +transfer. Admins create a named transfer event, add players by FID (bulk +or one-at-a-time), assign priority tiers, and export a ranked CSV for +distribution. Each event tracks an optional power threshold to flag players +who require a special pass. + +Access +------ +- Via the Settings menu → State Transfer button +- Via the /transfer slash command (admin only) + +Permissions +----------- +- Alliance Admin and above: Full access to all features + +Slash commands +-------------- +- /transfer Open the transfer event manager + +Features +-------- +Create Event + Enter a name, transfer date, and optional power threshold. If an event is + already active, the admin can continue it or close it and start a new one. + +Add Players + Enter one or more FIDs (one per line or comma-separated). Select a priority + tier for the whole batch (High / Med / Low). Each FID is looked up via the + game API to fetch the player's nickname; any FIDs that can't be resolved + are reported as failures. + +View List + Displays all players in the active event sorted by priority then addition + order. Players whose power exceeds the threshold are flagged with ⚠️. + +Change Priority + Pick any player from a dropdown and reassign their priority tier. + +Remove Player + Pick any player from a dropdown and remove them from the list (hard delete). + +Export CSV + Enter the number of available passes. Generates a ranked CSV file with a + cutoff row inserted at position N+1 so recipients can immediately see who + makes the cut. Players above the power threshold are flagged in a + "Special Pass Required" column. The file is sent to the requesting admin + via Discord DM. + +Close Event + Archives the current event (sets status to 'closed'). All data is retained + in the database for reference. + +Database +-------- +db/transfer.sqlite + transfer_events — one row per transfer event + id, name, transfer_date, power_threshold, + status (active | closed), created_at, created_by_id + + transfer_players — one row per nominated player + id, event_id, fid, name, power, priority (high | med | low), + added_by_id, added_at +""" + +import csv +import discord +import hashlib +import io +import sqlite3 +import ssl +import time +import aiohttp +from datetime import datetime +from discord.ext import commands +from .browser_headers import get_headers +from .permission_handler import PermissionManager +from .pimp_my_bot import theme + +DB_PATH = 'db/transfer.sqlite' + +PRIORITY_ORDER = {'high': 0, 'med': 1, 'low': 2} +PRIORITY_LABELS = {'high': 'High', 'med': 'Med', 'low': 'Low'} + + +# ── DB ──────────────────────────────────────────────────────────────────────── + +def _init_db(): + with sqlite3.connect(DB_PATH) as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS transfer_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + transfer_date TEXT NOT NULL, + power_threshold TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + created_by_id TEXT NOT NULL + ) + """) + db.execute(""" + CREATE TABLE IF NOT EXISTS transfer_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + fid TEXT NOT NULL, + name TEXT NOT NULL, + power TEXT, + priority TEXT NOT NULL DEFAULT 'med', + added_by_id TEXT NOT NULL, + added_at TEXT NOT NULL, + FOREIGN KEY (event_id) REFERENCES transfer_events(id) + ) + """) + db.commit() + + +def _get_active_event(): + with sqlite3.connect(DB_PATH) as db: + return db.execute( + "SELECT id, name, transfer_date, power_threshold FROM transfer_events WHERE status = 'active' ORDER BY id DESC LIMIT 1" + ).fetchone() + + +def _event_label(event): + return f"{event[1]} — {event[2]}" + + +# ── WOS API ─────────────────────────────────────────────────────────────────── + +async def _fetch_player(fid): + try: + secret = "tB87#kPtkxqOS2" + current_time = int(time.time() * 1000) + form = f"fid={fid}&time={current_time}" + sign = hashlib.md5((form + secret).encode("utf-8")).hexdigest() + form = f"sign={sign}&{form}" + url = "https://wos-giftcode-api.centurygame.com/api/player" + headers = get_headers("https://wos-giftcode-api.centurygame.com") + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.post(url, headers=headers, data=form, ssl=ssl_ctx) as resp: + if resp.status == 200: + data = await resp.json() + if data.get("data"): + return data["data"].get("nickname"), data["data"].get("avatar_image") + except Exception: + pass + return None, None + + +# ── Modals ──────────────────────────────────────────────────────────────────── + +class NewEventModal(discord.ui.Modal, title="Create Transfer Event"): + event_name = discord.ui.TextInput( + label="Event Name", + placeholder="e.g. Move to State 5", + max_length=100, + ) + transfer_date = discord.ui.TextInput( + label="Transfer Date", + placeholder="YYYY-MM-DD", + max_length=20, + ) + power_threshold = discord.ui.TextInput( + label="Power Threshold (optional)", + placeholder="e.g. 100000000 — players above this need a special pass", + required=False, + max_length=20, + ) + + def __init__(self, next_step): + super().__init__() + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + await self._next( + interaction, + self.event_name.value.strip(), + self.transfer_date.value.strip(), + self.power_threshold.value.strip() or None, + ) + + +class AddPlayersModal(discord.ui.Modal, title="Add Players to Transfer List"): + fids = discord.ui.TextInput( + label="Player FIDs", + placeholder="One per line or comma-separated", + style=discord.TextStyle.paragraph, + max_length=2000, + ) + + def __init__(self, next_step): + super().__init__() + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + raw = self.fids.value.strip() + if "\n" in raw: + fid_list = [f.strip() for f in raw.split("\n") if f.strip()] + else: + fid_list = [f.strip() for f in raw.split(",") if f.strip()] + fid_list = [f for f in fid_list if f.isdigit()] + await self._next(interaction, fid_list) + + +class ExportModal(discord.ui.Modal, title="Export Transfer List"): + passes = discord.ui.TextInput( + label="Number of Available Passes", + placeholder="e.g. 30", + max_length=4, + ) + + def __init__(self, next_step): + super().__init__() + self._next = next_step + + async def on_submit(self, interaction: discord.Interaction): + try: + count = int(self.passes.value.strip()) + except ValueError: + await interaction.response.send_message( + f"{theme.deniedIcon} Enter a whole number.", ephemeral=True + ) + return + await self._next(interaction, count) + + +# ── Priority select ─────────────────────────────────────────────────────────── + +class PrioritySelectView(discord.ui.View): + def __init__(self, fid_list, author_id): + super().__init__(timeout=120) + self.fid_list = fid_list + self.author_id = author_id + + options = [ + discord.SelectOption(label="High", value="high", emoji="🔴"), + discord.SelectOption(label="Med", value="med", emoji="🟡"), + discord.SelectOption(label="Low", value="low", emoji="🟢"), + ] + select = discord.ui.Select(placeholder="Select priority for this batch...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + priority = interaction.data["values"][0] + event = _get_active_event() + if not event: + await interaction.response.edit_message( + embed=discord.Embed(title=f"{theme.deniedIcon} No active event.", color=theme.emColor4), + view=None, + ) + return + + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + added, failed = [], [] + + await interaction.response.edit_message( + embed=discord.Embed( + title=f"{theme.timeIcon} Fetching player data...", + description=f"Looking up {len(self.fid_list)} FID(s)...", + color=theme.emColor1, + ), + view=None, + ) + + for fid in self.fid_list: + nickname, _ = await _fetch_player(fid) + if nickname: + with sqlite3.connect(DB_PATH) as db: + db.execute( + "INSERT INTO transfer_players (event_id, fid, name, power, priority, added_by_id, added_at) VALUES (?, ?, ?, NULL, ?, ?, ?)", + (event[0], fid, nickname, priority, str(interaction.user.id), now), + ) + db.commit() + added.append(f"`{nickname}` ({fid})") + else: + failed.append(fid) + + desc = f"{theme.upperDivider}\n" + if added: + desc += f"**{theme.verifiedIcon} Added ({len(added)}):**\n" + "\n".join(added[:20]) + if len(added) > 20: + desc += f"\n...and {len(added) - 20} more" + desc += "\n\n" + if failed: + desc += f"**{theme.deniedIcon} Not found ({len(failed)}):**\n" + ", ".join(failed[:20]) + desc += "\n\n" + desc += f"{theme.lowerDivider}" + + embed = discord.Embed( + title=f"{theme.verifiedIcon} Players Added — Priority: {PRIORITY_LABELS[priority]}", + description=desc, + color=theme.emColor3 if added else theme.emColor4, + ) + await interaction.edit_original_response(embed=embed, view=None) + + +# ── Remove player select ────────────────────────────────────────────────────── + +class RemovePlayerView(discord.ui.View): + def __init__(self, rows, author_id): + super().__init__(timeout=120) + self.author_id = author_id + + options = [ + discord.SelectOption( + label=f"{row[2]} ({row[1]})"[:100], + value=str(row[0]), + description=f"Priority: {PRIORITY_LABELS.get(row[3], row[3])} Power: {row[4] or 'N/A'}", + ) + for row in rows[:25] + ] + select = discord.ui.Select(placeholder=f"{theme.trashIcon} Select player to remove...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + pid = int(interaction.data["values"][0]) + with sqlite3.connect(DB_PATH) as db: + row = db.execute("SELECT fid, name FROM transfer_players WHERE id = ?", (pid,)).fetchone() + db.execute("DELETE FROM transfer_players WHERE id = ?", (pid,)) + db.commit() + embed = discord.Embed( + title=f"{theme.verifiedIcon} Player Removed", + description=f"`{row[1]}` ({row[0]}) removed from the transfer list.", + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=embed, view=None) + + +# ── Change priority select ──────────────────────────────────────────────────── + +class ChangePriorityPlayerView(discord.ui.View): + def __init__(self, rows, author_id): + super().__init__(timeout=120) + self.author_id = author_id + self.rows = rows + + options = [ + discord.SelectOption( + label=f"{row[2]} ({row[1]})"[:100], + value=str(row[0]), + description=f"Current: {PRIORITY_LABELS.get(row[3], row[3])}", + ) + for row in rows[:25] + ] + select = discord.ui.Select(placeholder="Select player to update...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + pid = int(interaction.data["values"][0]) + row = next((r for r in self.rows if r[0] == pid), None) + await interaction.response.edit_message( + embed=discord.Embed( + title=f"{theme.editListIcon} Select New Priority", + description=f"Changing priority for **{row[2]}** ({row[1]})", + color=theme.emColor1, + ), + view=ChangePriorityView(pid, row[2], interaction.user.id), + ) + + +class ChangePriorityView(discord.ui.View): + def __init__(self, pid, name, author_id): + super().__init__(timeout=120) + self.pid = pid + self.name = name + self.author_id = author_id + + options = [ + discord.SelectOption(label="High", value="high", emoji="🔴"), + discord.SelectOption(label="Med", value="med", emoji="🟡"), + discord.SelectOption(label="Low", value="low", emoji="🟢"), + ] + select = discord.ui.Select(placeholder="Select new priority...", options=options) + select.callback = self._on_select + self.add_item(select) + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + async def _on_select(self, interaction: discord.Interaction): + priority = interaction.data["values"][0] + with sqlite3.connect(DB_PATH) as db: + db.execute("UPDATE transfer_players SET priority = ? WHERE id = ?", (priority, self.pid)) + db.commit() + embed = discord.Embed( + title=f"{theme.verifiedIcon} Priority Updated", + description=f"**{self.name}** priority set to **{PRIORITY_LABELS[priority]}**.", + color=theme.emColor3, + ) + await interaction.response.edit_message(embed=embed, view=None) + + +# ── Confirm replace event ───────────────────────────────────────────────────── + +class ConfirmReplaceView(discord.ui.View): + def __init__(self, author_id): + super().__init__(timeout=120) + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + @discord.ui.button(label="Start New", emoji="🆕", style=discord.ButtonStyle.danger) + async def start_new(self, interaction: discord.Interaction, button: discord.ui.Button): + async def after_modal(i, name, date, threshold): + with sqlite3.connect(DB_PATH) as db: + db.execute("UPDATE transfer_events SET status = 'closed' WHERE status = 'active'") + db.commit() + await _create_event(i, name, date, threshold) + + await interaction.response.send_modal(NewEventModal(after_modal)) + + @discord.ui.button(label="Continue Existing", emoji="📋", style=discord.ButtonStyle.success) + async def continue_existing(self, interaction: discord.Interaction, button: discord.ui.Button): + event = _get_active_event() + cog = interaction.client.get_cog("TransferCog") + if cog: + await cog.show_main_menu(interaction, event) + else: + await interaction.response.edit_message( + embed=discord.Embed(title=f"{theme.deniedIcon} Transfer module not found.", color=theme.emColor4), + view=None, + ) + + @discord.ui.button(label="Cancel", emoji="❌", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.edit_message( + embed=discord.Embed( + title=f"{theme.deniedIcon} Cancelled", + description="No changes made.", + color=theme.emColor4, + ), + view=None, + ) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +async def _create_event(interaction, name, date, threshold, use_send=False): + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + with sqlite3.connect(DB_PATH) as db: + db.execute( + "INSERT INTO transfer_events (name, transfer_date, power_threshold, status, created_at, created_by_id) VALUES (?, ?, ?, 'active', ?, ?)", + (name, date, threshold, now, str(interaction.user.id)), + ) + db.commit() + event = _get_active_event() + cog = interaction.client.get_cog("TransferCog") + if cog: + await cog.show_main_menu(interaction, event, use_send=use_send) + + +# ── Main menu ───────────────────────────────────────────────────────────────── + +class TransferMenuView(discord.ui.View): + def __init__(self, cog, event, author_id): + super().__init__(timeout=300) + self.cog = cog + self.event = event + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + @discord.ui.button(label="Add Players", emoji="➕", style=discord.ButtonStyle.success, row=0) + async def add_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + async def after_fids(i, fid_list): + if not fid_list: + await i.response.edit_message( + embed=discord.Embed( + title=f"{theme.deniedIcon} No valid FIDs entered.", + color=theme.emColor4, + ), + view=None, + ) + return + embed = discord.Embed( + title=f"{theme.userIcon} Select Priority", + description=f"Adding **{len(fid_list)}** player(s). Select a priority for this batch.", + color=theme.emColor1, + ) + if i.response.is_done(): + await i.edit_original_response(embed=embed, view=PrioritySelectView(fid_list, i.user.id)) + else: + await i.response.edit_message(embed=embed, view=PrioritySelectView(fid_list, i.user.id)) + + await interaction.response.send_modal(AddPlayersModal(after_fids)) + + @discord.ui.button(label="View List", emoji="👁️", style=discord.ButtonStyle.primary, row=0) + async def view_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + event = self.event + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT id, fid, name, priority, power FROM transfer_players WHERE event_id = ? ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'med' THEN 1 ELSE 2 END, added_at", + (event[0],), + ).fetchall() + + threshold = event[3] + embed = discord.Embed( + title=f"{theme.listIcon} Transfer List — {_event_label(event)}", + color=theme.emColor1, + ) + + if not rows: + embed.description = f"{theme.blankListIcon} No players on the list yet." + else: + lines = [] + for idx, (pid, fid, name, priority, power) in enumerate(rows, 1): + flag = "" + if power and threshold: + try: + if float(power) > float(threshold): + flag = " ⚠️" + except ValueError: + pass + pri_emoji = {"high": "🔴", "med": "🟡", "low": "🟢"}.get(priority, "⚪") + power_str = f" • Power: `{power}`" if power else "" + lines.append(f"`{idx}.` {pri_emoji} **{name}**{flag} — `{fid}`{power_str}") + embed.description = "\n".join(lines[:40]) + if len(rows) > 40: + embed.description += f"\n...and {len(rows) - 40} more" + embed.set_footer(text=f"{len(rows)} player(s) total" + (f" • ⚠️ = needs special pass (power > {threshold})" if threshold else "")) + + if interaction.response.is_done(): + await interaction.edit_original_response(embed=embed, view=self) + else: + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button(label="Change Priority", emoji="📅", style=discord.ButtonStyle.secondary, row=1) + async def priority_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + event = self.event + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT id, fid, name, priority, power FROM transfer_players WHERE event_id = ? ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'med' THEN 1 ELSE 2 END, added_at", + (event[0],), + ).fetchall() + + if not rows: + await interaction.response.send_message( + f"{theme.deniedIcon} No players on the list.", ephemeral=True + ) + return + + embed = discord.Embed( + title=f"{theme.editListIcon} Change Priority", + description="Select a player to update their priority.", + color=theme.emColor1, + ) + await interaction.response.edit_message(embed=embed, view=ChangePriorityPlayerView(rows, interaction.user.id)) + + @discord.ui.button(label="Remove Player", emoji="🗑️", style=discord.ButtonStyle.danger, row=1) + async def remove_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + event = self.event + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT id, fid, name, priority, power FROM transfer_players WHERE event_id = ? ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'med' THEN 1 ELSE 2 END, added_at", + (event[0],), + ).fetchall() + + if not rows: + await interaction.response.send_message( + f"{theme.deniedIcon} No players on the list.", ephemeral=True + ) + return + + embed = discord.Embed( + title=f"{theme.trashIcon} Remove Player", + description="Select a player to remove from the transfer list.", + color=theme.emColor2, + ) + await interaction.response.edit_message(embed=embed, view=RemovePlayerView(rows, interaction.user.id)) + + @discord.ui.button(label="Export CSV", emoji="📤", style=discord.ButtonStyle.primary, row=2) + async def export_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + event = self.event + + async def after_modal(i, pass_count): + with sqlite3.connect(DB_PATH) as db: + rows = db.execute( + "SELECT fid, name, priority, power FROM transfer_players WHERE event_id = ? ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'med' THEN 1 ELSE 2 END, added_at", + (event[0],), + ).fetchall() + + if not rows: + await i.response.send_message( + f"{theme.deniedIcon} No players on the list to export.", ephemeral=True + ) + return + + threshold = event[3] + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(["Transfer Event:", _event_label(event)]) + writer.writerow(["Available Passes:", pass_count]) + if threshold: + writer.writerow(["Power Threshold:", threshold]) + writer.writerow(["Export Date:", datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")]) + writer.writerow(["Total Players:", len(rows)]) + writer.writerow([]) + writer.writerow(["#", "FID", "Name", "Priority", "Power", "Special Pass Required"]) + + for idx, (fid, name, priority, power) in enumerate(rows, 1): + if idx == pass_count + 1: + writer.writerow([]) + writer.writerow(["--- PASS CUTOFF ---", f"Passes available: {pass_count}", "", "", "", ""]) + writer.writerow([]) + + special_pass = "" + if power and threshold: + try: + special_pass = "YES" if float(power) > float(threshold) else "" + except ValueError: + pass + + writer.writerow([idx, fid, name, PRIORITY_LABELS.get(priority, priority), power or "", special_pass]) + + output.seek(0) + safe_name = _event_label(event).replace(" ", "_").replace("—", "-") + filename = f"transfer_{safe_name}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv" + file = discord.File(io.BytesIO(output.getvalue().encode("utf-8")), filename=filename) + + try: + await i.user.send( + embed=discord.Embed( + title=f"{theme.verifiedIcon} Transfer List Exported", + description=( + f"**{_event_label(event)}**\n\n" + f"{theme.userIcon} **Players:** {len(rows)}\n" + f"{theme.shieldIcon} **Passes:** {pass_count}\n" + f"Players ranked High → Med → Low with cutoff at position #{pass_count}." + ), + color=theme.emColor3, + ), + file=file, + ) + if i.response.is_done(): + await i.edit_original_response( + embed=discord.Embed( + title=f"{theme.verifiedIcon} Export Sent", + description="Check your DMs for the CSV file.", + color=theme.emColor3, + ), + view=None, + ) + else: + await i.response.edit_message( + embed=discord.Embed( + title=f"{theme.verifiedIcon} Export Sent", + description="Check your DMs for the CSV file.", + color=theme.emColor3, + ), + view=None, + ) + except discord.Forbidden: + await i.response.send_message( + f"{theme.deniedIcon} Could not DM you — please enable DMs from server members.", + ephemeral=True, + ) + + await interaction.response.send_modal(ExportModal(after_modal)) + + @discord.ui.button(label="Close Event", emoji="🔒", style=discord.ButtonStyle.secondary, row=2) + async def close_btn(self, interaction: discord.Interaction, button: discord.ui.Button): + embed = discord.Embed( + title=f"{theme.warnIcon} Close Event?", + description=( + f"Close **{_event_label(self.event)}**?\n\n" + "The event and its list will be archived but not deleted." + ), + color=theme.emColor4, + ) + await interaction.response.edit_message(embed=embed, view=ConfirmCloseView(self.event, interaction.user.id)) + + +class ConfirmCloseView(discord.ui.View): + def __init__(self, event, author_id): + super().__init__(timeout=120) + self.event = event + self.author_id = author_id + + async def interaction_check(self, interaction: discord.Interaction): + if interaction.user.id != self.author_id: + await interaction.response.send_message( + f"{theme.deniedIcon} Only the original user can interact with this.", ephemeral=True + ) + return False + return True + + @discord.ui.button(label="Close Event", emoji="🔒", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button): + with sqlite3.connect(DB_PATH) as db: + db.execute("UPDATE transfer_events SET status = 'closed' WHERE id = ?", (self.event[0],)) + db.commit() + await interaction.response.edit_message( + embed=discord.Embed( + title=f"{theme.verifiedIcon} Event Closed", + description=f"**{_event_label(self.event)}** has been archived.", + color=theme.emColor3, + ), + view=None, + ) + + @discord.ui.button(label="Cancel", emoji="❌", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + cog = interaction.client.get_cog("TransferCog") + if cog: + await cog.show_main_menu(interaction, self.event) + + +# ── Cog ─────────────────────────────────────────────────────────────────────── + +class TransferCog(commands.Cog): + def __init__(self, bot): + self.bot = bot + _init_db() + + async def show_main_menu(self, interaction: discord.Interaction, event, use_send=False): + embed = discord.Embed( + title=f"{theme.listIcon} State Transfer", + description=( + f"**Active Event:** {_event_label(event)}\n" + + (f"**Power Threshold:** `{event[3]}`\n" if event[3] else "") + + f"\n{theme.upperDivider}\n" + f"➕ **Add Players** — Add one or more FIDs to the list\n" + f"👁️ **View List** — See current transfer list\n" + f"📅 **Change Priority** — Update a player's priority\n" + f"🗑️ **Remove Player** — Remove someone from the list\n" + f"📤 **Export CSV** — Generate ranked CSV sent via DM\n" + f"🔒 **Close Event** — Archive this event\n" + f"{theme.lowerDivider}" + ), + color=theme.emColor1, + ) + embed.set_footer(text="Only you can see this menu") + view = TransferMenuView(self, event, interaction.user.id) + if interaction.response.is_done(): + await interaction.edit_original_response(embed=embed, view=view) + elif use_send: + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + else: + await interaction.response.edit_message(embed=embed, view=view) + + async def show_transfer_menu(self, interaction: discord.Interaction): + await self._transfer_entry(interaction) + + @discord.app_commands.command(name="transfer", description="State transfer list management") + async def transfer(self, interaction: discord.Interaction): + await self._transfer_entry(interaction) + + async def _transfer_entry(self, interaction: discord.Interaction): + is_admin, _ = PermissionManager.is_admin(interaction.user.id) + if not is_admin: + await interaction.response.send_message( + f"{theme.deniedIcon} You do not have permission to use this command.", + ephemeral=True, + ) + return + + event = _get_active_event() + + if event: + embed = discord.Embed( + title=f"{theme.listIcon} State Transfer", + description=( + f"An active transfer event already exists:\n\n" + f"{theme.upperDivider}\n" + f"**{_event_label(event)}**\n" + + (f"Power Threshold: `{event[3]}`\n" if event[3] else "") + + f"{theme.lowerDivider}\n\n" + f"Do you want to continue with this event or start a new one?" + ), + color=theme.emColor1, + ) + await interaction.response.send_message(embed=embed, view=ConfirmReplaceView(interaction.user.id), ephemeral=True) + else: + async def after_modal(i, name, date, threshold): + await _create_event(i, name, date, threshold, use_send=True) + + await interaction.response.send_modal(NewEventModal(after_modal)) + + +async def setup(bot): + await bot.add_cog(TransferCog(bot)) From 448601d5bfca305d97ff749ec9e4bc1a954c17bd Mon Sep 17 00:00:00 2001 From: blackdeathwos Date: Thu, 25 Jun 2026 22:19:41 -0600 Subject: [PATCH 2/2] Add discipline and transfer cogs to main.py load list Co-Authored-By: Claude Sonnet 4.6 --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index f2afddb..5cfca4f 100644 --- a/main.py +++ b/main.py @@ -1250,7 +1250,7 @@ def create_tables(): startup.phase_ok("Database ready") async def load_cogs(): - cogs = ["pimp_my_bot", "process_queue", "onnx_lifecycle", "bot_main_menu", "alliance_sync", "alliance", "alliance_member_operations", "bot_operations", "alliance_logs", "bot_support", "bot_health", "gift_operations", "alliance_history", "alliance_w_command", "bot_startup", "notification_system", "notification_schedule", "alliance_id_channel", "alliance_channels", "bot_backup", "notification_editor", "notification_templates", "notification_wizard", "attendance", "attendance_report", "attendance_ocr", "minister_schedule", "minister_menu", "minister_archive", "alliance_registration", "bear_track"] + cogs = ["pimp_my_bot", "process_queue", "onnx_lifecycle", "bot_main_menu", "alliance_sync", "alliance", "alliance_member_operations", "bot_operations", "alliance_logs", "bot_support", "bot_health", "gift_operations", "alliance_history", "alliance_w_command", "bot_startup", "notification_system", "notification_schedule", "alliance_id_channel", "alliance_channels", "bot_backup", "notification_editor", "notification_templates", "notification_wizard", "attendance", "attendance_report", "attendance_ocr", "minister_schedule", "minister_menu", "minister_archive", "alliance_registration", "bear_track", "discipline", "transfer"] failed_cogs = []