diff --git a/api_service/api_models.py b/api_service/api_models.py index 6bdc3c2..fba647d 100644 --- a/api_service/api_models.py +++ b/api_service/api_models.py @@ -76,6 +76,50 @@ class UserSettings(BaseModel): # Last updated timestamp last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now) +# ============= Role Selector Models ============= + +class RoleOption(BaseModel): + """Represents a single selectable role within a category preset.""" + role_id: str # Discord Role ID + name: str + emoji: Optional[str] = None + +class RoleCategoryPreset(BaseModel): + """Represents a global preset for a role category.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique ID for the preset category + name: str # e.g., "Colors", "Pronouns" + description: str + roles: List[RoleOption] = [] + max_selectable: int = 1 + display_order: int = 0 # For ordering presets if listed + +class GuildRole(BaseModel): + """Represents a specific role configured by a guild for selection.""" + role_id: str # Discord Role ID + name: str + emoji: Optional[str] = None + +class GuildRoleCategoryConfig(BaseModel): + """Represents a guild's specific configuration for a role selection category.""" + guild_id: str + category_id: str = Field(default_factory=lambda: str(uuid.uuid4())) # Unique ID for this guild's category instance + name: str # Custom name or preset name + description: str + roles: List[GuildRole] = [] + max_selectable: int = 1 + message_id: Optional[str] = None # Discord message ID of the selector embed + channel_id: Optional[str] = None # Discord channel ID where the selector embed is posted + is_preset: bool = False # True if this category is based on a global preset + preset_id: Optional[str] = None # If is_preset, this links to RoleCategoryPreset.id + +class UserCustomColorRole(BaseModel): + """Represents a user's custom color role.""" + user_id: str + guild_id: str + role_id: str # Discord Role ID of their custom color role + hex_color: str # e.g., "#RRGGBB" + last_updated: datetime.datetime = Field(default_factory=datetime.datetime.now) + # ============= API Request/Response Models ============= class GetConversationsResponse(BaseModel): diff --git a/api_service/database.py b/api_service/database.py index 1e6f5d7..8bfd96c 100644 --- a/api_service/database.py +++ b/api_service/database.py @@ -3,7 +3,10 @@ import json import datetime from typing import Dict, List, Optional, Any # Use absolute import for api_models -from api_service.api_models import Conversation, UserSettings, Message +from api_service.api_models import ( + Conversation, UserSettings, Message, + RoleCategoryPreset, GuildRoleCategoryConfig, UserCustomColorRole +) # ============= Database Class ============= @@ -13,6 +16,9 @@ class Database: self.conversations_file = os.path.join(data_dir, "conversations.json") self.settings_file = os.path.join(data_dir, "user_settings.json") self.tokens_file = os.path.join(data_dir, "user_tokens.json") + self.role_presets_file = os.path.join(data_dir, "role_category_presets.json") + self.guild_role_configs_file = os.path.join(data_dir, "guild_role_category_configs.json") + self.user_color_roles_file = os.path.join(data_dir, "user_custom_color_roles.json") # Create data directory if it doesn't exist os.makedirs(data_dir, exist_ok=True) @@ -21,6 +27,10 @@ class Database: self.conversations: Dict[str, Dict[str, Conversation]] = {} # user_id -> conversation_id -> Conversation self.user_settings: Dict[str, UserSettings] = {} # user_id -> UserSettings self.user_tokens: Dict[str, Dict[str, Any]] = {} # user_id -> token_data + self.role_category_presets: Dict[str, RoleCategoryPreset] = {} # preset_id -> RoleCategoryPreset + self.guild_role_category_configs: Dict[str, List[GuildRoleCategoryConfig]] = {} # guild_id -> List[GuildRoleCategoryConfig] + self.user_custom_color_roles: Dict[str, Dict[str, UserCustomColorRole]] = {} # guild_id -> user_id -> UserCustomColorRole + # Load data from files self.load_data() @@ -30,12 +40,115 @@ class Database: self.load_conversations() self.load_user_settings() self.load_user_tokens() + self.load_role_category_presets() + self.load_guild_role_category_configs() + self.load_user_custom_color_roles() def save_data(self): """Save all data to files""" self.save_conversations() self.save_all_user_settings() self.save_user_tokens() + self.save_role_category_presets() + self.save_guild_role_category_configs() + self.save_user_custom_color_roles() + + # ============= Role Selector Data Load/Save Methods ============= + + def load_role_category_presets(self): + """Load role category presets from file""" + if os.path.exists(self.role_presets_file): + try: + with open(self.role_presets_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.role_category_presets = { + preset_id: RoleCategoryPreset.model_validate(preset_data) + for preset_id, preset_data in data.items() + } + print(f"Loaded {len(self.role_category_presets)} role category presets.") + except Exception as e: + print(f"Error loading role category presets: {e}") + self.role_category_presets = {} + else: + self.role_category_presets = {} # Initialize if file doesn't exist + + def save_role_category_presets(self): + """Save role category presets to file""" + try: + serializable_data = { + preset_id: preset.model_dump() + for preset_id, preset in self.role_category_presets.items() + } + with open(self.role_presets_file, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving role category presets: {e}") + + def load_guild_role_category_configs(self): + """Load guild role category configs from file""" + if os.path.exists(self.guild_role_configs_file): + try: + with open(self.guild_role_configs_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.guild_role_category_configs = { + guild_id: [GuildRoleCategoryConfig.model_validate(config_data) for config_data in configs_list] + for guild_id, configs_list in data.items() + } + print(f"Loaded guild role category configs for {len(self.guild_role_category_configs)} guilds.") + except Exception as e: + print(f"Error loading guild role category configs: {e}") + self.guild_role_category_configs = {} + else: + self.guild_role_category_configs = {} + + def save_guild_role_category_configs(self): + """Save guild role category configs to file""" + try: + serializable_data = { + guild_id: [config.model_dump() for config in configs_list] + for guild_id, configs_list in self.guild_role_category_configs.items() + } + with open(self.guild_role_configs_file, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving guild role category configs: {e}") + + def load_user_custom_color_roles(self): + """Load user custom color roles from file""" + if os.path.exists(self.user_color_roles_file): + try: + with open(self.user_color_roles_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.user_custom_color_roles = { + guild_id: { + user_id: UserCustomColorRole.model_validate(role_data) + for user_id, role_data in user_roles.items() + } + for guild_id, user_roles in data.items() + } + print(f"Loaded user custom color roles for {len(self.user_custom_color_roles)} guilds.") + except Exception as e: + print(f"Error loading user custom color roles: {e}") + self.user_custom_color_roles = {} + else: + self.user_custom_color_roles = {} + + def save_user_custom_color_roles(self): + """Save user custom color roles to file""" + try: + serializable_data = { + guild_id: { + user_id: role.model_dump() + for user_id, role in user_roles.items() + } + for guild_id, user_roles in self.user_custom_color_roles.items() + } + with open(self.user_color_roles_file, "w", encoding="utf-8") as f: + json.dump(serializable_data, f, indent=2, default=str, ensure_ascii=False) + except Exception as e: + print(f"Error saving user custom color roles: {e}") + + # ============= Existing Data Load/Save Methods ============= def load_conversations(self): """Load conversations from file""" @@ -136,6 +249,98 @@ class Database: return True return False + # ============= Role Category Preset Methods ============= + + def get_role_category_preset(self, preset_id: str) -> Optional[RoleCategoryPreset]: + """Get a specific role category preset by ID.""" + return self.role_category_presets.get(preset_id) + + def get_all_role_category_presets(self) -> List[RoleCategoryPreset]: + """Get all role category presets.""" + return list(self.role_category_presets.values()) + + def save_role_category_preset(self, preset: RoleCategoryPreset) -> RoleCategoryPreset: + """Save a role category preset.""" + self.role_category_presets[preset.id] = preset + self.save_role_category_presets() + return preset + + def delete_role_category_preset(self, preset_id: str) -> bool: + """Delete a role category preset.""" + if preset_id in self.role_category_presets: + del self.role_category_presets[preset_id] + self.save_role_category_presets() + return True + return False + + # ============= Guild Role Category Config Methods ============= + + def get_guild_role_category_configs(self, guild_id: str) -> List[GuildRoleCategoryConfig]: + """Get all role category configurations for a specific guild.""" + return self.guild_role_category_configs.get(guild_id, []) + + def get_all_guild_role_category_configs(self) -> Dict[str, List[GuildRoleCategoryConfig]]: + """Get all role category configurations for all guilds.""" + return self.guild_role_category_configs + + def get_guild_role_category_config(self, guild_id: str, category_id: str) -> Optional[GuildRoleCategoryConfig]: + """Get a specific role category configuration for a guild.""" + for config in self.get_guild_role_category_configs(guild_id): + if config.category_id == category_id: + return config + return None + + def save_guild_role_category_config(self, config: GuildRoleCategoryConfig) -> GuildRoleCategoryConfig: + """Save a guild's role category configuration.""" + guild_id = config.guild_id + if guild_id not in self.guild_role_category_configs: + self.guild_role_category_configs[guild_id] = [] + + # Remove existing config with the same category_id if it exists, then add the new/updated one + self.guild_role_category_configs[guild_id] = [c for c in self.guild_role_category_configs[guild_id] if c.category_id != config.category_id] + self.guild_role_category_configs[guild_id].append(config) + self.save_guild_role_category_configs() + return config + + def delete_guild_role_category_config(self, guild_id: str, category_id: str) -> bool: + """Delete a specific role category configuration for a guild.""" + if guild_id in self.guild_role_category_configs: + initial_len = len(self.guild_role_category_configs[guild_id]) + self.guild_role_category_configs[guild_id] = [ + c for c in self.guild_role_category_configs[guild_id] if c.category_id != category_id + ] + if len(self.guild_role_category_configs[guild_id]) < initial_len: + self.save_guild_role_category_configs() + return True + return False + + # ============= User Custom Color Role Methods ============= + + def get_user_custom_color_role(self, guild_id: str, user_id: str) -> Optional[UserCustomColorRole]: + """Get a user's custom color role in a specific guild.""" + return self.user_custom_color_roles.get(guild_id, {}).get(user_id) + + def save_user_custom_color_role(self, color_role: UserCustomColorRole) -> UserCustomColorRole: + """Save a user's custom color role.""" + guild_id = color_role.guild_id + user_id = color_role.user_id + + if guild_id not in self.user_custom_color_roles: + self.user_custom_color_roles[guild_id] = {} + + color_role.last_updated = datetime.datetime.now() + self.user_custom_color_roles[guild_id][user_id] = color_role + self.save_user_custom_color_roles() + return color_role + + def delete_user_custom_color_role(self, guild_id: str, user_id: str) -> bool: + """Delete a user's custom color role in a specific guild.""" + if guild_id in self.user_custom_color_roles and user_id in self.user_custom_color_roles[guild_id]: + del self.user_custom_color_roles[guild_id][user_id] + self.save_user_custom_color_roles() + return True + return False + # ============= User Settings Methods ============= def get_user_settings(self, user_id: str) -> UserSettings: diff --git a/cogs/role_selector_cog.py b/cogs/role_selector_cog.py index 1462c78..315800d 100644 --- a/cogs/role_selector_cog.py +++ b/cogs/role_selector_cog.py @@ -1,78 +1,249 @@ import discord from discord.ext import commands -from discord.ui import View, Select, select +from discord.ui import View, Select, select, Modal, TextInput import json import os -from typing import List, Dict, Optional, Set, Tuple +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 -# 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", "Adachi Rei"], - "Notifications": ["Announcements"] -} +# Database and Pydantic Models +from api_service.api_server import db +from api_service.api_models import ( + RoleOption, RoleCategoryPreset, GuildRole, + GuildRoleCategoryConfig, UserCustomColorRole +) -# 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) -} +# 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, category_roles: List[discord.Role], selector_category_name: str, max_values: int = 1): + def __init__(self, guild_id: int, category_config: GuildRoleCategoryConfig, bot_instance): 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 + 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]] = {} - # 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)] + 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) - - # 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. + 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.id)) for role in chunk] - chunk_role_ids = {role.id for role in chunk} + 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 - - # 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)..." + chunk_max_values = min(total_max_values, len(options)) + placeholder = f"Select {category_config.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 - + 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, # Allow selecting zero from any individual dropdown - max_values=chunk_max_values, # Max selectable from *this* dropdown + min_values=actual_min_values, + max_values=chunk_max_values, options=options, custom_id=select_custom_id ) @@ -80,432 +251,402 @@ class RoleSelectorView(View): 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 + if guild.id != self.guild_id: + await interaction.followup.send("This role selector is not for this 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 - + 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', []) - 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 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 the '{current_selector_category}' category.", ephemeral=True) - return # Stop processing + 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] - - # 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 = [] + 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})") + 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] - # 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})") + 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] - # 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." - - + 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 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) + 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): def __init__(self, bot): self.bot = bot - self.bot.loop.create_task(self.register_persistent_views()) + self.bot.loop.create_task(self.register_all_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.""" + async def register_all_persistent_views(self): 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"] + + # 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: - # Register a view for this specific category - self.bot.add_view(RoleSelectorView(role_list, selector_category, max_values=max_values)) + view = RoleSelectorView(guild_id, category_config, self.bot) + self.bot.add_view(view) 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" 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 across {guild_count} guild(s).") + print(f"RoleSelectorCog: Finished registering {registered_count} persistent views.") - @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 + 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 - initial_message = await ctx.send(f"Fetching roles and creating embeds in {target_channel.mention}...") + @commands.group(name="roleselect", invoke_without_command=True) + @commands.has_permissions(manage_guild=True) + async def roleselect(self, ctx: commands.Context): + await ctx.send_help(ctx.command) - 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.") + @roleselect.command(name="addcategory") + @commands.has_permissions(manage_guild=True) + async def roleselect_addcategory(self, ctx: commands.Context, name: str, description: str, max_selectable: int = 1, preset_id: Optional[str] = None): + guild_id_str = str(ctx.guild.id) + if self._get_guild_category_config(ctx.guild.id, name) and not preset_id: # Allow adding preset even if name conflicts, preset name will be used + await ctx.send(f"A custom role category named '{name}' already exists.") 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"] + roles_to_add: List[GuildRole] = [] + is_preset_based = False + final_name = name + final_description = description + final_max_selectable = max_selectable - 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 preset_id: + preset = db.get_role_category_preset(preset_id) + if not preset: + await ctx.send(f"Preset with ID '{preset_id}' not found.") + return + + final_name = preset.name # Use preset's name + if self._get_guild_category_config(ctx.guild.id, final_name): # Check if preset name already exists + await ctx.send(f"A category based on preset '{final_name}' already exists.") + return - if sent_messages > 0: - await initial_message.edit(content=f"Created {sent_messages} role selection embed(s) in {target_channel.mention} successfully!") + for preset_role_option in preset.roles: + role_in_guild = ctx.guild.get_role(int(preset_role_option.role_id)) + if not role_in_guild: + await ctx.send(f"Warning: Role '{preset_role_option.name}' (ID: {preset_role_option.role_id}) from preset not found in this server. Skipping.") + continue + roles_to_add.append(GuildRole(role_id=str(role_in_guild.id), name=role_in_guild.name, emoji=preset_role_option.emoji)) + + 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: - await initial_message.edit(content=f"No roles found for any category to create embeds in {target_channel.mention}.") + msg += f" Use `!roleselect addrole \"{final_name}\" [emoji]` to add roles." + msg += f" Then use `!roleselect post \"{final_name}\" #channel` to post." + await ctx.send(msg) - @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)""" + @roleselect.command(name="removecategory") + @commands.has_permissions(manage_guild=True) + async def roleselect_removecategory(self, ctx: commands.Context, category_name_or_id: str): + guild_id = ctx.guild.id + config_to_remove = self._get_guild_category_config(guild_id, category_name_or_id) + if not config_to_remove: + await ctx.send(f"Role category '{category_name_or_id}' not found.") + return + if config_to_remove.message_id and config_to_remove.channel_id: + try: + channel = ctx.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() + await ctx.send(f"Deleted selector message for '{config_to_remove.name}'.") + except Exception as e: + await ctx.send(f"Could not delete selector message: {e}") + db.delete_guild_role_category_config(str(guild_id), config_to_remove.category_id) + await ctx.send(f"Role category '{config_to_remove.name}' removed.") + + @roleselect.command(name="listcategories") + @commands.has_permissions(manage_guild=True) + async def roleselect_listcategories(self, ctx: commands.Context): + guild_id_str = str(ctx.guild.id) + configs = db.get_guild_role_category_configs(guild_id_str) + if not configs: + await ctx.send("No role selection categories configured.") + 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 ctx.send(embed=embed) + + @roleselect.command(name="listpresets") + @commands.is_owner() # Presets are global, so owner only + async def roleselect_listpresets(self, ctx: commands.Context): + presets = db.get_all_role_category_presets() + if not presets: + await ctx.send("No global presets available.") + 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 ctx.send(embed=embed) + + @roleselect.command(name="addrole") + @commands.has_permissions(manage_guild=True) + async def roleselect_addrole(self, ctx: commands.Context, category_name_or_id: str, role: discord.Role, emoji: Optional[str] = None): + guild_id = ctx.guild.id + config = self._get_guild_category_config(guild_id, category_name_or_id) + if not config: + await ctx.send(f"Category '{category_name_or_id}' not found.") + return + if config.is_preset: + await ctx.send(f"Category '{config.name}' uses a preset. Roles are managed via the preset definition.") + return + if any(r.role_id == str(role.id) for r in config.roles): + await ctx.send(f"Role '{role.name}' is already in '{config.name}'.") + return + config.roles.append(GuildRole(role_id=str(role.id), name=role.name, emoji=emoji)) + db.save_guild_role_category_config(config) + await ctx.send(f"Role '{role.name}' added to '{config.name}'.") + + @roleselect.command(name="removerole") + @commands.has_permissions(manage_guild=True) + async def roleselect_removerole(self, ctx: commands.Context, category_name_or_id: str, role: discord.Role): + guild_id = ctx.guild.id + config = self._get_guild_category_config(guild_id, category_name_or_id) + if not config: + await ctx.send(f"Category '{category_name_or_id}' not found.") + return + if config.is_preset: + await ctx.send(f"Category '{config.name}' uses a preset. Roles are managed via the preset definition.") + 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 ctx.send(f"Role '{role.name}' removed from '{config.name}'.") + else: + await ctx.send(f"Role '{role.name}' not found in '{config.name}'.") + + @roleselect.command(name="setcolorui") + @commands.has_permissions(manage_guild=True) + async def roleselect_setcolorui(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): + target_channel = channel or ctx.channel + 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 != ctx.channel: + await ctx.send(f"Custom color button posted in {target_channel.mention}.", ephemeral=True) + except discord.Forbidden: + await ctx.send(f"I don't have permissions to send messages in {target_channel.mention}.", ephemeral=True) + except Exception as e: + await ctx.send(f"Error posting custom color button: {e}", ephemeral=True) + + @roleselect.command(name="post") + @commands.has_permissions(manage_guild=True) + async def roleselect_post(self, ctx: commands.Context, category_name_or_id: str, channel: Optional[discord.TextChannel] = None): target_channel = channel or ctx.channel guild = ctx.guild - if not guild: - await ctx.send("This command must be used within a server.") + config = self._get_guild_category_config(guild.id, category_name_or_id) + if not config: + await ctx.send(f"Category '{category_name_or_id}' not found.") 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 - + if not config.roles: + await ctx.send(f"Category '{config.name}' has no roles. Add roles first.") + return + embed = discord.Embed(title=f"✨ {config.name} Roles ✨", description=config.description, color=discord.Color.blue()) + view = RoleSelectorView(guild.id, config, self.bot) + + 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 + config.channel_id = str(target_channel.id) + new_msg = await target_channel.send(embed=embed, view=view) + config.message_id = str(new_msg.id) + await message_to_edit.delete() + await ctx.send(f"Updated and moved selector for '{config.name}' to {target_channel.mention}.") + else: # Just updated + await ctx.send(f"Updated selector for '{config.name}' in {target_channel.mention}.") + db.save_guild_role_category_config(config) + return + except Exception as e: # NotFound, Forbidden, etc. + await ctx.send(f"Couldn't update original message ({e}), posting new one.") + config.message_id = None; config.channel_id = None # Clear old IDs 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 + 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 ctx.send(f"Posted role selector for '{config.name}' in {target_channel.mention}.") 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"Error posting role selector: {e}") - 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.group(name="rolepreset", invoke_without_command=True) @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.") + async def rolepreset(self, ctx: commands.Context): + await ctx.send_help(ctx.command) + + @rolepreset.command(name="add") + @commands.is_owner() + async def rolepreset_add(self, ctx: commands.Context, preset_id: str, name: str, description: str, max_selectable: int = 1, display_order: int = 0): + if db.get_role_category_preset(preset_id): + await ctx.send(f"Preset ID '{preset_id}' already exists.") return + new_preset = RoleCategoryPreset(id=preset_id, name=name, description=description, roles=[], max_selectable=max_selectable, display_order=display_order) + db.save_role_category_preset(new_preset) + await ctx.send(f"Preset '{name}' (ID: {preset_id}) created. Add roles with `!rolepreset addrole`.") - 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 = [] + @rolepreset.command(name="remove") + @commands.is_owner() + async def rolepreset_remove(self, ctx: commands.Context, preset_id: str): + if not db.get_role_category_preset(preset_id): + await ctx.send(f"Preset ID '{preset_id}' not found.") + return + db.delete_role_category_preset(preset_id) + await ctx.send(f"Preset ID '{preset_id}' removed.") + @rolepreset.command(name="addrole") + @commands.is_owner() + async def rolepreset_addrole(self, ctx: commands.Context, preset_id: str, role_name_or_id: str, emoji: Optional[str] = None): + preset = db.get_role_category_preset(preset_id) + if not preset: + await ctx.send(f"Preset ID '{preset_id}' not found.") + return + target_role: Optional[discord.Role] = None + role_display_name = role_name_or_id 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}.") + 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: + 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 ctx.send(f"Role '{role_name_or_id}' not found in any server.") 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}") + if any(r.role_id == str(target_role.id) for r in preset.roles): + await ctx.send(f"Role '{target_role.name}' already in preset '{preset.name}'.") return + preset.roles.append(RoleOption(role_id=str(target_role.id), name=role_display_name, emoji=emoji)) + db.save_role_category_preset(preset) + await ctx.send(f"Role '{role_display_name}' added to preset '{preset.name}'.") - # --- 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}...") + @rolepreset.command(name="removerole") + @commands.is_owner() + async def rolepreset_removerole(self, ctx: commands.Context, preset_id: str, role_id_or_name: str): + preset = db.get_role_category_preset(preset_id) + if not preset: + await ctx.send(f"Preset ID '{preset_id}' not found.") + return + initial_len = len(preset.roles) + preset.roles = [r for r in preset.roles if not (r.role_id == role_id_or_name or r.name.lower() == role_id_or_name.lower())] + if len(preset.roles) < initial_len: + db.save_role_category_preset(preset) + await ctx.send(f"Role matching '{role_id_or_name}' removed from preset '{preset.name}'.") + else: + await ctx.send(f"Role matching '{role_id_or_name}' not found in preset.") - roles_by_creator_category = self._get_dynamic_roles_per_category(guild) + @commands.command(name="create_role_embeds", hidden=True) + @commands.is_owner() + async def create_role_embeds_old(self, ctx: commands.Context): + await ctx.send("This command is deprecated. Use `!roleselect post #channel` instead.") - 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 + @commands.command(name="update_role_selectors", hidden=True) + @commands.is_owner() + async def update_role_selectors_old(self, ctx: commands.Context): + await ctx.send("This command is deprecated. Use `!roleselect post #channel` to update an existing selector message.") - 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) + @commands.command(name="recreate_role_embeds", hidden=True) + @commands.is_owner() + async def recreate_role_embeds_old(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): + # This command's old logic is complex and relies on EXPECTED_ROLES and CATEGORY_DETAILS. + # Re-implementing it fully with the new DB structure is out of scope for this refactor's immediate goals. + # Admins should use `roleselect removecategory` and `roleselect post` for similar functionality. + await ctx.send("This command is deprecated and its full functionality is not replicated. " + "To recreate selectors, please use `!roleselect removecategory` to remove the old one " + "(this will attempt to delete the message), then `!roleselect post` to create a new one.") async def setup(bot):