""" FractalCog -- Discord cog that exposes all slash commands for the ZAO Fractal voting system. This module is the primary entry-point for user and admin interactions. It registers slash commands (/zaofractal, /randomize, /endgroup, /status, etc.) and a full suite of admin-only commands for managing live fractal sessions. Architecture: FractalCog (this file) -- slash command handlers; orchestrates the flow FractalGroup (group.py) -- per-session state machine (voting rounds, results) views.py -- Discord UI components (buttons, modals) The cog inherits from BaseCog, which provides common helpers like ``check_voice_state`` and ``is_supreme_admin``. """ import discord from discord import app_commands from discord.ext import commands import logging import random import math from datetime import datetime from ..base import BaseCog from .views import MemberConfirmationView from config.config import INTROS_CHANNEL_ID class FractalCog(BaseCog): """Discord cog that registers and handles all ZAO Fractal slash commands. Responsible for: - Creating fractal groups from voice-channel members (/zaofractal) - Randomising waiting-room members into breakout rooms (/randomize) - Providing status, wallet, and admin management commands - Tracking active groups and daily creation counters per guild """ def __init__(self, bot): """Initialise the cog and its internal tracking structures. Args: bot: The Discord bot instance that owns this cog. """ super().__init__(bot) self.bot = bot self.logger = logging.getLogger('bot') # Maps Discord thread ID -> FractalGroup for every in-progress session self.active_groups = {} # Tracks how many groups have been created per guild per day, # keyed as guild_id -> {date_string: count} self.daily_counters = {} # ------------------------------------------------------------------ # Primary user-facing commands # ------------------------------------------------------------------ @app_commands.command( name="zaofractal", description="Create a new ZAO fractal voting group from your current voice channel" ) @app_commands.describe(name="Custom name for this fractal group (optional)") async def zaofractal(self, interaction: discord.Interaction, name: str = None): """Start the fractal creation flow for the invoking user's voice channel. Steps: 1. Verify the caller is in a voice channel (via BaseCog helper). 2. Look up each voice-channel member's wallet and intro status so the facilitator can see who is ready before confirming. 3. Present a MemberConfirmationView with Start / Modify buttons. The actual fractal session is not created here -- it is created in FractalNameModal.on_submit once the facilitator confirms. """ # Guard: Discord may fire the interaction twice in rare cases if interaction.response.is_done(): return try: await interaction.response.defer(ephemeral=True) except discord.NotFound: return except discord.InteractionResponded: pass # Ensure the caller is in a voice channel and collect its members voice_check = await self.check_voice_state(interaction.user) if not voice_check['success']: try: await interaction.followup.send(voice_check['message'], ephemeral=True) except (discord.NotFound, discord.HTTPException) as e: self.logger.warning(f"Followup failed, falling back to channel send: {e}") await interaction.channel.send(f"{interaction.user.mention} {voice_check['message']}") return members = voice_check['members'] custom_name = name # --- Pre-flight readiness checks for each member --- registry = getattr(self.bot, 'wallet_registry', None) self.logger.info(f"Wallet registry available: {registry is not None}") # Pull cached intro data from IntroCog so we can flag members # who haven't introduced themselves yet intro_cog = self.bot.get_cog('IntroCog') intro_cache = intro_cog.intro_cache if intro_cog else None member_status = [] without_wallet = [] without_intro = [] for member in members: wallet = registry.lookup(member) if registry else None has_intro = bool(intro_cache.get(member.id)) if intro_cache else None self.logger.info(f"Wallet lookup for {member.display_name} (id={member.id}, name={member.name}, global={member.global_name}): {wallet}") if not wallet: without_wallet.append(member) if not has_intro: without_intro.append(member) member_status.append((member, wallet, has_intro)) # --- Build a rich confirmation message showing readiness icons --- lines = [f"**Start fractal{f' ({custom_name})' if custom_name else ''}?**\n"] for member, wallet, has_intro in member_status: wallet_icon = "βœ…" if wallet else "❌" intro_icon = "πŸ“" if has_intro else "πŸ“­" issues = [] if not wallet: issues.append("no wallet") if not has_intro: issues.append("no intro") suffix = f" β€” {', '.join(issues)}" if issues else "" lines.append(f"{wallet_icon}{intro_icon} {member.mention}{suffix}") # Append aggregate warnings so the facilitator knows at a glance warnings = [] if without_wallet: warnings.append(f"⚠️ **{len(without_wallet)}** member(s) have no wallet. They should `/register` before results are submitted onchain.") if without_intro: warnings.append(f"πŸ“­ **{len(without_intro)}** member(s) have no intro. They should post in <#{INTROS_CHANNEL_ID}>.") if warnings: lines.append("") lines.extend(warnings) confirm_msg = "\n".join(lines) # Present the confirmation view; the facilitator clicks Start to proceed view = MemberConfirmationView(self, members, interaction.user) try: await interaction.followup.send(confirm_msg, view=view, ephemeral=True) except (discord.NotFound, discord.HTTPException) as e: # Interaction webhook may have expired; fall back to a channel message self.logger.warning(f"Followup failed, falling back to channel send: {e}") await interaction.channel.send( f"{interaction.user.mention} {confirm_msg}", view=view ) @app_commands.command( name="randomize", description="Split members from Fractal Waiting Room into fractal rooms (max 6 per room)" ) @app_commands.describe( facilitator_1="Facilitator for fractal-1", facilitator_2="Facilitator for fractal-2", facilitator_3="Facilitator for fractal-3", facilitator_4="Facilitator for fractal-4", facilitator_5="Facilitator for fractal-5", facilitator_6="Facilitator for fractal-6", ) async def randomize( self, interaction: discord.Interaction, facilitator_1: discord.Member = None, facilitator_2: discord.Member = None, facilitator_3: discord.Member = None, facilitator_4: discord.Member = None, facilitator_5: discord.Member = None, facilitator_6: discord.Member = None, ): """Randomly distribute waiting-room members into breakout voice channels. Admin-only. Finds all human members in the "Fractal Waiting Room" voice channel, shuffles them, and moves them into ``fractal-1``, ``fractal-2``, etc. Facilitators can be pre-assigned to specific rooms; everyone else is placed via greedy round-robin into the smallest group. Groups are capped at 6 members each. The number of breakout rooms is the greater of: - ceil(total_people / 6) -- enough rooms to fit everyone - the highest facilitator slot index -- so that all named facilitators have a room to go to """ await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return guild = interaction.guild # Map the six optional facilitator params into a dict keyed by room # index (0-based) for convenient lookup facilitator_params = [facilitator_1, facilitator_2, facilitator_3, facilitator_4, facilitator_5, facilitator_6] facilitators = {} # room index (0-based) -> member for i, fac in enumerate(facilitator_params): if fac is not None: facilitators[i] = fac # Locate the staging voice channel by name (case-insensitive) waiting_room = None for channel in guild.voice_channels: if "fractal waiting room" in channel.name.lower(): waiting_room = channel break if not waiting_room: await interaction.followup.send("❌ Could not find a **Fractal Waiting Room** voice channel.", ephemeral=True) return # Exclude bots from the participant list all_members = [m for m in waiting_room.members if not m.bot] # Facilitators are placed deterministically, so remove them from the # pool that gets shuffled facilitator_ids = {fac.id for fac in facilitators.values()} random_pool = [m for m in all_members if m.id not in facilitator_ids] # Sanity check: all named facilitators must actually be present waiting_ids = {m.id for m in all_members} not_in_room = [fac for fac in facilitators.values() if fac.id not in waiting_ids] if not_in_room: names = ", ".join(f.display_name for f in not_in_room) await interaction.followup.send( f"❌ These facilitators are not in the Fractal Waiting Room: **{names}**", ephemeral=True ) return total_people = len(all_members) if total_people < 2: await interaction.followup.send("❌ Need at least 2 members in the Fractal Waiting Room to randomize.", ephemeral=True) return # Compute room count: enough for 6-person cap AND enough for every # facilitator slot that was specified min_groups_for_people = math.ceil(total_people / 6) min_groups_for_facilitators = (max(facilitators.keys()) + 1) if facilitators else 0 num_groups = max(min_groups_for_people, min_groups_for_facilitators) # Verify that the required voice channels (fractal-1 .. fractal-N) exist fractal_rooms = [] missing_rooms = [] for i in range(1, num_groups + 1): room = None for channel in guild.voice_channels: if channel.name.lower() == f"fractal-{i}": room = channel break if room: fractal_rooms.append(room) else: missing_rooms.append(f"fractal-{i}") if missing_rooms: await interaction.followup.send( f"❌ Missing voice channels: **{', '.join(missing_rooms)}**\n" f"Please create these voice channels first.", ephemeral=True ) return # Seed each group with its pre-assigned facilitator groups = [[] for _ in range(num_groups)] group_facilitators = {} # room index -> facilitator member for room_idx, fac in facilitators.items(): groups[room_idx].append(fac) group_facilitators[room_idx] = fac # Shuffle the remaining pool to ensure fairness random.shuffle(random_pool) # Greedy fill: always place the next member in the smallest group for member in random_pool: min_idx = min(range(num_groups), key=lambda idx: len(groups[idx])) groups[min_idx].append(member) # Move every member to their target voice channel move_results = [] failed_moves = [] for group_idx, group_members in enumerate(groups): room = fractal_rooms[group_idx] fac = group_facilitators.get(group_idx) for member in group_members: try: await member.move_to(room) except discord.HTTPException as e: failed_moves.append((member, str(e))) member_names = ", ".join(m.display_name for m in group_members) fac_label = f" | Facilitator: **{fac.display_name}**" if fac else "" move_results.append(f"**{room.name}** ({len(group_members)}){fac_label}\n> {member_names}") # Report results to the admin msg = f"# Randomized {total_people} members into {num_groups} groups\n\n" msg += "\n\n".join(move_results) if failed_moves: msg += f"\n\n⚠️ Failed to move {len(failed_moves)} member(s):" for member, error in failed_moves: msg += f"\nβ€’ {member.display_name}: {error}" await interaction.followup.send(msg, ephemeral=True) @app_commands.command( name="endgroup", description="End an active fractal group (facilitator only)" ) async def end_group(self, interaction: discord.Interaction): """Allow the facilitator to gracefully end their fractal session early. Must be invoked inside the fractal's Discord thread. Only the designated facilitator may use it; admins should use ``/admin_end_fractal`` instead. """ await interaction.response.defer(ephemeral=True) # Must be called from within a thread, not a regular channel if not isinstance(interaction.channel, discord.Thread): await interaction.followup.send("❌ This command can only be used in a fractal group thread.", ephemeral=True) return group = self.active_groups.get(interaction.channel.id) if not group: await interaction.followup.send("❌ This thread is not an active fractal group.", ephemeral=True) return # Only the facilitator who created the session may end it if interaction.user.id != group.facilitator.id: await interaction.followup.send("❌ Only the group facilitator can end the fractal group.", ephemeral=True) return # end_fractal() posts results and cleans up active_groups internally; # the pop() is a defensive guard against partial cleanup await group.end_fractal() self.active_groups.pop(interaction.channel.id, None) await interaction.followup.send("βœ… Fractal group ended successfully.", ephemeral=True) @app_commands.command( name="status", description="Show the current status of an active fractal group" ) async def status(self, interaction: discord.Interaction): """Display a summary of the active fractal in the current thread. Shows facilitator, current level, member/candidate counts, votes cast, and any winners determined so far. """ try: await interaction.response.defer(ephemeral=True) except discord.NotFound: return except discord.InteractionResponded: pass # Check if in a fractal thread if not isinstance(interaction.channel, discord.Thread): await interaction.followup.send("❌ This command can only be used in a fractal group thread.", ephemeral=True) return # Check if this is an active fractal group group = self.active_groups.get(interaction.channel.id) if not group: await interaction.followup.send("❌ This thread is not an active fractal group.", ephemeral=True) return # Build status message status = f"# ZAO Fractal Status\n\n" status += f"**Group:** {interaction.channel.name}\n" status += f"**Facilitator:** {group.facilitator.mention}\n" status += f"**Current Level:** {group.current_level}\n" status += f"**Members:** {len(group.members)}\n" status += f"**Active Candidates:** {len(group.active_candidates)}\n" status += f"**Votes Cast:** {len(group.votes)}/{len(group.members)}\n\n" # Winners so far if group.winners: status += "**Winners:**\n" for level, winner in sorted(group.winners.items(), reverse=True): status += f"Level {level}: {winner.mention}\n" await interaction.followup.send(status, ephemeral=True) @app_commands.command( name="groupwallets", description="Show wallet addresses for all members in the current fractal group" ) async def groupwallets(self, interaction: discord.Interaction): """Show each group member's registered wallet address. Useful before finalising a fractal so the facilitator can verify everyone's wallet is linked for the onchain submission. """ try: await interaction.response.defer(ephemeral=True) except discord.NotFound: return except discord.InteractionResponded: pass # Check if in a fractal thread if not isinstance(interaction.channel, discord.Thread): await interaction.followup.send("❌ Use this in a fractal group thread.", ephemeral=True) return group = self.active_groups.get(interaction.channel.id) if not group: await interaction.followup.send("❌ This thread is not an active fractal group.", ephemeral=True) return registry = getattr(self.bot, 'wallet_registry', None) if not registry: await interaction.followup.send("❌ Wallet registry not available.", ephemeral=True) return msg = f"# πŸ”— Group Wallets\n\n" missing = [] for member in group.members: wallet = registry.lookup(member) if wallet: short = f"{wallet[:6]}...{wallet[-4:]}" msg += f"βœ… **{member.display_name}** β†’ `{wallet}`\n" else: msg += f"❌ **{member.display_name}** β†’ No wallet registered\n" missing.append(member.display_name) if missing: msg += f"\n⚠️ **{len(missing)} member(s) missing wallets.** They can use `/register 0xAddress` to link." else: msg += f"\nβœ… All {len(group.members)} members have wallets linked!" await interaction.followup.send(msg, ephemeral=True) # ------------------------------------------------------------------ # Admin commands -- all require the "Supreme Admin" role # ------------------------------------------------------------------ @app_commands.command( name="admin_end_fractal", description="[ADMIN] Force end any active fractal group" ) @app_commands.describe(thread_id="ID of the thread to end (optional)") async def admin_end_fractal(self, interaction: discord.Interaction, thread_id: str = None): """Force-end a specific fractal by thread ID, or list all active fractals. When called without ``thread_id``, prints all running fractals so the admin can pick which one to terminate. """ await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return if thread_id: # End specific fractal try: thread_id_int = int(thread_id) if thread_id_int in self.active_groups: group = self.active_groups[thread_id_int] await group.end_fractal() await interaction.followup.send(f"βœ… Ended fractal in {group.thread.mention}", ephemeral=True) else: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) else: # Show list of active fractals to choose from if not self.active_groups: await interaction.followup.send("❌ No active fractals to end.", ephemeral=True) return status = "**Active Fractals:**\n" for thread_id, group in self.active_groups.items(): status += f"β€’ {group.thread.mention} (ID: {thread_id}) - Level {group.current_level}\n" status += "\nUse `/admin_end_fractal thread_id:` to end a specific one." await interaction.followup.send(status, ephemeral=True) @app_commands.command( name="admin_list_fractals", description="[ADMIN] List all active fractal groups" ) async def admin_list_fractals(self, interaction: discord.Interaction): """List every in-progress fractal on this server with key metadata.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return if not self.active_groups: await interaction.followup.send("βœ… No active fractal groups.", ephemeral=True) return status = f"**Active Fractal Groups ({len(self.active_groups)}):**\n\n" for thread_id, group in self.active_groups.items(): status += f"**{group.thread.name}**\n" status += f"β€’ Thread: {group.thread.mention}\n" status += f"β€’ Facilitator: {group.facilitator.mention}\n" status += f"β€’ Current Level: {group.current_level}\n" status += f"β€’ Members: {len(group.members)}\n" status += f"β€’ Active Candidates: {len(group.active_candidates)}\n" status += f"β€’ Votes Cast: {len(group.votes)}\n\n" await interaction.followup.send(status, ephemeral=True) @app_commands.command( name="admin_cleanup", description="[ADMIN] Clean up old/stuck fractal groups" ) async def admin_cleanup(self, interaction: discord.Interaction): """Remove stale entries from active_groups whose threads no longer exist or are archived.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return cleaned_count = 0 to_remove = [] for thread_id, group in self.active_groups.items(): try: # Check if thread still exists and is accessible thread = self.bot.get_channel(thread_id) if not thread or thread.archived: to_remove.append(thread_id) cleaned_count += 1 except Exception as e: self.logger.warning(f"Error checking thread {thread_id} during cleanup: {e}") to_remove.append(thread_id) cleaned_count += 1 # Remove invalid groups for thread_id in to_remove: del self.active_groups[thread_id] await interaction.followup.send( f"βœ… Cleanup complete. Removed {cleaned_count} inactive fractal groups.", ephemeral=True ) # --- Round progression overrides --- @app_commands.command( name="admin_force_round", description="[ADMIN] Skip current voting and move to next level" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_force_round(self, interaction: discord.Interaction, thread_id: str): """Skip the current round by selecting the leading candidate as winner. If no votes have been cast, a random candidate is chosen. Ties are broken randomly. """ await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] # Find candidate with most votes or pick randomly if tie vote_counts = {} for candidate_id in group.votes.values(): vote_counts[candidate_id] = vote_counts.get(candidate_id, 0) + 1 if vote_counts: max_votes = max(vote_counts.values()) winners = [cid for cid, count in vote_counts.items() if count == max_votes] winner_id = winners[0] if len(winners) == 1 else random.choice(winners) winner = discord.utils.get(group.active_candidates, id=winner_id) else: # No votes cast, pick random candidate winner = random.choice(group.active_candidates) await group.thread.send(f"⚑ **ADMIN OVERRIDE:** Forcing round completion. Winner: {winner.mention}") await group.start_new_round(winner) await interaction.followup.send(f"βœ… Forced round completion in {group.thread.mention}. Winner: {winner.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error forcing round: {str(e)}", ephemeral=True) @app_commands.command( name="admin_reset_votes", description="[ADMIN] Clear all votes in current round" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_reset_votes(self, interaction: discord.Interaction, thread_id: str): """Clear all votes in the current round so members can re-vote from scratch.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] old_vote_count = len(group.votes) group.votes = {} await group.thread.send(f"⚑ **ADMIN RESET:** All votes cleared. Voting restarted for Level {group.current_level}.") await interaction.followup.send(f"βœ… Reset {old_vote_count} votes in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error resetting votes: {str(e)}", ephemeral=True) @app_commands.command( name="admin_declare_winner", description="[ADMIN] Manually declare a round winner" ) @app_commands.describe(thread_id="ID of the fractal thread", user="User to declare as winner") async def admin_declare_winner(self, interaction: discord.Interaction, thread_id: str, user: discord.Member): """Bypass voting and declare a specific member as the current-round winner.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] if user not in group.active_candidates: await interaction.followup.send(f"❌ {user.mention} is not an active candidate in this fractal.", ephemeral=True) return await group.thread.send(f"⚑ **ADMIN DECLARATION:** {user.mention} declared winner of Level {group.current_level}!") await group.start_new_round(user) await interaction.followup.send(f"βœ… Declared {user.mention} as winner in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error declaring winner: {str(e)}", ephemeral=True) # --- Member management overrides --- @app_commands.command( name="admin_add_member", description="[ADMIN] Add someone to an active fractal" ) @app_commands.describe(thread_id="ID of the fractal thread", user="User to add to the fractal") async def admin_add_member(self, interaction: discord.Interaction, thread_id: str, user: discord.Member): """Hot-add a user to a running fractal (adds to members, candidates, and thread).""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] if user in group.members: await interaction.followup.send(f"❌ {user.mention} is already in this fractal.", ephemeral=True) return # Add to members and active candidates group.members.append(user) group.active_candidates.append(user) # Add to thread try: await group.thread.add_user(user) except discord.HTTPException: pass await group.thread.send(f"⚑ **ADMIN ADD:** {user.mention} has been added to the fractal!") await interaction.followup.send(f"βœ… Added {user.mention} to {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error adding member: {str(e)}", ephemeral=True) @app_commands.command( name="admin_remove_member", description="[ADMIN] Remove someone from active fractal" ) @app_commands.describe(thread_id="ID of the fractal thread", user="User to remove from the fractal") async def admin_remove_member(self, interaction: discord.Interaction, thread_id: str, user: discord.Member): """Remove a user from a running fractal, revoking their vote if cast.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] if user not in group.members: await interaction.followup.send(f"❌ {user.mention} is not in this fractal.", ephemeral=True) return # Remove from members and active candidates if user in group.members: group.members.remove(user) if user in group.active_candidates: group.active_candidates.remove(user) # Remove their vote if they had one if user.id in group.votes: del group.votes[user.id] await group.thread.send(f"⚑ **ADMIN REMOVE:** {user.mention} has been removed from the fractal.") await interaction.followup.send(f"βœ… Removed {user.mention} from {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error removing member: {str(e)}", ephemeral=True) @app_commands.command( name="admin_change_facilitator", description="[ADMIN] Transfer facilitator role to another member" ) @app_commands.describe(thread_id="ID of the fractal thread", user="New facilitator") async def admin_change_facilitator(self, interaction: discord.Interaction, thread_id: str, user: discord.Member): """Transfer the facilitator role to another member mid-session.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] old_facilitator = group.facilitator if user not in group.members: await interaction.followup.send(f"❌ {user.mention} must be a member of the fractal to become facilitator.", ephemeral=True) return group.facilitator = user await group.thread.send(f"⚑ **FACILITATOR CHANGE:** {old_facilitator.mention} β†’ {user.mention}") await interaction.followup.send(f"βœ… Changed facilitator from {old_facilitator.mention} to {user.mention} in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error changing facilitator: {str(e)}", ephemeral=True) # --- Pause / resume / restart controls --- @app_commands.command( name="admin_pause_fractal", description="[ADMIN] Temporarily pause voting in a fractal" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_pause_fractal(self, interaction: discord.Interaction, thread_id: str): """Suspend voting in a fractal. Votes submitted while paused are rejected. Sets a ``paused`` flag on the FractalGroup that ``process_vote`` checks. """ await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] # Add paused flag to group if not hasattr(group, 'paused'): group.paused = False if group.paused: await interaction.followup.send("❌ Fractal is already paused.", ephemeral=True) return group.paused = True await group.thread.send("⏸️ **FRACTAL PAUSED** by admin. Voting is temporarily suspended.") await interaction.followup.send(f"βœ… Paused fractal in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error pausing fractal: {str(e)}", ephemeral=True) @app_commands.command( name="admin_resume_fractal", description="[ADMIN] Resume paused fractal voting" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_resume_fractal(self, interaction: discord.Interaction, thread_id: str): """Un-pause a previously paused fractal so voting can continue.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] if not hasattr(group, 'paused') or not group.paused: await interaction.followup.send("❌ Fractal is not paused.", ephemeral=True) return group.paused = False await group.thread.send("▢️ **FRACTAL RESUMED** by admin. Voting continues!") await interaction.followup.send(f"βœ… Resumed fractal in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error resuming fractal: {str(e)}", ephemeral=True) @app_commands.command( name="admin_restart_fractal", description="[ADMIN] Restart fractal from beginning with same members" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_restart_fractal(self, interaction: discord.Interaction, thread_id: str): """Reset a fractal back to Level 6 with the same members, discarding all progress.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] # Reset fractal state group.current_level = 6 group.votes = {} group.winners = {} group.active_candidates = group.members.copy() if hasattr(group, 'paused'): group.paused = False await group.thread.send("πŸ”„ **FRACTAL RESTARTED** by admin. Starting fresh from Level 6!") # Start new round await group.start_new_round() await interaction.followup.send(f"βœ… Restarted fractal in {group.thread.mention}", ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error restarting fractal: {str(e)}", ephemeral=True) # --- Monitoring and data export --- @app_commands.command( name="admin_fractal_stats", description="[ADMIN] Detailed stats for a specific fractal group" ) @app_commands.describe(thread_id="ID of the fractal thread") async def admin_fractal_stats(self, interaction: discord.Interaction, thread_id: str): """Show granular stats for one fractal: vote distribution, winners so far, pause state.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return group = self.active_groups[thread_id_int] # Calculate detailed stats total_members = len(group.members) active_candidates = len(group.active_candidates) votes_cast = len(group.votes) vote_percentage = (votes_cast / total_members * 100) if total_members > 0 else 0 # Vote distribution vote_counts = {} for candidate_id in group.votes.values(): candidate = discord.utils.get(group.active_candidates, id=candidate_id) if candidate: vote_counts[candidate.display_name] = vote_counts.get(candidate.display_name, 0) + 1 stats = f"# πŸ“Š **Detailed Fractal Stats**\n\n" stats += f"**Thread:** {group.thread.mention}\n" stats += f"**Facilitator:** {group.facilitator.mention}\n" stats += f"**Current Level:** {group.current_level}\n" stats += f"**Status:** {'⏸️ Paused' if hasattr(group, 'paused') and group.paused else '▢️ Active'}\n\n" stats += f"**Members:** {total_members}\n" stats += f"**Active Candidates:** {active_candidates}\n" stats += f"**Votes Cast:** {votes_cast}/{total_members} ({vote_percentage:.1f}%)\n" stats += f"**Votes Needed to Win:** {group.get_vote_threshold()}\n\n" if vote_counts: stats += "**Current Vote Distribution:**\n" for candidate, count in sorted(vote_counts.items(), key=lambda x: x[1], reverse=True): stats += f"β€’ {candidate}: {count} votes\n" stats += "\n" if group.winners: stats += "**Winners So Far:**\n" for level in sorted(group.winners.keys(), reverse=True): winner = group.winners[level] stats += f"β€’ Level {level}: {winner.display_name}\n" await interaction.followup.send(stats, ephemeral=True) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error getting fractal stats: {str(e)}", ephemeral=True) @app_commands.command( name="admin_server_stats", description="[ADMIN] Overall server fractal statistics" ) async def admin_server_stats(self, interaction: discord.Interaction): """Aggregate statistics across all active fractals in this guild.""" await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: guild_id = interaction.guild.id # Count active fractals for this server server_fractals = [group for group in self.active_groups.values() if group.thread.guild.id == guild_id] total_active = len(server_fractals) total_participants = sum(len(group.members) for group in server_fractals) total_votes_cast = sum(len(group.votes) for group in server_fractals) # Daily counter stats today = datetime.now().strftime("%b %d, %Y") daily_count = 0 if guild_id in self.daily_counters and today in self.daily_counters[guild_id]: daily_count = self.daily_counters[guild_id][today] stats = f"# πŸ“ˆ **Server Fractal Statistics**\n\n" stats += f"**Server:** {interaction.guild.name}\n" stats += f"**Active Fractals:** {total_active}\n" stats += f"**Total Participants:** {total_participants}\n" stats += f"**Total Votes Cast:** {total_votes_cast}\n" stats += f"**Groups Created Today:** {daily_count}\n\n" if server_fractals: stats += "**Active Groups:**\n" for group in server_fractals: status = "⏸️ Paused" if hasattr(group, 'paused') and group.paused else "▢️ Active" stats += f"β€’ {group.thread.name} - Level {group.current_level} ({status})\n" else: stats += "No active fractals currently running.\n" await interaction.followup.send(stats, ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error getting server stats: {str(e)}", ephemeral=True) @app_commands.command( name="admin_export_data", description="[ADMIN] Export fractal data for analysis" ) @app_commands.describe(thread_id="ID of the fractal thread (optional - exports all if not specified)") async def admin_export_data(self, interaction: discord.Interaction, thread_id: str = None): """Export fractal state as a JSON file for offline analysis. If ``thread_id`` is provided, exports only that fractal; otherwise exports every active fractal in the current guild. """ await interaction.response.defer(ephemeral=True) if not self.is_supreme_admin(interaction.user): await interaction.followup.send("❌ You need the **Supreme Admin** role to use this command.", ephemeral=True) return try: import json from datetime import datetime export_data = { "export_timestamp": datetime.now().isoformat(), "server_id": interaction.guild.id, "server_name": interaction.guild.name, "fractals": [] } if thread_id: # Export specific fractal thread_id_int = int(thread_id) if thread_id_int not in self.active_groups: await interaction.followup.send("❌ No active fractal found with that thread ID.", ephemeral=True) return groups_to_export = [self.active_groups[thread_id_int]] else: # Export all fractals for this server groups_to_export = [group for group in self.active_groups.values() if group.thread.guild.id == interaction.guild.id] for group in groups_to_export: fractal_data = { "thread_id": group.thread.id, "thread_name": group.thread.name, "facilitator": { "id": group.facilitator.id, "name": group.facilitator.display_name }, "current_level": group.current_level, "paused": hasattr(group, 'paused') and group.paused, "members": [{"id": m.id, "name": m.display_name} for m in group.members], "active_candidates": [{"id": m.id, "name": m.display_name} for m in group.active_candidates], "votes": {str(voter_id): candidate_id for voter_id, candidate_id in group.votes.items()}, "winners": {str(level): {"id": winner.id, "name": winner.display_name} for level, winner in group.winners.items()} } export_data["fractals"].append(fractal_data) # Create JSON file content json_content = json.dumps(export_data, indent=2) # Create file and send import io file_buffer = io.StringIO(json_content) file = discord.File(file_buffer, filename=f"fractal_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json") await interaction.followup.send( f"πŸ“ **Data Export Complete**\n" f"Exported {len(export_data['fractals'])} fractal(s) from {interaction.guild.name}", file=file, ephemeral=True ) except ValueError: await interaction.followup.send("❌ Invalid thread ID format.", ephemeral=True) except Exception as e: await interaction.followup.send(f"❌ Error exporting data: {str(e)}", ephemeral=True)