""" stack_manager.py Stack management feature: leaders create a persistent embed, players apply to join, leader accepts/declines applicants. """ # Standard Library Imports import json import time from pathlib import Path # Third-Party Library Imports import discord from discord import app_commands from discord.ext import commands # Local Module Imports from .utils import ( DEFAULT_FOOTER_CAT, STACKS_DIR, load_json, write_json, is_blacklisted, gate_entitle, permission_fail, esc, collect_command_stats, command_locale, ) from .utils import t, guild_lang MAX_MEMBERS = 8 MAX_REQUESTERS = 20 # ============================================================================ # HELPERS # ============================================================================ def get_stack_path(guild_id: str, leader_discord_id: str) -> Path: """Return the filesystem Path for a stack's JSON file. Args: guild_id: Discord guild ID string. leader_discord_id: Discord user ID string of the stack leader. Returns: Path object pointing to the stack JSON file. """ return STACKS_DIR / f"{guild_id}-{leader_discord_id}.json" async def load_stack(guild_id: str, leader_discord_id: str) -> dict | None: """Load a stack's data from its JSON file asynchronously. Args: guild_id: Discord guild ID string. leader_discord_id: Discord user ID string of the stack leader. Returns: The parsed stack dict, or None if the file does not exist or is empty. """ path = get_stack_path(guild_id, leader_discord_id) if not path.exists(): return None data = await load_json(path, None) return data if data else None def load_stack_sync(guild_id: str, leader_discord_id: str) -> dict | None: """Sync version for use in __init__ methods that can't be async.""" path = get_stack_path(guild_id, leader_discord_id) if not path.exists(): return None try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data if data else None except Exception: return None async def save_stack(data: dict) -> bool: """Persist a stack's data dict to its JSON file. Args: data: Stack data dict containing at least guild_id and leader_discord_id. Returns: True on success, False on failure. """ path = get_stack_path(data["guild_id"], data["leader_discord_id"]) STACKS_DIR.mkdir(parents=True, exist_ok=True) return await write_json(path, data) def build_stack_embed(data: dict, lang: str = "en") -> discord.Embed: """Build the public-facing Discord embed for a stack. Shows the stack name, member list with vehicles, leader star indicator, and any pending applicants in the queue. Args: data: Stack data dict with members, requesters, leader info, and name. lang: Language code for i18n. Returns: A discord.Embed ready to send or edit into a message. """ leader_nick = esc(data["leader_nick"]) members = data.get("members", []) requesters = data.get("requesters", []) embed = discord.Embed( title=data.get("name") or t(lang, "stacks.stack_title", leader=data['leader_nick']), color=discord.Color.blurple(), ) if members: lines = [] for m in members: nick = esc(m['nick']) vehicle = esc(m['vehicle']) if m["discord_id"] == data["leader_discord_id"]: lines.append(f"{nick} — {vehicle} (⭐)") else: lines.append(f"{nick} — {vehicle}") members_text = "\n".join(lines) else: members_text = t(lang, "stacks.no_members") embed.add_field( name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS), value=members_text, inline=False, ) if requesters: req_lines = [ f"{esc(r['nick'])} — {esc(r['vehicle'])}" for r in requesters ] embed.add_field( name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS), value="\n".join(req_lines), inline=False, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed def _build_manage_embed(data: dict, lang: str = "en") -> discord.Embed: """Build the ephemeral management embed showing members and queue. Args: data: Stack data dict with members and requesters lists. lang: Language code for i18n. Returns: A discord.Embed for the leader's management panel. """ requesters = data.get("requesters", []) members = data.get("members", []) embed = discord.Embed(title=t(lang, "stacks.manage_title"), color=discord.Color.blurple()) embed.add_field( name=t(lang, "stacks.members_field", count=len(members), max=MAX_MEMBERS), value="\n".join( f"{esc(m['nick'])} — {esc(m['vehicle'])}" for m in members ) or t(lang, "common.none_option"), inline=False, ) embed.add_field( name=t(lang, "stacks.queue_field", count=len(requesters), max=MAX_REQUESTERS), value="\n".join( f"{esc(r['nick'])} — {esc(r['vehicle'])}" for r in requesters ) or t(lang, "stacks.no_pending_requests"), inline=False, ) embed.set_footer(text=DEFAULT_FOOTER_CAT) return embed async def _do_transfer( data: dict, new_leader_id: str, public_stack_view: "StackView", lang: str = "en", ) -> str | None: """ Transfer stack ownership to new_leader_id. Renames the JSON file, updates public_stack_view in place. Returns an error string on failure, or None on success. """ guild_id = data["guild_id"] old_leader_id = data["leader_discord_id"] new_leader = next((m for m in data["members"] if m["discord_id"] == new_leader_id), None) if new_leader is None: return t(lang, "stacks.member_not_exists") new_path = get_stack_path(guild_id, new_leader_id) if new_path.exists(): return t(lang, "stacks.already_has_stack") old_path = get_stack_path(guild_id, old_leader_id) data["leader_discord_id"] = new_leader["discord_id"] data["leader_nick"] = new_leader["nick"] data["leader_vehicle"] = new_leader["vehicle"] data["members"] = [m for m in data["members"] if m["discord_id"] != old_leader_id] await save_stack(data) # writes to new path old_path.unlink(missing_ok=True) public_stack_view.leader_discord_id = new_leader_id return None async def refresh_stack_message(view: "StackView") -> None: """Re-render and edit the public stack message with current data. Args: view: The StackView whose associated message should be refreshed. """ if view.message is None: return data = await load_stack(view.guild_id, view.leader_discord_id) if data is None: return embed = build_stack_embed(data, lang=view.lang) await view.message.edit(embed=embed, view=view) # ============================================================================ # MODAL # ============================================================================ class JoinStackModal(discord.ui.Modal, title="Apply to Join Stack"): """Modal presented to users requesting to join a stack. Collects the vehicle the player intends to use and adds them to the stack's requester queue upon submission. """ vehicle_input = discord.ui.TextInput( label="What will you play?", placeholder="e.g. F-16C, WZ305...", max_length=100, required=True, ) def __init__(self, guild_id: str, leader_discord_id: str, stack_view: "StackView", lang: str = "en"): super().__init__() self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.stack_view = stack_view self.lang = lang self.title = t(lang, "stacks.join_modal_title") self.vehicle_input.label = t(lang, "stacks.join_vehicle_label") self.vehicle_input.placeholder = t(lang, "stacks.join_vehicle_placeholder") async def on_submit(self, interaction: discord.Interaction) -> None: """Validate the application and append the user to the stack's request queue.""" data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message( t(self.lang, "stacks.no_longer_exists"), ephemeral=True ) return user_id = str(interaction.user.id) nick = interaction.user.display_name vehicle = self.vehicle_input.value.strip() if any(m["discord_id"] == user_id for m in data["members"]): await interaction.response.send_message( t(self.lang, "stacks.already_member"), ephemeral=True ) return if any(r["discord_id"] == user_id for r in data["requesters"]): await interaction.response.send_message( t(self.lang, "stacks.already_applied"), ephemeral=True ) return if len(data["requesters"]) >= MAX_REQUESTERS: await interaction.response.send_message( t(self.lang, "stacks.queue_full", max=MAX_REQUESTERS), ephemeral=True, ) return data["requesters"].append({"discord_id": user_id, "nick": nick, "vehicle": vehicle}) await save_stack(data) await refresh_stack_message(self.stack_view) await interaction.response.send_message( t(self.lang, "stacks.application_sent"), ephemeral=True ) # ============================================================================ # DISBAND CONFIRMATION VIEW # ============================================================================ class DisbandConfirmView(discord.ui.View): """Ephemeral confirmation view with Yes/Cancel buttons for disbanding a stack.""" def __init__(self, stack_view: "StackView", lang: str = "en"): super().__init__(timeout=30) self.stack_view = stack_view self.lang = lang self.confirm.label = t(lang, "buttons.yes_disband") self.cancel.label = t(lang, "buttons.cancel") @discord.ui.button(label="Yes, Disband", style=discord.ButtonStyle.red) async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.edit_message( content=t(self.lang, "stacks.stack_disbanded"), embed=None, view=None ) path = get_stack_path(self.stack_view.guild_id, self.stack_view.leader_discord_id) path.unlink(missing_ok=True) embed = discord.Embed( title=t(self.lang, "stacks.disbanded_title"), description=t(self.lang, "stacks.disbanded_desc"), color=discord.Color.dark_red(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) if self.stack_view.message: await self.stack_view.message.edit(embed=embed, view=None) self.stack_view.stop() @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), embed=None, view=None) # ============================================================================ # TRANSFER LEAVE VIEW (ephemeral, leader-only) # ============================================================================ class TransferLeaveView(discord.ui.View): """Shown when the leader clicks Leave / Withdraw and other members exist.""" def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__(timeout=60) self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang self.selected_uid: str | None = None data = load_stack_sync(guild_id, leader_discord_id) non_leader = [ m for m in (data.get("members", []) if data else []) if m["discord_id"] != leader_discord_id ] options = [ discord.SelectOption( label=f"{m['nick']} — {m['vehicle']}"[:100], value=m["discord_id"], ) for m in non_leader ] select = discord.ui.Select(placeholder=t(lang, "stacks.select_new_leader"), options=options) select.callback = self._on_select self.add_item(select) transfer_btn = discord.ui.Button(label=t(lang, "buttons.transfer_leave"), style=discord.ButtonStyle.blurple) transfer_btn.callback = self._on_transfer self.add_item(transfer_btn) async def _on_select(self, interaction: discord.Interaction) -> None: if interaction.data: self.selected_uid = interaction.data["values"][0] # type: ignore[typeddict-item] await interaction.response.defer() async def _on_transfer(self, interaction: discord.Interaction) -> None: if not self.selected_uid: await interaction.response.send_message( t(self.lang, "stacks.select_member_transfer"), ephemeral=True ) return data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return new_nick = next( (m["nick"] for m in data["members"] if m["discord_id"] == self.selected_uid), "?" ) error = await _do_transfer(data, self.selected_uid, self.public_stack_view, lang=self.lang) if error: await interaction.response.send_message(error, ephemeral=True) return await refresh_stack_message(self.public_stack_view) await interaction.response.edit_message( content=t(self.lang, "stacks.ownership_transferred", nick=esc(new_nick)), view=None, ) # ============================================================================ # MANAGE SUB-VIEWS (ephemeral, leader-only) # ============================================================================ class AcceptMembersView(discord.ui.View): """Sub-view: accept or decline applicants from the queue.""" def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__(timeout=300) self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang self.selected_requester_uids: list[str] = [] data = load_stack_sync(guild_id, leader_discord_id) requesters = data.get("requesters", []) if data else [] if requesters: req_options = [ discord.SelectOption( label=f"{r['nick']} — {r['vehicle']}"[:100], value=r["discord_id"], ) for r in requesters ] req_select = discord.ui.Select( placeholder=t(lang, "stacks.select_applicants"), options=req_options, min_values=1, max_values=len(req_options), ) req_select.callback = self._on_requester_select self.add_item(req_select) accept_btn = discord.ui.Button(label=t(lang, "buttons.accept_selected"), style=discord.ButtonStyle.green) accept_btn.callback = self._on_accept self.add_item(accept_btn) accept_all_btn = discord.ui.Button(label=t(lang, "buttons.accept_all"), style=discord.ButtonStyle.green) accept_all_btn.callback = self._on_accept_all self.add_item(accept_all_btn) decline_btn = discord.ui.Button(label=t(lang, "buttons.decline_selected"), style=discord.ButtonStyle.red) decline_btn.callback = self._on_decline self.add_item(decline_btn) else: disabled_req = discord.ui.Select( placeholder=t(lang, "stacks.no_pending_applications"), options=[discord.SelectOption(label="—", value="none")], disabled=True, ) self.add_item(disabled_req) back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) back_btn.callback = self._on_back self.add_item(back_btn) async def _on_requester_select(self, interaction: discord.Interaction) -> None: if interaction.data: self.selected_requester_uids = interaction.data["values"] # type: ignore[typeddict-item] await interaction.response.defer() async def _on_accept(self, interaction: discord.Interaction) -> None: if not self.selected_requester_uids: await interaction.response.send_message( t(self.lang, "stacks.select_applicant_first"), ephemeral=True ) return data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return available_slots = MAX_MEMBERS - len(data["members"]) to_accept_ids = self.selected_requester_uids[:available_slots] if not to_accept_ids: await interaction.response.send_message( t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True ) return accepted = [r for r in data["requesters"] if r["discord_id"] in to_accept_ids] data["members"].extend(accepted) accepted_ids = {r["discord_id"] for r in accepted} data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids] await save_stack(data) self.selected_requester_uids = [] await refresh_stack_message(self.public_stack_view) new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) async def _on_accept_all(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return available_slots = MAX_MEMBERS - len(data["members"]) if available_slots <= 0: await interaction.response.send_message( t(self.lang, "stacks.stack_full", max=MAX_MEMBERS), ephemeral=True ) return to_accept = data["requesters"][:available_slots] accepted_ids = {r["discord_id"] for r in to_accept} data["members"].extend(to_accept) data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in accepted_ids] await save_stack(data) self.selected_requester_uids = [] await refresh_stack_message(self.public_stack_view) new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) async def _on_decline(self, interaction: discord.Interaction) -> None: if not self.selected_requester_uids: await interaction.response.send_message( t(self.lang, "stacks.select_applicant_first"), ephemeral=True ) return data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return declined_ids = set(self.selected_requester_uids) data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in declined_ids] await save_stack(data) self.selected_requester_uids = [] await refresh_stack_message(self.public_stack_view) new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) async def _on_back(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message( content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view ) class RemoveMembersView(discord.ui.View): """Sub-view: remove members and/or requesters by various bulk or selective actions.""" def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__(timeout=300) self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang # values are prefixed: "m:{id}" for members, "r:{id}" for requesters self.selected_values: list[str] = [] data = load_stack_sync(guild_id, leader_discord_id) non_leader_members = [ m for m in (data.get("members", []) if data else []) if m["discord_id"] != leader_discord_id ] requesters = data.get("requesters", []) if data else [] all_options = [ discord.SelectOption( label=f"[Active] {m['nick']} — {m['vehicle']}"[:100], value=f"m:{m['discord_id']}", ) for m in non_leader_members ] + [ discord.SelectOption( label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100], value=f"r:{r['discord_id']}", ) for r in requesters ] if all_options: select = discord.ui.Select( placeholder=t(lang, "stacks.select_to_remove"), options=all_options, min_values=1, max_values=len(all_options), ) select.callback = self._on_select self.add_item(select) else: self.add_item(discord.ui.Select( placeholder=t(lang, "stacks.no_members_or_applicants"), options=[discord.SelectOption(label="—", value="none")], disabled=True, )) remove_all_btn = discord.ui.Button(label=t(lang, "buttons.remove_all"), style=discord.ButtonStyle.red) remove_all_btn.callback = self._on_remove_all self.add_item(remove_all_btn) remove_active_btn = discord.ui.Button(label=t(lang, "buttons.remove_active"), style=discord.ButtonStyle.red) remove_active_btn.callback = self._on_remove_active self.add_item(remove_active_btn) remove_queued_btn = discord.ui.Button(label=t(lang, "buttons.remove_queued"), style=discord.ButtonStyle.red) remove_queued_btn.callback = self._on_remove_queued self.add_item(remove_queued_btn) remove_selected_btn = discord.ui.Button(label=t(lang, "buttons.remove_selected"), style=discord.ButtonStyle.red) remove_selected_btn.callback = self._on_remove_selected self.add_item(remove_selected_btn) back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) back_btn.callback = self._on_back self.add_item(back_btn) async def _on_select(self, interaction: discord.Interaction) -> None: if interaction.data: self.selected_values = interaction.data["values"] # type: ignore[typeddict-item] await interaction.response.defer() async def _apply_removal( self, interaction: discord.Interaction, remove_member_ids: set[str], remove_requester_ids: set[str], ) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return remove_member_ids -= {self.leader_discord_id} data["members"] = [m for m in data["members"] if m["discord_id"] not in remove_member_ids] data["requesters"] = [r for r in data["requesters"] if r["discord_id"] not in remove_requester_ids] await save_stack(data) self.selected_values = [] await refresh_stack_message(self.public_stack_view) new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) async def _on_remove_all(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return member_ids = {m["discord_id"] for m in data.get("members", [])} requester_ids = {r["discord_id"] for r in data.get("requesters", [])} await self._apply_removal(interaction, member_ids, requester_ids) async def _on_remove_active(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return member_ids = {m["discord_id"] for m in data.get("members", [])} await self._apply_removal(interaction, member_ids, set()) async def _on_remove_queued(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return requester_ids = {r["discord_id"] for r in data.get("requesters", [])} await self._apply_removal(interaction, set(), requester_ids) async def _on_remove_selected(self, interaction: discord.Interaction) -> None: if not self.selected_values: await interaction.response.send_message( t(self.lang, "stacks.select_person_first"), ephemeral=True ) return member_ids = {v[2:] for v in self.selected_values if v.startswith("m:")} requester_ids = {v[2:] for v in self.selected_values if v.startswith("r:")} await self._apply_removal(interaction, member_ids, requester_ids) async def _on_back(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message( content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view ) class PingMessageModal(discord.ui.Modal, title="Ping Message"): """Modal for composing an optional custom ping message.""" message = discord.ui.TextInput( label="Custom message (optional)", placeholder='e.g. Come now! Stack starting!', required=False, max_length=200, ) def __init__( self, guild_id: str, leader_discord_id: str, ping_type: str, selected_uids: list[str] | None = None, lang: str = "en", ): super().__init__() self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.ping_type = ping_type # "all" | "active" | "queued" | "select" self.selected_uids = selected_uids or [] self.lang = lang self.title = t(lang, "stacks.ping_modal_title") self.message.label = t(lang, "stacks.ping_message_label") self.message.placeholder = t(lang, "stacks.ping_message_placeholder") async def on_submit(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return if self.ping_type == "all": targets = ( [m["discord_id"] for m in data.get("members", []) if m["discord_id"] != self.leader_discord_id] + [r["discord_id"] for r in data.get("requesters", [])] ) elif self.ping_type == "active": targets = [m["discord_id"] for m in data.get("members", [])] elif self.ping_type == "queued": targets = [r["discord_id"] for r in data.get("requesters", [])] else: targets = self.selected_uids if not targets: await interaction.response.send_message(t(self.lang, "stacks.no_one_to_ping"), ephemeral=True) return mentions = " ".join(f"<@{uid}>" for uid in targets) custom = self.message.value.strip() if custom: content = f"{mentions} {custom}" else: stack_name = data.get("name") or "the stack" content = f"{mentions}\n-# {t(self.lang, 'stacks.ping_footer', leader=esc(data['leader_nick']), stack=stack_name)}" await interaction.response.send_message(t(self.lang, "stacks.pinged"), ephemeral=True) await interaction.channel.send(content) # type: ignore[union-attr] class PingMembersView(discord.ui.View): """Sub-view: ping all, active members, queued applicants, or a custom selection.""" def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__(timeout=300) self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang self.selected_uids: list[str] = [] data = load_stack_sync(guild_id, leader_discord_id) members = data.get("members", []) if data else [] requesters = data.get("requesters", []) if data else [] all_options = [ discord.SelectOption( label=f"[Active] {m['nick']} — {m['vehicle']}"[:100], value=m["discord_id"], ) for m in members ] + [ discord.SelectOption( label=f"[Queue] {r['nick']} — {r['vehicle']}"[:100], value=r["discord_id"], ) for r in requesters ] if all_options: select = discord.ui.Select( placeholder=t(lang, "stacks.select_to_ping"), options=all_options, min_values=1, max_values=len(all_options), ) select.callback = self._on_select self.add_item(select) else: self.add_item(discord.ui.Select( placeholder=t(lang, "stacks.no_members_or_applicants"), options=[discord.SelectOption(label="—", value="none")], disabled=True, )) ping_all_btn = discord.ui.Button(label=t(lang, "buttons.ping_all"), style=discord.ButtonStyle.green) ping_all_btn.callback = self._on_ping_all self.add_item(ping_all_btn) ping_active_btn = discord.ui.Button(label=t(lang, "buttons.ping_active"), style=discord.ButtonStyle.blurple) ping_active_btn.callback = self._on_ping_active self.add_item(ping_active_btn) ping_queued_btn = discord.ui.Button(label=t(lang, "buttons.ping_queued"), style=discord.ButtonStyle.blurple) ping_queued_btn.callback = self._on_ping_queued self.add_item(ping_queued_btn) ping_select_btn = discord.ui.Button(label=t(lang, "buttons.ping_selected"), style=discord.ButtonStyle.blurple) ping_select_btn.callback = self._on_ping_selected self.add_item(ping_select_btn) back_btn = discord.ui.Button(label=t(lang, "buttons.back"), style=discord.ButtonStyle.grey) back_btn.callback = self._on_back self.add_item(back_btn) async def _on_select(self, interaction: discord.Interaction) -> None: if interaction.data: self.selected_uids = interaction.data["values"] # type: ignore[typeddict-item] await interaction.response.defer() async def _on_ping_all(self, interaction: discord.Interaction) -> None: await interaction.response.send_modal( PingMessageModal(self.guild_id, self.leader_discord_id, "all", lang=self.lang) ) async def _on_ping_active(self, interaction: discord.Interaction) -> None: await interaction.response.send_modal( PingMessageModal(self.guild_id, self.leader_discord_id, "active", lang=self.lang) ) async def _on_ping_queued(self, interaction: discord.Interaction) -> None: await interaction.response.send_modal( PingMessageModal(self.guild_id, self.leader_discord_id, "queued", lang=self.lang) ) async def _on_ping_selected(self, interaction: discord.Interaction) -> None: if not self.selected_uids: await interaction.response.send_message( t(self.lang, "stacks.select_from_dropdown"), ephemeral=True ) return await interaction.response.send_modal( PingMessageModal(self.guild_id, self.leader_discord_id, "select", self.selected_uids, lang=self.lang) ) async def _on_back(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) new_view = ManageStackView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message( content=None, embed=_build_manage_embed(data, lang=self.lang) if data else None, view=new_view ) class RenameStackModal(discord.ui.Modal, title="Rename Stack"): """Modal for the stack leader to rename their stack.""" name = discord.ui.TextInput( label="Stack name", placeholder="e.g. Night Owls, Alpha Squad...", max_length=50, required=True, ) def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__() self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang self.title = t(lang, "stacks.rename_modal_title") self.name.label = t(lang, "stacks.rename_label") self.name.placeholder = t(lang, "stacks.rename_placeholder") async def on_submit(self, interaction: discord.Interaction) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return data["name"] = self.name.value.strip() await save_stack(data) await refresh_stack_message(self.public_stack_view) await interaction.response.send_message( t(self.lang, "stacks.stack_renamed", name=data['name']), ephemeral=True ) # ============================================================================ # MANAGE VIEW (ephemeral, leader-only) # ============================================================================ class ManageStackView(discord.ui.View): """Entry view with 4 navigation buttons for stack management. Buttons: Accept Members, Remove Members, Ping Members, Rename Stack. """ def __init__(self, guild_id: str, leader_discord_id: str, public_stack_view: "StackView", lang: str = "en"): super().__init__(timeout=300) self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.public_stack_view = public_stack_view self.lang = lang self.accept_btn.label = t(lang, "buttons.accept_members") self.remove_btn.label = t(lang, "buttons.remove_members") self.ping_btn.label = t(lang, "buttons.ping_members") self.rename_btn.label = t(lang, "buttons.rename_stack") @discord.ui.button(label="Accept Members", style=discord.ButtonStyle.green) async def accept_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return new_view = AcceptMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) @discord.ui.button(label="Remove Members", style=discord.ButtonStyle.red) async def remove_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return new_view = RemoveMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) @discord.ui.button(label="Ping Members", style=discord.ButtonStyle.blurple) async def ping_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return new_view = PingMembersView(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) await interaction.response.edit_message(embed=_build_manage_embed(data, lang=self.lang), view=new_view) @discord.ui.button(label="Rename Stack", style=discord.ButtonStyle.grey) async def rename_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.send_modal( RenameStackModal(self.guild_id, self.leader_discord_id, self.public_stack_view, lang=self.lang) ) # ============================================================================ # PUBLIC STACK VIEW # ============================================================================ class StackView(discord.ui.View): """Public persistent view attached to the stack embed message. Provides Join, Leave, Manage, and Disband buttons. Times out after 8 hours, at which point the stack is automatically cleaned up. """ def __init__(self, guild_id: str, leader_discord_id: str, lang: str = "en"): super().__init__(timeout=28800) # 8 hours self.guild_id = guild_id self.leader_discord_id = leader_discord_id self.lang = lang self.message: discord.Message | None = None self.join_btn.label = t(lang, "buttons.request_to_join") self.leave_btn.label = t(lang, "buttons.leave_withdraw") self.manage_btn.label = t(lang, "buttons.manage_stack") self.disband_btn.label = t(lang, "buttons.disband_stack") @discord.ui.button(label="Request to Join", style=discord.ButtonStyle.green) async def join_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: """Open the JoinStackModal for the user to submit a join application.""" data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message( t(self.lang, "stacks.no_longer_exists"), ephemeral=True ) return await interaction.response.send_modal( JoinStackModal(self.guild_id, self.leader_discord_id, self, lang=self.lang) ) @discord.ui.button(label="Leave / Withdraw", style=discord.ButtonStyle.grey) async def leave_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: """Remove the user from members or requesters, or prompt ownership transfer if leader.""" user_id = str(interaction.user.id) if user_id == self.leader_discord_id: data = await load_stack(self.guild_id, self.leader_discord_id) non_leader = [ m for m in (data.get("members", []) if data else []) if m["discord_id"] != self.leader_discord_id ] if not non_leader: await interaction.response.send_message( t(self.lang, "stacks.only_member_use_disband"), ephemeral=True, ) return await interaction.response.send_message( t(self.lang, "stacks.select_transfer_prompt"), view=TransferLeaveView(self.guild_id, self.leader_discord_id, self, lang=self.lang), ephemeral=True, ) return data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message( t(self.lang, "stacks.no_longer_exists"), ephemeral=True ) return if any(m["discord_id"] == user_id for m in data["members"]): data["members"] = [m for m in data["members"] if m["discord_id"] != user_id] await save_stack(data) await refresh_stack_message(self) await interaction.response.send_message(t(self.lang, "stacks.left_stack"), ephemeral=True) return if any(r["discord_id"] == user_id for r in data["requesters"]): data["requesters"] = [r for r in data["requesters"] if r["discord_id"] != user_id] await save_stack(data) await refresh_stack_message(self) await interaction.response.send_message( t(self.lang, "stacks.application_withdrawn"), ephemeral=True ) return await interaction.response.send_message( t(self.lang, "stacks.not_member_or_applicant"), ephemeral=True ) @discord.ui.button(label="Manage Stack ⚙️", style=discord.ButtonStyle.blurple) async def manage_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: """Open the ephemeral management panel (leader-only).""" if interaction.user.id != int(self.leader_discord_id): await interaction.response.send_message( t(self.lang, "stacks.leader_only_manage"), ephemeral=True ) return data = await load_stack(self.guild_id, self.leader_discord_id) if data is None: await interaction.response.send_message(t(self.lang, "stacks.stack_not_found"), ephemeral=True) return manage_view = ManageStackView(self.guild_id, self.leader_discord_id, self, lang=self.lang) await interaction.response.send_message( embed=_build_manage_embed(data, lang=self.lang), view=manage_view, ephemeral=True ) @discord.ui.button(label="Disband Stack", style=discord.ButtonStyle.red) async def disband_btn(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: """Show a disband confirmation prompt (leader-only).""" if interaction.user.id != int(self.leader_discord_id): await interaction.response.send_message( t(self.lang, "stacks.leader_only_disband"), ephemeral=True ) return await interaction.response.send_message( t(self.lang, "stacks.confirm_disband"), view=DisbandConfirmView(self, lang=self.lang), ephemeral=True, ) async def on_timeout(self) -> None: path = get_stack_path(self.guild_id, self.leader_discord_id) try: path.unlink(missing_ok=True) except Exception: pass if self.message is None: return try: embed = discord.Embed( title=t(self.lang, "stacks.expired_title"), description=t(self.lang, "stacks.expired_desc"), color=discord.Color.dark_grey(), ) embed.set_footer(text=DEFAULT_FOOTER_CAT) await self.message.edit(embed=embed, view=None) except Exception: pass # ============================================================================ # FORCE CREATE VIEW (ephemeral, used when duplicate stack exists) # ============================================================================ class ForceCreateView(discord.ui.View): """Shown when the user already has a stack JSON — lets them nuke it and start fresh.""" def __init__(self, guild_id: str, leader_id: str, nick: str, vehicle: str, channel_id: str, lang: str = "en"): super().__init__(timeout=30) self.guild_id = guild_id self.leader_id = leader_id self.nick = nick self.vehicle = vehicle self.channel_id = channel_id self.lang = lang self.confirm.label = t(lang, "buttons.force_disband_create") self.cancel.label = t(lang, "buttons.cancel") @discord.ui.button(label="Force Disband & Create New", style=discord.ButtonStyle.red) async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: get_stack_path(self.guild_id, self.leader_id).unlink(missing_ok=True) data = { "guild_id": self.guild_id, "leader_discord_id": self.leader_id, "leader_nick": self.nick, "leader_vehicle": self.vehicle, "channel_id": self.channel_id, "message_id": None, "name": None, "created_at": int(time.time()), "members": [ {"discord_id": self.leader_id, "nick": self.nick, "vehicle": self.vehicle} ], "requesters": [], } STACKS_DIR.mkdir(parents=True, exist_ok=True) await save_stack(data) view = StackView(self.guild_id, self.leader_id, lang=self.lang) embed = build_stack_embed(data, lang=self.lang) msg = await interaction.channel.send(embed=embed, view=view) # type: ignore[union-attr] view.message = msg data["message_id"] = str(msg.id) await save_stack(data) await interaction.response.edit_message( content=t(self.lang, "stacks.force_created"), view=None ) @discord.ui.button(label="Cancel", style=discord.ButtonStyle.grey) async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button) -> None: await interaction.response.edit_message(content=t(self.lang, "stacks.cancelled"), view=None) # ============================================================================ # COMMAND REGISTRATION # ============================================================================ def register_commands(bot: commands.Bot) -> None: """Register the /stack-create and /stack-manage slash commands on the bot. Args: bot: The Discord bot instance to register commands on. """ @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="stack-create", description=command_locale("Create a new player stack", "commands.stack_create.description")) @app_commands.describe(vehicle=command_locale("What vehicle will you start with?", "commands.stack_create.vehicle")) async def stack_create(interaction: discord.Interaction, vehicle: str) -> None: await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) guild_id = str(interaction.guild_id) lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en" leader_id = str(interaction.user.id) nick = interaction.user.display_name stack_path = get_stack_path(guild_id, leader_id) if stack_path.exists(): await interaction.followup.send( t(lang, "stacks.already_active_stack"), view=ForceCreateView(guild_id, leader_id, nick, vehicle.strip(), str(interaction.channel_id), lang=lang), ephemeral=True, ) return data = { "guild_id": guild_id, "leader_discord_id": leader_id, "leader_nick": nick, "leader_vehicle": vehicle.strip(), "channel_id": str(interaction.channel_id), "message_id": None, "name": None, "created_at": int(time.time()), "members": [ {"discord_id": leader_id, "nick": nick, "vehicle": vehicle.strip()} ], "requesters": [], } STACKS_DIR.mkdir(parents=True, exist_ok=True) await save_stack(data) view = StackView(guild_id, leader_id, lang=lang) embed = build_stack_embed(data, lang=lang) msg = await interaction.followup.send(embed=embed, view=view, wait=True) view.message = msg data["message_id"] = str(msg.id) await save_stack(data) @stack_create.error async def stack_create_error(interaction: discord.Interaction, error: Exception) -> None: await permission_fail(interaction, error) @is_blacklisted() @gate_entitle("standard") @bot.tree.command(name="stack-manage", description=command_locale("Re-post your active stack embed to this channel", "commands.stack_manage.description")) async def stack_manage(interaction: discord.Interaction) -> None: await collect_command_stats(interaction) await interaction.response.defer(ephemeral=False) guild_id = str(interaction.guild_id) lang = await guild_lang(interaction.guild_id) if interaction.guild_id else "en" leader_id = str(interaction.user.id) data = await load_stack(guild_id, leader_id) if data is None: await interaction.followup.send( t(lang, "stacks.no_active_stack"), ephemeral=True, ) return view = StackView(guild_id, leader_id, lang=lang) embed = build_stack_embed(data, lang=lang) msg = await interaction.followup.send(embed=embed, view=view, wait=True) view.message = msg data["message_id"] = str(msg.id) await save_stack(data) @stack_manage.error async def stack_manage_error(interaction: discord.Interaction, error: Exception) -> None: await permission_fail(interaction, error)