discordbot/cogs/role_selector_cog.py

1101 lines
62 KiB
Python

import discord
from discord.ext import commands
from discord import app_commands # Added for slash commands
from discord.ui import View, Select, select, Modal, TextInput
import json
import os
from typing import List, Dict, Optional, Set, Tuple, Union
import asyncio # Added for sleep
import re # For hex/rgb validation
import uuid # For generating IDs
# Database and Pydantic Models
from api_service.api_server import db
from api_service.api_models import (
RoleOption, RoleCategoryPreset, GuildRole,
GuildRoleCategoryConfig, UserCustomColorRole
)
async def is_owner_check(interaction: discord.Interaction) -> bool:
"""Checks if the interacting user is the bot owner."""
return interaction.user.id == interaction.client.owner_id
# For color name validation
try:
from matplotlib.colors import is_color_like, to_rgb, XKCD_COLORS
except ImportError:
XKCD_COLORS = {} # Fallback if matplotlib is not installed
def is_color_like(c): # Basic fallback
if isinstance(c, str):
return c.startswith('#') and len(c) in [4, 7]
return False
def to_rgb(c): # Basic fallback
if isinstance(c, str) and c.startswith('#'):
hex_color = c.lstrip('#')
if len(hex_color) == 3:
return tuple(int(hex_color[i]*2, 16)/255.0 for i in range(3))
if len(hex_color) == 6:
return tuple(int(hex_color[i:i+2], 16)/255.0 for i in range(0, 6, 2))
return (0,0,0) # Default black
# --- Constants ---
DEFAULT_ROLE_COLOR = discord.Color.default() # Used for custom color roles initially
# --- Color Parsing Helper ---
def _parse_color_input(color_input: str) -> Optional[discord.Color]:
"""Parses a color input string (hex, rgb, or name) into a discord.Color object."""
color_input = color_input.strip()
# Try hex
hex_match = re.fullmatch(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})", color_input)
if hex_match:
hex_val = hex_match.group(1)
if len(hex_val) == 3:
hex_val = "".join([c*2 for c in hex_val])
try:
return discord.Color(int(hex_val, 16))
except ValueError:
pass # Should not happen with regex match
# Try RGB: "r, g, b" or "(r, g, b)"
rgb_match = re.fullmatch(r"\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?", color_input)
if rgb_match:
try:
r, g, b = [int(x) for x in rgb_match.groups()]
if all(0 <= val <= 255 for val in (r, g, b)):
return discord.Color.from_rgb(r, g, b)
except ValueError:
pass
# Try English color name (matplotlib XKCD_COLORS)
if XKCD_COLORS: # Check if matplotlib was imported
normalized_input = color_input.lower().replace(" ", "")
# Check against normalized keys
for xkcd_name_key, xkcd_hex_val in XKCD_COLORS.items():
if xkcd_name_key.lower().replace(" ", "") == normalized_input:
try:
# to_rgb for xkcd colors usually returns (r,g,b) float tuple
rgb_float = to_rgb(xkcd_hex_val)
return discord.Color.from_rgb(int(rgb_float[0]*255), int(rgb_float[1]*255), int(rgb_float[2]*255))
except Exception:
pass # Matplotlib color conversion failed
break
# Fallback for very common color names if matplotlib is not available
elif color_input.lower() in {"red": (255,0,0), "green": (0,255,0), "blue": (0,0,255), "yellow": (255,255,0), "purple": (128,0,128), "orange": (255,165,0), "pink": (255,192,203), "black": (0,0,0), "white": (255,255,255)}:
r,g,b = {"red": (255,0,0), "green": (0,255,0), "blue": (0,0,255), "yellow": (255,255,0), "purple": (128,0,128), "orange": (255,165,0), "pink": (255,192,203), "black": (0,0,0), "white": (255,255,255)}[color_input.lower()]
return discord.Color.from_rgb(r,g,b)
return None
# --- Custom Color Modal ---
class CustomColorModal(Modal, title="Set Your Custom Role Color"):
color_input = TextInput(
label="Color (Hex, RGB, or Name)",
placeholder="#RRGGBB, 255,0,128, or 'sky blue'",
style=discord.TextStyle.short,
required=True,
max_length=100
)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True, thinking=True)
guild = interaction.guild
member = interaction.user
if not guild or not isinstance(member, discord.Member):
await interaction.followup.send("This can only be used in a server.", ephemeral=True)
return
parsed_color = _parse_color_input(self.color_input.value)
if not parsed_color:
await interaction.followup.send(
f"Could not understand the color '{self.color_input.value}'.\n"
"Please use a hex code (e.g., `#FF0000`), RGB values (e.g., `255,0,0`), "
"or a known color name (e.g., 'red', 'sky blue').",
ephemeral=True
)
return
custom_role_name = f"User Color - {member.id}"
existing_user_color_role_db = db.get_user_custom_color_role(str(guild.id), str(member.id))
role_to_update: Optional[discord.Role] = None
if existing_user_color_role_db:
role_to_update = guild.get_role(int(existing_user_color_role_db.role_id))
if not role_to_update:
db.delete_user_custom_color_role(str(guild.id), str(member.id))
existing_user_color_role_db = None
elif role_to_update.name != custom_role_name: # Name mismatch, could be manually changed
try: # Try to rename it back
await role_to_update.edit(name=custom_role_name, reason="Standardizing custom color role name")
except discord.Forbidden:
await interaction.followup.send("I couldn't standardize your existing color role name. Please check my permissions.", ephemeral=True)
# Potentially fall through to create a new one if renaming fails and old one is problematic
except discord.HTTPException:
pass # Non-critical error, proceed with color update
if not role_to_update:
for r in guild.roles: # Check if a role with the target name already exists
if r.name == custom_role_name:
role_to_update = r
break
if not role_to_update:
try:
role_to_update = await guild.create_role(
name=custom_role_name,
color=parsed_color, # Set initial color
reason=f"Custom color role for {member.display_name}"
)
except discord.Forbidden:
await interaction.followup.send("I don't have permission to create roles.", ephemeral=True)
return
except discord.HTTPException as e:
await interaction.followup.send(f"Failed to create role: {e}", ephemeral=True)
return
if not role_to_update:
await interaction.followup.send("Failed to obtain a role to update.", ephemeral=True)
return
if role_to_update.color != parsed_color:
try:
await role_to_update.edit(color=parsed_color, reason=f"Color update for {member.display_name}")
except discord.Forbidden:
await interaction.followup.send("I don't have permission to edit the role color.", ephemeral=True)
return
except discord.HTTPException as e:
await interaction.followup.send(f"Failed to update role color: {e}", ephemeral=True)
return
roles_to_add_to_member = []
if role_to_update.id not in [r.id for r in member.roles]:
roles_to_add_to_member.append(role_to_update)
roles_to_remove_from_member = [
r for r in member.roles
if r.name.startswith("User Color - ") and r.id != role_to_update.id
]
try:
if roles_to_remove_from_member:
await member.remove_roles(*roles_to_remove_from_member, reason="Cleaning up old custom color roles")
if roles_to_add_to_member:
await member.add_roles(*roles_to_add_to_member, reason="Applied custom color role")
except discord.Forbidden:
await interaction.followup.send("I don't have permission to assign roles.", ephemeral=True)
return
except discord.HTTPException as e:
await interaction.followup.send(f"Failed to assign role: {e}", ephemeral=True)
return
user_color_role_data = UserCustomColorRole(
user_id=str(member.id),
guild_id=str(guild.id),
role_id=str(role_to_update.id),
hex_color=f"#{parsed_color.value:06x}"
)
db.save_user_custom_color_role(user_color_role_data)
await interaction.followup.send(f"Your custom role color has been set to {user_color_role_data.hex_color}!", ephemeral=True)
async def on_error(self, interaction: discord.Interaction, error: Exception):
await interaction.followup.send(f"An error occurred: {error}", ephemeral=True)
print(f"Error in CustomColorModal: {error}")
# --- View for the Custom Color Button ---
class CustomColorButtonView(View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(label="Set Custom Role Color", style=discord.ButtonStyle.primary, custom_id="persistent_set_custom_color_button")
async def set_color_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
modal = CustomColorModal()
await interaction.response.send_modal(modal)
# --- Persistent View Definition ---
class RoleSelectorView(View):
def __init__(self, guild_id: int, category_config: GuildRoleCategoryConfig, bot_instance):
super().__init__(timeout=None)
self.guild_id = guild_id
self.category_config = category_config
self.bot = bot_instance
self.category_role_ids: Set[int] = {int(role.role_id) for role in category_config.roles}
self.custom_id = f"persistent_role_select_view_{guild_id}_{category_config.category_id}"
self.select_chunk_map: Dict[str, Set[int]] = {}
category_display_roles: List[GuildRole] = category_config.roles
self.role_chunks = [category_display_roles[i:i + 25] for i in range(0, len(category_display_roles), 25)]
num_chunks = len(self.role_chunks)
total_max_values = min(category_config.max_selectable, len(category_display_roles))
actual_min_values = 0
for i, chunk in enumerate(self.role_chunks):
options = [
discord.SelectOption(
label=role.name,
value=str(role.role_id),
emoji=role.emoji if role.emoji else None
) for role in chunk
]
chunk_role_ids = {int(role.role_id) for role in chunk}
if not options:
continue
chunk_max_values = min(total_max_values, len(options))
placeholder = f"Select {category_config.name} role(s)..."
if num_chunks > 1:
placeholder = f"Select {category_config.name} role(s) ({i+1}/{num_chunks})..."
select_custom_id = f"role_select_dropdown_{guild_id}_{category_config.category_id}_{i}"
self.select_chunk_map[select_custom_id] = chunk_role_ids
select_component = Select(
placeholder=placeholder,
min_values=actual_min_values,
max_values=chunk_max_values,
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):
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
if guild.id != self.guild_id:
await interaction.followup.send("This role selector is not for this server.", ephemeral=True)
return
interacted_custom_id = interaction.data['custom_id']
interacted_chunk_role_ids: Set[int] = self.select_chunk_map.get(interacted_custom_id, set())
if not interacted_chunk_role_ids:
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 identifying roles for this dropdown.", ephemeral=True)
return
selected_values = interaction.data.get('values', [])
selected_role_ids_from_interaction = {int(value) for value in selected_values}
member_category_role_ids = {role.id for role in member.roles if role.id in self.category_role_ids}
roles_to_add_ids = selected_role_ids_from_interaction - member_category_role_ids
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
if self.category_config.max_selectable == 1 and roles_to_add_ids:
if len(roles_to_add_ids) > 1:
await interaction.followup.send(f"Error: Cannot select multiple roles for '{self.category_config.name}'.", ephemeral=True)
return
role_to_add_id = list(roles_to_add_ids)[0]
other_member_roles_in_category = member_category_role_ids - {role_to_add_id}
roles_to_remove_ids.update(other_member_roles_in_category)
roles_to_add_ids = {role_to_add_id}
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)}
added_names, removed_names, error_messages = [], [], []
try:
if roles_to_remove:
await member.remove_roles(*roles_to_remove, reason=f"Deselected/changed via {self.category_config.name} role selector ({interacted_custom_id})")
removed_names = [r.name for r in roles_to_remove if r]
if roles_to_add:
await member.add_roles(*roles_to_add, reason=f"Selected via {self.category_config.name} role selector ({interacted_custom_id})")
added_names = [r.name for r in roles_to_add if r]
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)}"
if not added_names and not removed_names:
if selected_values: feedback = "No changes needed for the roles selected in this dropdown."
else: feedback = "No roles selected in this dropdown." if not member_roles_in_interacted_chunk else "Roles deselected from 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: {e}")
except Exception as e: error_messages.append(f"Unexpected error: {e}"); print(f"Error in role selector: {e}")
if error_messages: await interaction.followup.send("\n".join(error_messages), ephemeral=True)
class RoleSelectorCog(commands.Cog):
roleselect_group = app_commands.Group(name="roleselect", description="Manage role selection categories and selectors.")
rolepreset_group = app_commands.Group(name="rolepreset", description="Manage global role category presets.")
def __init__(self, bot):
self.bot = bot
self.bot.loop.create_task(self.register_all_persistent_views())
async def register_all_persistent_views(self):
await self.bot.wait_until_ready()
print("RoleSelectorCog: Registering persistent views...")
registered_count = 0
# Register RoleSelectorView for each guild config
guild_configs_data = db.get_all_guild_role_category_configs()
for guild_id_str, category_configs_list in guild_configs_data.items():
guild_id = int(guild_id_str)
guild = self.bot.get_guild(guild_id)
if not guild:
print(f" Skipping guild {guild_id} (not found).")
continue
for category_config in category_configs_list:
if category_config.roles: # Only register if there are roles
try:
view = RoleSelectorView(guild_id, category_config, self.bot)
self.bot.add_view(view)
registered_count += 1
except Exception as e:
print(f" Error registering RoleSelectorView for {category_config.name} in {guild.id}: {e}")
# Register CustomColorButtonView (it's globally persistent by its custom_id)
try:
self.bot.add_view(CustomColorButtonView())
print(" Registered CustomColorButtonView globally.")
registered_count +=1
except Exception as e:
print(f" Error registering CustomColorButtonView globally: {e}")
print(f"RoleSelectorCog: Finished registering {registered_count} persistent views.")
def _get_guild_category_config(self, guild_id: int, category_name_or_id: str) -> Optional[GuildRoleCategoryConfig]:
configs = db.get_guild_role_category_configs(str(guild_id))
for config in configs:
if config.category_id == category_name_or_id or config.name.lower() == category_name_or_id.lower():
return config
return None
async def autocomplete_category_name(self, interaction: discord.Interaction, current: str) -> List[app_commands.Choice[str]]:
choices = []
# Add existing guild category names
if interaction.guild:
guild_configs = db.get_guild_role_category_configs(str(interaction.guild_id))
for config in guild_configs:
if config.name.lower().startswith(current.lower()): # Check if current is not empty before startswith
choices.append(app_commands.Choice(name=config.name, value=config.name))
elif not current: # If current is empty, add all
choices.append(app_commands.Choice(name=config.name, value=config.name))
# Add global preset names
presets = db.get_all_role_category_presets()
for preset in presets:
if preset.name.lower().startswith(current.lower()): # Check if current is not empty
choices.append(app_commands.Choice(name=f"Preset: {preset.name}", value=preset.name))
elif not current: # If current is empty, add all
choices.append(app_commands.Choice(name=f"Preset: {preset.name}", value=preset.name))
# Limit to 25 choices as per Discord API limits
return choices[:25]
@roleselect_group.command(name="addcategory", description="Adds a new role category for selection.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(
name="The name for the new category (or select a preset).",
description="A description for this role category.",
max_selectable="Maximum number of roles a user can select from this category (default: 1).",
preset_id="Optional ID of a global preset to base this category on (auto-filled if preset selected for name)."
)
@app_commands.autocomplete(name=autocomplete_category_name)
async def roleselect_addcategory(self, interaction: discord.Interaction, name: str, description: str, max_selectable: Optional[int] = 1, preset_id: Optional[str] = None):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
guild_id_str = str(interaction.guild_id)
# Ensure max_selectable has a valid default if None is passed by Discord for optional int
current_max_selectable = max_selectable if max_selectable is not None else 1
# Check if the provided 'name' is actually a preset name from autocomplete
# If it starts with "Preset: ", extract the actual preset name
actual_name = name
if name.startswith("Preset: "):
actual_name = name[len("Preset: "):]
# If preset_id was not explicitly provided, set it to the ID of the selected preset
if not preset_id:
selected_preset = discord.utils.find(lambda p: p.name == actual_name, db.get_all_role_category_presets())
if selected_preset:
preset_id = selected_preset.id
if self._get_guild_category_config(interaction.guild_id, actual_name) and not preset_id: # Allow adding preset even if name conflicts, preset name will be used
await interaction.response.send_message(f"A custom role category named '{actual_name}' already exists.", ephemeral=True)
return
roles_to_add: List[GuildRole] = []
is_preset_based = False
final_name = name
final_description = description
final_max_selectable = current_max_selectable
if preset_id:
preset = db.get_role_category_preset(preset_id)
if not preset:
await interaction.response.send_message(f"Preset with ID '{preset_id}' not found.", ephemeral=True)
return
final_name = preset.name # Use preset's name
if self._get_guild_category_config(interaction.guild_id, final_name): # Check if preset name already exists
await interaction.response.send_message(f"A category based on preset '{final_name}' already exists.", ephemeral=True)
return
# For auto-creating roles from preset
if not interaction.guild.me.guild_permissions.manage_roles:
await interaction.response.send_message("I need 'Manage Roles' permission to create roles from the preset.", ephemeral=True)
return
# Define color map locally for this command, similar to init_defaults
color_map_for_creation = {
"Red": discord.Color.red(), "Blue": discord.Color.blue(), "Green": discord.Color.green(),
"Yellow": discord.Color.gold(), "Purple": discord.Color.purple(), "Orange": discord.Color.orange(),
"Pink": discord.Color.fuchsia(), "Black": discord.Color(0x010101), "White": discord.Color(0xFEFEFE)
}
# Defer if not already, as role creation can take time
if not interaction.response.is_done():
await interaction.response.defer(ephemeral=True, thinking=True)
created_roles_count = 0
for preset_role_option in preset.roles:
# Check if role with this NAME exists in the current guild
existing_role_in_guild = discord.utils.get(interaction.guild.roles, name=preset_role_option.name)
if existing_role_in_guild:
roles_to_add.append(GuildRole(role_id=str(existing_role_in_guild.id), name=existing_role_in_guild.name, emoji=preset_role_option.emoji))
else:
# Role does not exist by name, create it
role_color = discord.Color.default()
if preset.name.lower() == "colors" and preset_role_option.name in color_map_for_creation:
role_color = color_map_for_creation[preset_role_option.name]
try:
newly_created_role = await interaction.guild.create_role(
name=preset_role_option.name,
color=role_color,
permissions=discord.Permissions.none(), # Basic permissions
reason=f"Auto-created for preset '{preset.name}' by {interaction.user}"
)
roles_to_add.append(GuildRole(role_id=str(newly_created_role.id), name=newly_created_role.name, emoji=preset_role_option.emoji))
created_roles_count += 1
except discord.Forbidden:
await interaction.followup.send(f"I lack permission to create the role '{preset_role_option.name}'. Skipping.", ephemeral=True)
continue # Skip this role
except discord.HTTPException as e:
await interaction.followup.send(f"Failed to create role '{preset_role_option.name}': {e}. Skipping.", ephemeral=True)
continue # Skip this role
final_description = preset.description
final_max_selectable = preset.max_selectable
is_preset_based = True
new_config = GuildRoleCategoryConfig(
guild_id=guild_id_str,
name=final_name,
description=final_description,
roles=roles_to_add,
max_selectable=final_max_selectable,
is_preset=is_preset_based,
preset_id=preset_id if is_preset_based else None
)
db.save_guild_role_category_config(new_config)
msg = f"Role category '{final_name}' added."
if is_preset_based:
msg += f" Based on preset '{preset_id}'."
else:
msg += f" Use `/roleselect addrole category_name_or_id:{final_name} role:<role> [emoji:<emoji>]` to add roles." # Updated help text
msg += f" Then use `/roleselect post category_name_or_id:{final_name} channel:<#channel>` to post." # Updated help text
if interaction.response.is_done():
await interaction.followup.send(msg, ephemeral=True)
else:
await interaction.response.send_message(msg, ephemeral=True)
@roleselect_group.command(name="removecategory", description="Removes a role category and its selector message.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(category_name_or_id="The name or ID of the category to remove.")
async def roleselect_removecategory(self, interaction: discord.Interaction, category_name_or_id: str):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
guild_id = interaction.guild_id
config_to_remove = self._get_guild_category_config(guild_id, category_name_or_id)
if not config_to_remove:
await interaction.response.send_message(f"Role category '{category_name_or_id}' not found.", ephemeral=True)
return
deleted_message_feedback = ""
if config_to_remove.message_id and config_to_remove.channel_id:
try:
channel = interaction.guild.get_channel(int(config_to_remove.channel_id))
if channel and isinstance(channel, discord.TextChannel):
message = await channel.fetch_message(int(config_to_remove.message_id))
await message.delete()
deleted_message_feedback = f" Deleted selector message for '{config_to_remove.name}'."
except Exception as e:
deleted_message_feedback = f" Could not delete selector message: {e}."
db.delete_guild_role_category_config(str(guild_id), config_to_remove.category_id)
response_message = f"Role category '{config_to_remove.name}' removed.{deleted_message_feedback}"
if interaction.response.is_done():
await interaction.followup.send(response_message, ephemeral=True)
else:
await interaction.response.send_message(response_message, ephemeral=True)
@roleselect_group.command(name="listcategories", description="Lists all configured role selection categories.")
@app_commands.checks.has_permissions(manage_guild=True)
async def roleselect_listcategories(self, interaction: discord.Interaction):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
guild_id_str = str(interaction.guild_id)
configs = db.get_guild_role_category_configs(guild_id_str)
if not configs:
await interaction.response.send_message("No role selection categories configured.", ephemeral=True)
return
embed = discord.Embed(title="Configured Role Selection Categories", color=discord.Color.blue())
for config in configs:
roles_str = ", ".join([r.name for r in config.roles[:5]]) + ("..." if len(config.roles) > 5 else "")
embed.add_field(name=f"{config.name} (ID: `{config.category_id}`)", value=f"Desc: {config.description}\nMax: {config.max_selectable}\nRoles: {roles_str or 'None'}\nPreset: {config.preset_id or 'No'}", inline=False)
await interaction.response.send_message(embed=embed, ephemeral=False) # Make it visible
@roleselect_group.command(name="listpresets", description="Lists all available global role category presets.")
async def roleselect_listpresets(self, interaction: discord.Interaction):
presets = db.get_all_role_category_presets()
if not presets:
await interaction.response.send_message("No global presets available.", ephemeral=True)
return
embed = discord.Embed(title="Available Role Category Presets", color=discord.Color.green())
for preset in sorted(presets, key=lambda p: p.display_order):
roles_str = ", ".join([f"{r.name} ({r.role_id})" for r in preset.roles[:3]]) + ("..." if len(preset.roles) > 3 else "")
embed.add_field(name=f"{preset.name} (ID: `{preset.id}`)", value=f"Desc: {preset.description}\nMax: {preset.max_selectable}\nRoles: {roles_str or 'None'}", inline=False)
await interaction.response.send_message(embed=embed, ephemeral=False) # Make it visible
@roleselect_group.command(name="addrole", description="Adds a role to a specified category.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(
category_name_or_id="The name or ID of the category to add the role to.",
role="The role to add.",
emoji="Optional emoji to display next to the role in the selector."
)
async def roleselect_addrole(self, interaction: discord.Interaction, category_name_or_id: str, role: discord.Role, emoji: Optional[str] = None):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
guild_id = interaction.guild_id
config = self._get_guild_category_config(guild_id, category_name_or_id)
if not config:
await interaction.response.send_message(f"Category '{category_name_or_id}' not found.", ephemeral=True)
return
if config.is_preset:
await interaction.response.send_message(f"Category '{config.name}' uses a preset. Roles are managed via the preset definition.", ephemeral=True)
return
if any(r.role_id == str(role.id) for r in config.roles):
await interaction.response.send_message(f"Role '{role.name}' is already in '{config.name}'.", ephemeral=True)
return
config.roles.append(GuildRole(role_id=str(role.id), name=role.name, emoji=emoji))
db.save_guild_role_category_config(config)
await interaction.response.send_message(f"Role '{role.name}' added to '{config.name}'.", ephemeral=True)
@roleselect_group.command(name="removerole", description="Removes a role from a specified category.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(
category_name_or_id="The name or ID of the category to remove the role from.",
role="The role to remove."
)
async def roleselect_removerole(self, interaction: discord.Interaction, category_name_or_id: str, role: discord.Role):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
guild_id = interaction.guild_id
config = self._get_guild_category_config(guild_id, category_name_or_id)
if not config:
await interaction.response.send_message(f"Category '{category_name_or_id}' not found.", ephemeral=True)
return
if config.is_preset:
await interaction.response.send_message(f"Category '{config.name}' uses a preset. Roles are managed via the preset definition.", ephemeral=True)
return
initial_len = len(config.roles)
config.roles = [r for r in config.roles if r.role_id != str(role.id)]
if len(config.roles) < initial_len:
db.save_guild_role_category_config(config)
await interaction.response.send_message(f"Role '{role.name}' removed from '{config.name}'.", ephemeral=True)
else:
await interaction.response.send_message(f"Role '{role.name}' not found in '{config.name}'.", ephemeral=True)
@roleselect_group.command(name="setcolorui", description="Posts the UI for users to set their custom role color.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(channel="The channel to post the custom color UI in (defaults to current channel).")
async def roleselect_setcolorui(self, interaction: discord.Interaction, channel: Optional[discord.TextChannel] = None):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
target_channel = channel or interaction.channel
if not isinstance(target_channel, discord.TextChannel): # Ensure it's a text channel
await interaction.response.send_message("Invalid channel specified for custom color UI.", ephemeral=True)
return
embed = discord.Embed(
title="🎨 Custom Role Color",
description="Click the button below to set a custom color for your name in this server!",
color=discord.Color.random()
)
view = CustomColorButtonView()
try:
await target_channel.send(embed=embed, view=view)
if target_channel != interaction.channel:
await interaction.response.send_message(f"Custom color button posted in {target_channel.mention}.", ephemeral=True)
else:
await interaction.response.send_message("Custom color button posted.", ephemeral=True)
except discord.Forbidden:
await interaction.response.send_message(f"I don't have permissions to send messages in {target_channel.mention}.", ephemeral=True)
except Exception as e:
await interaction.response.send_message(f"Error posting custom color button: {e}", ephemeral=True)
@roleselect_group.command(name="post", description="Posts or updates the role selector message for a category.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(
category_name_or_id="The name or ID of the category to post.",
channel="The channel to post the selector in (defaults to current channel)."
)
async def roleselect_post(self, interaction: discord.Interaction, category_name_or_id: str, channel: Optional[discord.TextChannel] = None):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
target_channel = channel or interaction.channel
if not isinstance(target_channel, discord.TextChannel): # Ensure it's a text channel
await interaction.response.send_message("Invalid channel specified for posting.", ephemeral=True)
return
guild = interaction.guild
config = self._get_guild_category_config(guild.id, category_name_or_id)
if not config:
await interaction.response.send_message(f"Category '{category_name_or_id}' not found.", ephemeral=True)
return
if not config.roles:
await interaction.response.send_message(f"Category '{config.name}' has no roles. Add roles first using `/roleselect addrole`.", ephemeral=True)
return
embed = discord.Embed(title=f"{config.name} Roles ✨", description=config.description, color=discord.Color.blue())
view = RoleSelectorView(guild.id, config, self.bot)
# Defer the response as message fetching/editing can take time
await interaction.response.defer(ephemeral=True)
if config.message_id and config.channel_id:
try:
original_channel = guild.get_channel(int(config.channel_id))
if original_channel and isinstance(original_channel, discord.TextChannel):
message_to_edit = await original_channel.fetch_message(int(config.message_id))
await message_to_edit.edit(embed=embed, view=view)
if original_channel.id != target_channel.id: # Moved
# Delete old message, post new one, then update config
await message_to_edit.delete() # Delete the old message first
new_msg = await target_channel.send(embed=embed, view=view)
config.channel_id = str(target_channel.id)
config.message_id = str(new_msg.id)
await interaction.followup.send(f"Updated and moved selector for '{config.name}' to {target_channel.mention}.", ephemeral=True)
else: # Just updated in the same channel
await interaction.followup.send(f"Updated selector for '{config.name}' in {target_channel.mention}.", ephemeral=True)
db.save_guild_role_category_config(config)
return
except Exception as e: # NotFound, Forbidden, etc.
await interaction.followup.send(f"Couldn't update original message ({e}), posting new one.", ephemeral=True)
config.message_id = None
config.channel_id = None # Clear old IDs as we are posting a new one
try:
msg = await target_channel.send(embed=embed, view=view)
config.message_id = str(msg.id)
config.channel_id = str(target_channel.id)
db.save_guild_role_category_config(config)
await interaction.followup.send(f"Posted role selector for '{config.name}' in {target_channel.mention}.", ephemeral=True)
except Exception as e:
await interaction.followup.send(f"Error posting role selector: {e}", ephemeral=True)
@roleselect_group.command(name="postallcategories", description="Posts or updates selector messages for all categories in a channel.")
@app_commands.checks.has_permissions(manage_guild=True)
@app_commands.describe(channel="The channel to post all selectors in (defaults to current channel).")
async def roleselect_postallcategories(self, interaction: discord.Interaction, channel: Optional[discord.TextChannel] = None):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
target_channel = channel or interaction.channel
if not isinstance(target_channel, discord.TextChannel):
await interaction.response.send_message("Invalid channel specified for posting.", ephemeral=True)
return
guild = interaction.guild
all_guild_configs = db.get_guild_role_category_configs(str(guild.id))
if not all_guild_configs:
await interaction.response.send_message("No role categories configured for this server.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True, thinking=True)
posted_count = 0
updated_count = 0
skipped_count = 0
error_count = 0
feedback_details = []
for config in all_guild_configs:
if not config.roles:
feedback_details.append(f"Skipped '{config.name}': No roles configured.")
skipped_count += 1
continue
embed = discord.Embed(title=f"{config.name} Roles ✨", description=config.description, color=discord.Color.blue())
view = RoleSelectorView(guild.id, config, self.bot)
action_taken = "Posted"
# Check if a message already exists and handle it
if config.message_id and config.channel_id:
try:
original_channel = guild.get_channel(int(config.channel_id))
if original_channel and isinstance(original_channel, discord.TextChannel):
message_to_edit_or_delete = await original_channel.fetch_message(int(config.message_id))
if original_channel.id != target_channel.id: # Moving to a new channel
await message_to_edit_or_delete.delete() # Delete from old location
# message_id and channel_id will be updated when new message is sent
config.message_id = None
config.channel_id = None
action_taken = f"Moved '{config.name}' to {target_channel.mention} and posted"
else: # Updating in the same channel (target_channel is the original_channel)
await message_to_edit_or_delete.edit(embed=embed, view=view)
db.save_guild_role_category_config(config) # Save updated config (no change to message/channel id)
feedback_details.append(f"Updated selector for '{config.name}' in {target_channel.mention}.")
updated_count += 1
continue # Skip to next config as this one is handled
except discord.NotFound:
feedback_details.append(f"Note: Original message for '{config.name}' not found, will post anew.")
config.message_id = None
config.channel_id = None
except discord.Forbidden:
feedback_details.append(f"Error: Could not access/delete original message for '{config.name}' in {original_channel.mention if original_channel else 'unknown channel'}. Posting new one.")
config.message_id = None
config.channel_id = None
except Exception as e:
feedback_details.append(f"Error handling original message for '{config.name}': {e}. Posting new one.")
config.message_id = None
config.channel_id = None
# Post new message if no existing one or if it was deleted due to move
try:
new_msg = await target_channel.send(embed=embed, view=view)
config.message_id = str(new_msg.id)
config.channel_id = str(target_channel.id)
db.save_guild_role_category_config(config)
feedback_details.append(f"{action_taken} selector for '{config.name}' in {target_channel.mention}.")
if action_taken.startswith("Moved") or action_taken.startswith("Posted"):
posted_count +=1
else: # Should be covered by updated_count already
pass
except discord.Forbidden:
feedback_details.append(f"Error: No permission to post '{config.name}' in {target_channel.mention}.")
error_count += 1
except Exception as e:
feedback_details.append(f"Error posting '{config.name}': {e}.")
error_count += 1
summary_message = (
f"Finished posting all category selectors to {target_channel.mention}.\n"
f"Successfully Posted/Moved: {posted_count}\n"
f"Successfully Updated: {updated_count}\n"
f"Skipped (no roles/already exists): {skipped_count}\n"
f"Errors: {error_count}\n\n"
"Details:\n" + "\n".join(feedback_details)
)
await interaction.followup.send(summary_message, ephemeral=True)
# Role Preset Commands (Owner Only)
@rolepreset_group.command(name="add", description="Creates a new global role category preset.")
@app_commands.check(is_owner_check)
@app_commands.describe(
preset_id="A unique ID for this preset (e.g., 'color_roles', 'region_roles').",
name="The display name for this preset.",
description="A description for this preset.",
max_selectable="Maximum roles a user can select from categories using this preset (default: 1).",
display_order="Order in which this preset appears in lists (lower numbers first, default: 0)."
)
async def rolepreset_add(self, interaction: discord.Interaction, preset_id: str, name: str, description: str, max_selectable: Optional[int] = 1, display_order: Optional[int] = 0):
current_max_selectable = max_selectable if max_selectable is not None else 1
current_display_order = display_order if display_order is not None else 0
if db.get_role_category_preset(preset_id):
await interaction.response.send_message(f"Preset ID '{preset_id}' already exists.", ephemeral=True)
return
new_preset = RoleCategoryPreset(id=preset_id, name=name, description=description, roles=[], max_selectable=current_max_selectable, display_order=current_display_order)
db.save_role_category_preset(new_preset)
await interaction.response.send_message(f"Preset '{name}' (ID: {preset_id}) created. Add roles with `/rolepreset addrole`.", ephemeral=True)
@rolepreset_group.command(name="remove", description="Removes a global role category preset.")
@app_commands.check(is_owner_check)
@app_commands.describe(preset_id="The ID of the preset to remove.")
async def rolepreset_remove(self, interaction: discord.Interaction, preset_id: str):
if not db.get_role_category_preset(preset_id):
await interaction.response.send_message(f"Preset ID '{preset_id}' not found.", ephemeral=True)
return
db.delete_role_category_preset(preset_id)
await interaction.response.send_message(f"Preset ID '{preset_id}' removed.", ephemeral=True)
@rolepreset_group.command(name="addrole", description="Adds a role (by ID or name) to a global preset.")
@app_commands.check(is_owner_check)
@app_commands.describe(
preset_id="The ID of the preset to add the role to.",
role_name_or_id="The name or ID of the role to add. The first matching role found across all servers the bot is in will be used.",
emoji="Optional emoji for the role in this preset."
)
async def rolepreset_addrole(self, interaction: discord.Interaction, preset_id: str, role_name_or_id: str, emoji: Optional[str] = None):
preset = db.get_role_category_preset(preset_id)
if not preset:
await interaction.response.send_message(f"Preset ID '{preset_id}' not found.", ephemeral=True)
return
target_role: Optional[discord.Role] = None
role_display_name = role_name_or_id
# Attempt to find role by ID first, then by name across all guilds
try:
role_id_int = int(role_name_or_id)
for guild in self.bot.guilds:
if r := guild.get_role(role_id_int):
target_role = r
role_display_name = r.name
break
except ValueError: # Not an ID, try by name
for guild in self.bot.guilds:
for r_obj in guild.roles:
if r_obj.name.lower() == role_name_or_id.lower():
target_role = r_obj
role_display_name = r_obj.name
break
if target_role:
break
if not target_role:
await interaction.response.send_message(f"Role '{role_name_or_id}' not found in any server the bot is in.", ephemeral=True)
return
if any(r.role_id == str(target_role.id) for r in preset.roles):
await interaction.response.send_message(f"Role '{target_role.name}' (ID: {target_role.id}) is already in preset '{preset.name}'.", ephemeral=True)
return
preset.roles.append(RoleOption(role_id=str(target_role.id), name=role_display_name, emoji=emoji))
db.save_role_category_preset(preset)
await interaction.response.send_message(f"Role '{role_display_name}' (ID: {target_role.id}) added to preset '{preset.name}'.", ephemeral=True)
@rolepreset_group.command(name="removerole", description="Removes a role (by ID or name) from a global preset.")
@app_commands.check(is_owner_check)
@app_commands.describe(
preset_id="The ID of the preset to remove the role from.",
role_id_or_name="The ID or name of the role to remove from the preset."
)
async def rolepreset_removerole(self, interaction: discord.Interaction, preset_id: str, role_id_or_name: str):
preset = db.get_role_category_preset(preset_id)
if not preset:
await interaction.response.send_message(f"Preset ID '{preset_id}' not found.", ephemeral=True)
return
initial_len = len(preset.roles)
# Try to match by ID first, then by name if ID doesn't match or isn't an int
role_to_remove_id_str: Optional[str] = None
try:
role_id_int = int(role_id_or_name)
role_to_remove_id_str = str(role_id_int)
except ValueError:
pass # role_id_or_name is not an integer, will try to match by name
if role_to_remove_id_str:
preset.roles = [r for r in preset.roles if r.role_id != role_to_remove_id_str]
else: # Match by name (case-insensitive)
preset.roles = [r for r in preset.roles if r.name.lower() != role_id_or_name.lower()]
if len(preset.roles) < initial_len:
db.save_role_category_preset(preset)
await interaction.response.send_message(f"Role matching '{role_id_or_name}' removed from preset '{preset.name}'.", ephemeral=True)
else:
await interaction.response.send_message(f"Role matching '{role_id_or_name}' not found in preset '{preset.name}'.", ephemeral=True)
@rolepreset_group.command(name="add_all_to_guild", description="Adds all available global presets as categories to this guild.")
@app_commands.checks.has_permissions(manage_guild=True)
async def rolepreset_add_all_to_guild(self, interaction: discord.Interaction):
if not interaction.guild:
await interaction.response.send_message("This command can only be used in a server.", ephemeral=True)
return
if not interaction.guild.me.guild_permissions.manage_roles:
await interaction.response.send_message("I need 'Manage Roles' permission to create roles from presets.", ephemeral=True)
return
await interaction.response.defer(ephemeral=True, thinking=True)
guild_id_str = str(interaction.guild_id)
all_presets = db.get_all_role_category_presets()
added_count = 0
skipped_count = 0
feedback_messages = []
color_map_for_creation = {
"Red": discord.Color.red(), "Blue": discord.Color.blue(), "Green": discord.Color.green(),
"Yellow": discord.Color.gold(), "Purple": discord.Color.purple(), "Orange": discord.Color.orange(),
"Pink": discord.Color.fuchsia(), "Black": discord.Color(0x010101), "White": discord.Color(0xFEFEFE)
}
for preset in all_presets:
# Check if a category based on this preset already exists in the guild
if self._get_guild_category_config(interaction.guild_id, preset.name):
feedback_messages.append(f"Skipped '{preset.name}': A category with this name already exists.")
skipped_count += 1
continue
roles_to_add: List[GuildRole] = []
for preset_role_option in preset.roles:
existing_role_in_guild = discord.utils.get(interaction.guild.roles, name=preset_role_option.name)
if existing_role_in_guild:
roles_to_add.append(GuildRole(role_id=str(existing_role_in_guild.id), name=existing_role_in_guild.name, emoji=preset_role_option.emoji))
else:
role_color = discord.Color.default()
if preset.name.lower() == "colors" and preset_role_option.name in color_map_for_creation:
role_color = color_map_for_creation[preset_role_option.name]
try:
newly_created_role = await interaction.guild.create_role(
name=preset_role_option.name,
color=role_color,
permissions=discord.Permissions.none(),
reason=f"Auto-created for preset '{preset.name}' by {interaction.user}"
)
roles_to_add.append(GuildRole(role_id=str(newly_created_role.id), name=newly_created_role.name, emoji=preset_role_option.emoji))
except discord.Forbidden:
feedback_messages.append(f"Warning: Couldn't create role '{preset_role_option.name}' for preset '{preset.name}' due to permissions.")
continue
except discord.HTTPException as e:
feedback_messages.append(f"Warning: Failed to create role '{preset_role_option.name}' for preset '{preset.name}': {e}.")
continue
new_config = GuildRoleCategoryConfig(
guild_id=guild_id_str,
name=preset.name,
description=preset.description,
roles=roles_to_add,
max_selectable=preset.max_selectable,
is_preset=True,
preset_id=preset.id
)
db.save_guild_role_category_config(new_config)
feedback_messages.append(f"Added '{preset.name}' as a new category.")
added_count += 1
final_message = f"Attempted to add {len(all_presets)} presets.\nAdded: {added_count}\nSkipped: {skipped_count}\n\nDetails:\n" + "\n".join(feedback_messages)
await interaction.followup.send(final_message, ephemeral=True)
@rolepreset_group.command(name="init_defaults", description="Initializes default global presets based on role_creator_cog structure.")
@app_commands.check(is_owner_check)
async def rolepreset_init_defaults(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True, thinking=True)
# Definitions from role_creator_cog.py
color_map_creator = {
"Red": discord.Color.red(), "Blue": discord.Color.blue(), "Green": discord.Color.green(),
"Yellow": discord.Color.gold(), "Purple": discord.Color.purple(), "Orange": discord.Color.orange(),
"Pink": discord.Color.fuchsia(), "Black": discord.Color(0x010101), "White": discord.Color(0xFEFEFE)
}
role_categories_creator = {
"Colors": {"roles": ["Red", "Blue", "Green", "Yellow", "Purple", "Orange", "Pink", "Black", "White"], "max": 1, "desc": "Choose your favorite color role."},
"Regions": {"roles": ["NA East", "NA West", "EU", "Asia", "Oceania", "South America"], "max": 1, "desc": "Select your region."},
"Pronouns": {"roles": ["He/Him", "She/Her", "They/Them", "Ask Pronouns"], "max": 4, "desc": "Select your pronoun roles."},
"Interests": {"roles": ["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"], "max": 16, "desc": "Select your interests."},
"Gaming Platforms": {"roles": ["PC", "PlayStation", "Xbox", "Nintendo Switch", "Mobile"], "max": 5, "desc": "Select your gaming platforms."},
"Favorite Vocaloids": {"roles": ["Hatsune Miku", "Kasane Teto", "Akita Neru", "Kagamine Rin", "Kagamine Len", "Megurine Luka", "Kaito", "Meiko", "Gumi", "Kaai Yuki", "Adachi Rei"], "max": 10, "desc": "Select your favorite Vocaloids."},
"Notifications": {"roles": ["Announcements"], "max": 1, "desc": "Opt-in for announcements."}
}
created_presets = 0
skipped_presets = 0
preset_details_msg = ""
for idx, (category_name, cat_details) in enumerate(role_categories_creator.items()):
preset_id = f"default_{category_name.lower().replace(' ', '_')}"
if db.get_role_category_preset(preset_id):
preset_details_msg += f"Skipped: Preset '{category_name}' (ID: {preset_id}) already exists.\n"
skipped_presets += 1
continue
role_options_for_preset: List[RoleOption] = []
for role_name_in_creator in cat_details["roles"]:
# Find a canonical role ID from any guild for this role name
# This is a bit naive as role names might not be unique or exist
# The `/rolepreset addrole` command does this search more robustly.
# For init_defaults, we'll store the name and expect `/roleselect addcategory` to handle creation.
# The RoleOption model requires a role_id. We can use a placeholder or a convention.
# For now, let's use the role name as a placeholder for role_id if no actual ID is found,
# or better, skip if no role is found to ensure preset integrity.
# The user's request was to use what's in role_creator_cog.
# The role_creator_cog *creates* these roles.
# The preset system should ideally reference existing roles or define names for creation.
# Let's simplify: the preset will store the *name*. The guild-specific setup will create it.
# The RoleOption model has role_id and name. For presets, role_id might be less critical
# if the expectation is that the guild-level command creates roles by name.
# However, the current `/rolepreset addrole` finds an existing role ID.
# To be consistent, `init_defaults` should also try to find an ID.
found_role_for_option: Optional[discord.Role] = None
for g in self.bot.guilds:
for r in g.roles:
if r.name.lower() == role_name_in_creator.lower():
found_role_for_option = r
break
if found_role_for_option:
break
if found_role_for_option:
role_options_for_preset.append(RoleOption(
role_id=str(found_role_for_option.id), # Use ID of first found role
name=role_name_in_creator, # Use the canonical name from creator_cog
emoji=None # No emojis in role_creator_cog
))
else:
# If no role found across all guilds, we can't create a valid RoleOption for the preset.
# We could store the name as a placeholder, but this deviates from RoleOption model.
# For now, we'll log this and skip adding this specific role to the preset.
# The owner can add it manually later if needed.
preset_details_msg += f"Warning: Role '{role_name_in_creator}' for preset '{category_name}' not found in any guild. It won't be added to the preset.\n"
new_preset = RoleCategoryPreset(
id=preset_id,
name=category_name,
description=cat_details["desc"],
roles=role_options_for_preset,
max_selectable=cat_details["max"],
display_order=idx
)
db.save_role_category_preset(new_preset)
created_presets += 1
preset_details_msg += f"Created: Preset '{category_name}' (ID: {preset_id}) with {len(role_options_for_preset)} role options.\n"
final_summary = f"Default preset initialization complete.\nCreated: {created_presets}\nSkipped: {skipped_presets}\n\nDetails:\n{preset_details_msg}"
await interaction.followup.send(final_summary, ephemeral=True)
# Deprecated commands are removed as they are not slash commands and functionality is covered
async def setup(bot):
cog = RoleSelectorCog(bot)
await bot.add_cog(cog)
# Syncing should ideally happen once after all cogs are loaded, e.g., in main.py or a central setup.
# If this cog is reloaded, syncing here might be okay.
# For now, let's assume global sync happens elsewhere or is handled by bot.setup_hook if it calls tree.sync
# await bot.tree.sync()
print("RoleSelectorCog loaded. Persistent views will be registered. Ensure slash commands are synced globally if needed.")