discordbot/cogs/role_selector_cog.py
2025-04-25 19:33:08 -06:00

514 lines
30 KiB
Python

import discord
from discord.ext import commands
from discord.ui import View, Select, select
import json
import os
from typing import List, Dict, Optional, Set, Tuple
import asyncio # Added for sleep
# Role structure expected (based on role_creator_cog)
# Using original category names from role_creator_cog as keys
EXPECTED_ROLES: Dict[str, List[str]] = {
"Colors": ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Black", "White"],
"Regions": ["NA East", "NA West", "EU", "Asia", "Oceania", "South America"],
"Pronouns": ["He/Him", "She/Her", "They/Them", "Ask Pronouns"],
"Interests": ["Art", "Music", "Movies", "Books", "Technology", "Science", "History", "Food", "Programming", "Anime", "Photography", "Travel", "Writing", "Cooking", "Fitness", "Nature", "Gaming", "Philosophy", "Psychology", "Design", "Machine Learning", "Cryptocurrency", "Astronomy", "Mythology", "Languages", "Architecture", "DIY Projects", "Hiking", "Streaming", "Virtual Reality", "Coding Challenges", "Board Games", "Meditation", "Urban Exploration", "Tattoo Art", "Comics", "Robotics", "3D Modeling", "Podcasts"],
"Gaming Platforms": ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"],
"Favorite Vocaloids": ["Hatsune Miku", "Kasane Teto", "Akita Neru", "Kagamine Rin", "Kagamine Len", "Megurine Luka", "Kaito", "Meiko", "Gumi", "Kaai Yuki", "Yowane Haku"],
"Notifications": ["Announcements"]
}
# Mapping creator categories to selector categories (for single-choice logic etc.)
# and providing display names/embed titles
CATEGORY_DETAILS = {
"Colors": {"selector_category": "color", "title": "🎨 Color Roles", "description": "Choose your favorite color role.", "color": discord.Color.green(), "max_values": 1},
"Regions": {"selector_category": "region", "title": "🌍 Region Roles", "description": "Select your region.", "color": discord.Color.orange(), "max_values": 1},
"Pronouns": {"selector_category": "name", "title": "📛 Pronoun Roles", "description": "Select your pronoun roles.", "color": discord.Color.blue(), "max_values": 4}, # Allow multiple pronouns
"Interests": {"selector_category": "interests", "title": "💡 Interests", "description": "Select your interests.", "color": discord.Color.purple(), "max_values": 16}, # Allow multiple (Increased max_values again)
"Gaming Platforms": {"selector_category": "gaming", "title": "🎮 Gaming Platforms", "description": "Select your gaming platforms.", "color": discord.Color.dark_grey(), "max_values": 5}, # Allow multiple
"Favorite Vocaloids": {"selector_category": "vocaloid", "title": "🎤 Favorite Vocaloids", "description": "Select your favorite Vocaloids.", "color": discord.Color.teal(), "max_values": 10}, # Allow multiple
"Notifications": {"selector_category": "notifications", "title": "🔔 Notifications", "description": "Opt-in for notifications.", "color": discord.Color.light_grey(), "max_values": 1} # Allow multiple (or single if only one role)
}
# --- Persistent View Definition ---
class RoleSelectorView(View):
def __init__(self, category_roles: List[discord.Role], selector_category_name: str, max_values: int = 1):
super().__init__(timeout=None)
self.category_role_ids: Set[int] = {role.id for role in category_roles}
self.selector_category_name = selector_category_name
self.custom_id = f"persistent_role_select_view_{selector_category_name}"
self.select_chunk_map: Dict[str, Set[int]] = {} # Map custom_id to role IDs in that chunk
# Split roles into chunks of 25 for multiple select menus if needed
self.role_chunks = [category_roles[i:i + 25] for i in range(0, len(category_roles), 25)]
num_chunks = len(self.role_chunks)
# Ensure total max_values doesn't exceed the total number of roles
total_max_values = min(max_values, len(category_roles))
# For multi-select, min_values is typically 0 unless explicitly required otherwise
# For single-select categories, min_values should be 0 to allow deselecting by choosing nothing
# Note: Discord UI might enforce min_values=1 if max_values=1. Let's keep min_values=0 for flexibility.
actual_min_values = 0
for i, chunk in enumerate(self.role_chunks):
options = [discord.SelectOption(label=role.name, value=str(role.id)) for role in chunk]
chunk_role_ids = {role.id for role in chunk}
if not options:
continue
# Determine max_values for this specific select menu
# If multiple selects, allow selecting up to total_max_values across all of them.
# Each individual select menu still has a max_values limit of 25.
chunk_max_values = min(total_max_values, len(options)) # Allow selecting up to the total allowed, but capped by options in this chunk
placeholder = f"Select {selector_category_name} role(s)..."
if num_chunks > 1:
placeholder = f"Select {selector_category_name} role(s) ({i+1}/{num_chunks})..."
# Custom ID needs to be unique per select menu but linkable to the category
select_custom_id = f"role_select_dropdown_{selector_category_name}_{i}"
self.select_chunk_map[select_custom_id] = chunk_role_ids # Store mapping
select_component = Select(
placeholder=placeholder,
min_values=actual_min_values, # Allow selecting zero from any individual dropdown
max_values=chunk_max_values, # Max selectable from *this* dropdown
options=options,
custom_id=select_custom_id
)
select_component.callback = self.select_callback
self.add_item(select_component)
async def select_callback(self, interaction: discord.Interaction):
# Callback logic remains largely the same, but needs to handle potentially
# Callback logic needs to handle selections from one dropdown without
# affecting selections made via other dropdowns in the same view/category.
await interaction.response.defer(ephemeral=True, thinking=True)
member = interaction.user
guild = interaction.guild
if not isinstance(member, discord.Member) or not guild:
await interaction.followup.send("This interaction must be used within a server.", ephemeral=True)
return
# --- Identify interacted dropdown and its roles ---
interacted_custom_id = interaction.data['custom_id']
# Find the corresponding chunk role IDs using the stored map
interacted_chunk_role_ids: Set[int] = set()
if hasattr(self, 'select_chunk_map') and interacted_custom_id in self.select_chunk_map:
interacted_chunk_role_ids = self.select_chunk_map[interacted_custom_id]
else:
# Fallback or error handling if map isn't populated (shouldn't happen in normal flow)
print(f"Warning: Could not find chunk map for custom_id {interacted_custom_id} in view {self.custom_id}")
# Attempt to find the component and its options as a less reliable fallback
for component in self.children:
if isinstance(component, Select) and component.custom_id == interacted_custom_id:
interacted_chunk_role_ids = {int(opt.value) for opt in component.options}
break
if not interacted_chunk_role_ids:
await interaction.followup.send("An internal error occurred trying to identify the roles for this dropdown.", ephemeral=True)
return
selected_values = interaction.data.get('values', [])
current_selector_category = self.selector_category_name
# --- Calculate changes based on interaction ---
selected_role_ids_from_interaction = {int(value) for value in selected_values}
# Get all roles the member currently has within this entire category
member_category_role_ids = {role.id for role in member.roles if role.id in self.category_role_ids}
# Roles to add are those selected in this interaction that the member doesn't already have
roles_to_add_ids = selected_role_ids_from_interaction - member_category_role_ids
# Roles to remove are those from *this specific dropdown's chunk* that the member *had*, but are *no longer selected* in this interaction.
member_roles_in_interacted_chunk = member_category_role_ids.intersection(interacted_chunk_role_ids)
roles_to_remove_ids = member_roles_in_interacted_chunk - selected_role_ids_from_interaction
# --- Single-choice category handling ---
is_single_choice = current_selector_category in ['color', 'region', 'notifications'] # Add more if needed
if is_single_choice and roles_to_add_ids:
# Ensure only one role is being added
if len(roles_to_add_ids) > 1:
await interaction.followup.send(f"Error: Cannot select multiple roles for the '{current_selector_category}' category.", ephemeral=True)
return # Stop processing
role_to_add_id = list(roles_to_add_ids)[0]
# Identify all other roles in the category the member currently has (excluding the one being added)
other_member_roles_in_category = member_category_role_ids - {role_to_add_id}
# Add these other roles to the removal set
roles_to_remove_ids.update(other_member_roles_in_category)
# Ensure only the single selected role is in the add set
roles_to_add_ids = {role_to_add_id}
# --- Convert IDs to Role objects ---
roles_to_add = {guild.get_role(role_id) for role_id in roles_to_add_ids if guild.get_role(role_id)}
roles_to_remove = {guild.get_role(role_id) for role_id in roles_to_remove_ids if guild.get_role(role_id)}
# --- Apply changes and provide feedback ---
added_names = []
removed_names = []
error_messages = []
try:
# Perform removals first
if roles_to_remove:
await member.remove_roles(*roles_to_remove, reason=f"Deselected/changed via {current_selector_category} role selector ({interacted_custom_id})")
removed_names = [r.name for r in roles_to_remove if r]
# Then perform additions
if roles_to_add:
await member.add_roles(*roles_to_add, reason=f"Selected via {current_selector_category} role selector ({interacted_custom_id})")
added_names = [r.name for r in roles_to_add if r]
# Construct feedback message
if added_names or removed_names:
feedback = "Your roles have been updated!"
if added_names:
feedback += f"\n+ Added: {', '.join(added_names)}"
if removed_names:
feedback += f"\n- Removed: {', '.join(removed_names)}"
elif selected_values: # Roles were selected, but no changes needed (already had them)
feedback = f"No changes needed for the roles selected in this dropdown."
else: # No roles selected in this interaction
if member_roles_in_interacted_chunk: # Had roles from this chunk, now removed
feedback = f"Roles deselected from this dropdown."
else: # Had no roles from this chunk, selected none
feedback = f"No roles selected in this dropdown."
await interaction.followup.send(feedback, ephemeral=True)
except discord.Forbidden:
error_messages.append("I don't have permission to manage roles.")
except discord.HTTPException as e:
error_messages.append(f"An error occurred while updating roles: {e}")
except Exception as e:
error_messages.append(f"An unexpected error occurred: {e}")
print(f"Error in role selector callback: {e}")
if error_messages:
await interaction.followup.send("\n".join(error_messages), ephemeral=True)
class RoleSelectorCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.bot.loop.create_task(self.register_persistent_views())
def _get_guild_roles_by_name(self, guild: discord.Guild) -> Dict[str, discord.Role]:
return {role.name.lower(): role for role in guild.roles}
def _get_dynamic_roles_per_category(self, guild: discord.Guild) -> Dict[str, List[discord.Role]]:
"""Dynamically fetches roles and groups them by the original creator category."""
guild_roles_map = self._get_guild_roles_by_name(guild)
categorized_roles: Dict[str, List[discord.Role]] = {cat: [] for cat in EXPECTED_ROLES.keys()}
missing_roles = []
for creator_category, role_names in EXPECTED_ROLES.items():
for role_name in role_names:
role = guild_roles_map.get(role_name.lower())
if role:
categorized_roles[creator_category].append(role)
else:
missing_roles.append(f"'{role_name}' (Category: {creator_category})")
if missing_roles:
print(f"Warning: Roles not found in guild '{guild.name}' ({guild.id}): {', '.join(missing_roles)}")
# Sort roles within each category alphabetically by name for consistent order
for category in categorized_roles:
categorized_roles[category].sort(key=lambda r: r.name)
return categorized_roles
async def register_persistent_views(self):
"""Registers persistent views dynamically for each category."""
await self.bot.wait_until_ready()
print("RoleSelectorCog: Registering persistent views...")
registered_count = 0
guild_count = 0
for guild in self.bot.guilds:
guild_count += 1
print(f"Processing guild for view registration: {guild.name} ({guild.id})")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
try:
# Register a view for this specific category
self.bot.add_view(RoleSelectorView(role_list, selector_category, max_values=max_values))
registered_count += 1
except Exception as e:
print(f" - Error registering view for '{creator_category}' in guild {guild.id}: {e}")
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f" - No roles found for category '{creator_category}' in guild {guild.id}, skipping view registration.")
elif creator_category not in CATEGORY_DETAILS:
print(f" - Warning: Category '{creator_category}' found in EXPECTED_ROLES but not in CATEGORY_DETAILS. Cannot register view.")
print(f"RoleSelectorCog: Finished registering {registered_count} persistent views across {guild_count} guild(s).")
@commands.command(name="create_role_embeds")
@commands.is_owner()
async def create_role_embeds(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Creates embeds with persistent dropdowns for each role category. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command can only be used in a server.")
return
initial_message = await ctx.send(f"Fetching roles and creating embeds in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
if not any(roles_by_creator_category.values()):
await initial_message.edit(content="No roles matching the expected names were found in this server. Please run the `create_roles` command first.")
return
sent_messages = 0
# --- Create Embeds and attach Persistent Views for each category ---
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
embed = discord.Embed(
title=details["title"],
description=details["description"],
color=details["color"]
)
# Create a new view instance for sending
view = RoleSelectorView(role_list, selector_category, max_values=max_values)
try:
await target_channel.send(embed=embed, view=view)
sent_messages += 1
except discord.Forbidden:
await ctx.send(f"Error: Missing permissions to send messages in {target_channel.mention}.")
await initial_message.delete() # Clean up initial message
return
except discord.HTTPException as e:
await ctx.send(f"Error sending embed for '{creator_category}': {e}")
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f"Skipping embed for empty category '{creator_category}' in guild {guild.id}")
if sent_messages > 0:
await initial_message.edit(content=f"Created {sent_messages} role selection embed(s) in {target_channel.mention} successfully!")
else:
await initial_message.edit(content=f"No roles found for any category to create embeds in {target_channel.mention}.")
@commands.command(name="update_role_selectors")
@commands.is_owner()
async def update_role_selectors(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Updates existing role selector messages in a channel with the current roles. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command must be used within a server.")
return
await ctx.send(f"Starting update process for role selectors in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
updated_messages = 0
checked_messages = 0
errors = 0
try:
async for message in target_channel.history(limit=200): # Check recent messages
checked_messages += 1
if message.author == self.bot.user and message.embeds and message.components:
# Check if the message has a view with a select menu matching our pattern
view_component = message.components[0] # Assuming the view is the first component row
if not isinstance(view_component, discord.ActionRow) or not view_component.children:
continue
first_item = view_component.children[0]
if isinstance(first_item, discord.ui.Select) and first_item.custom_id and first_item.custom_id.startswith("role_select_dropdown_"):
selector_category_name = first_item.custom_id.split("role_select_dropdown_")[1]
# Find the original creator category based on the selector category name
creator_category = None
for cat, details in CATEGORY_DETAILS.items():
if details["selector_category"] == selector_category_name:
creator_category = cat
break
if creator_category and creator_category in roles_by_creator_category:
current_roles = roles_by_creator_category[creator_category]
if not current_roles:
print(f"Skipping update for {selector_category_name} in message {message.id} - no roles found for this category anymore.")
continue # Skip if no roles exist for this category now
details = CATEGORY_DETAILS[creator_category]
max_values = details["max_values"]
# Create a new view with the updated roles
new_view = RoleSelectorView(current_roles, selector_category_name, max_values=max_values)
# Check if the options or max_values actually changed to avoid unnecessary edits
select_in_old_message = first_item
select_in_new_view = new_view.children[0] if new_view.children and isinstance(new_view.children[0], discord.ui.Select) else None
if select_in_new_view:
old_options = {(opt.label, str(opt.value)) for opt in select_in_old_message.options}
new_options = {(opt.label, str(opt.value)) for opt in select_in_new_view.options}
old_max_values = select_in_old_message.max_values
new_max_values = select_in_new_view.max_values
if old_options != new_options or old_max_values != new_max_values:
try:
await message.edit(view=new_view)
print(f"Updated role selector for '{selector_category_name}' in message {message.id} (Options changed: {old_options != new_options}, Max values changed: {old_max_values != new_max_values})")
updated_messages += 1
except discord.Forbidden:
print(f"Error: Missing permissions to edit message {message.id} in {target_channel.name}")
errors += 1
except discord.HTTPException as e:
print(f"Error: Failed to edit message {message.id}: {e}")
errors += 1
except Exception as e:
print(f"Unexpected error editing message {message.id}: {e}")
errors += 1
else:
print(f"Skipping update for {selector_category_name} in message {message.id} - options and max_values unchanged.")
else:
print(f"Error: Could not find Select component in the newly generated view for category '{selector_category_name}'. Skipping message {message.id}.")
# else: # Debugging if needed
# print(f"Message {message.id} has select menu '{selector_category_name}' but no matching category found in current config.")
# else: # Debugging if needed
# print(f"Message {message.id} from bot has components, but first item is not a recognized select menu.")
# else: # Debugging if needed
# if message.author == self.bot.user:
# print(f"Message {message.id} from bot skipped (Embeds: {bool(message.embeds)}, Components: {bool(message.components)})")
except discord.Forbidden:
await ctx.send(f"Error: I don't have permissions to read message history in {target_channel.mention}.")
return
except Exception as e:
await ctx.send(f"An unexpected error occurred during the update process: {e}")
print(f"Unexpected error in update_role_selectors: {e}")
return
await ctx.send(f"Role selector update process finished for {target_channel.mention}.\n"
f"Checked: {checked_messages} messages.\n"
f"Updated: {updated_messages} selectors.\n"
f"Errors: {errors}")
@commands.command(name="recreate_role_embeds")
@commands.is_owner()
async def recreate_role_embeds(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None):
"""Deletes existing role selectors in a channel and creates new ones. (Owner Only)"""
target_channel = channel or ctx.channel
guild = ctx.guild
if not guild:
await ctx.send("This command must be used within a server.")
return
initial_status_msg = await ctx.send(f"Starting recreation process for role selectors in {target_channel.mention}...")
# --- Step 1: Find and Delete Existing Selectors ---
deleted_messages = 0
checked_messages = 0
deletion_errors = 0
messages_to_delete = []
try:
await initial_status_msg.edit(content=f"Searching for existing role selectors in {target_channel.mention} (checking last 500 messages)...")
async for message in target_channel.history(limit=500): # Check a reasonable number of messages
checked_messages += 1
# --- MODIFIED: Delete any message sent by the bot ---
if message.author == self.bot.user:
messages_to_delete.append(message)
# --- END MODIFICATION ---
if messages_to_delete:
await initial_status_msg.edit(content=f"Found {len(messages_to_delete)} messages from the bot. Deleting...")
# Delete messages one by one to handle potential rate limits and errors better
for msg in messages_to_delete:
try:
await msg.delete()
deleted_messages += 1
await asyncio.sleep(1) # Add a small delay to avoid rate limits
except discord.Forbidden:
print(f"Error: Missing permissions to delete message {msg.id} in {target_channel.name}")
deletion_errors += 1
except discord.NotFound:
print(f"Warning: Message {msg.id} not found (already deleted?).")
# Don't count as an error, but maybe decrement deleted_messages if needed?
except discord.HTTPException as e:
print(f"Error: Failed to delete message {msg.id}: {e}")
deletion_errors += 1
except Exception as e:
print(f"Unexpected error deleting message {msg.id}: {e}")
deletion_errors += 1
await initial_status_msg.edit(content=f"Deleted {deleted_messages} messages. Errors during deletion: {deletion_errors}.")
else:
await initial_status_msg.edit(content="No existing role selector messages found to delete.")
await asyncio.sleep(2) # Brief pause before creating new ones
except discord.Forbidden:
await initial_status_msg.edit(content=f"Error: I don't have permissions to read message history or delete messages in {target_channel.mention}.")
return
except Exception as e:
await initial_status_msg.edit(content=f"An unexpected error occurred during deletion: {e}")
print(f"Unexpected error in recreate_role_embeds (deletion phase): {e}")
return
# --- Step 2: Create New Embeds (similar to create_role_embeds) ---
await initial_status_msg.edit(content=f"Fetching roles and creating new embeds in {target_channel.mention}...")
roles_by_creator_category = self._get_dynamic_roles_per_category(guild)
if not any(roles_by_creator_category.values()):
await initial_status_msg.edit(content="No roles matching the expected names were found in this server. Cannot create new embeds. Please run the `create_roles` command first.")
return
sent_messages = 0
creation_errors = 0
for creator_category, role_list in roles_by_creator_category.items():
if role_list and creator_category in CATEGORY_DETAILS:
details = CATEGORY_DETAILS[creator_category]
selector_category = details["selector_category"]
max_values = details["max_values"]
embed = discord.Embed(
title=details["title"],
description=details["description"],
color=details["color"]
)
view = RoleSelectorView(role_list, selector_category, max_values=max_values)
try:
await target_channel.send(embed=embed, view=view)
sent_messages += 1
await asyncio.sleep(0.5) # Small delay between sends
except discord.Forbidden:
await ctx.send(f"Error: Missing permissions to send messages in {target_channel.mention}. Aborting creation.")
creation_errors += 1
break # Stop trying if permissions are missing
except discord.HTTPException as e:
await ctx.send(f"Error sending embed for '{creator_category}': {e}")
creation_errors += 1
except Exception as e:
print(f"Unexpected error sending embed for '{creator_category}': {e}")
creation_errors += 1
elif not role_list and creator_category in CATEGORY_DETAILS:
print(f"Skipping new embed for empty category '{creator_category}' in guild {guild.id}")
final_message = f"Role selector recreation process finished for {target_channel.mention}.\n" \
f"Deleted: {deleted_messages} (Errors: {deletion_errors})\n" \
f"Created: {sent_messages} (Errors: {creation_errors})"
await initial_status_msg.edit(content=final_message)
async def setup(bot):
await bot.add_cog(RoleSelectorCog(bot))
print("RoleSelectorCog loaded. Persistent views will be registered once the bot is ready.")