1853 lines
77 KiB
Python
1853 lines
77 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)
|
|
|
|
# New logic: Remove roles from "Colors" preset if custom color is set
|
|
removed_preset_color_roles_names = []
|
|
# Ensure guild is not None, though it should be from earlier checks (line 103)
|
|
if guild and isinstance(member, discord.Member): # member check for safety
|
|
guild_role_categories = db.get_guild_role_category_configs(str(guild.id))
|
|
colors_preset_role_ids_to_remove = set()
|
|
|
|
for cat_config in guild_role_categories:
|
|
# "Colors" preset ID is 'default_colors'
|
|
if cat_config.is_preset and cat_config.preset_id == "default_colors":
|
|
for role_option in cat_config.roles:
|
|
colors_preset_role_ids_to_remove.add(int(role_option.role_id))
|
|
break # Found the Colors preset for this guild
|
|
|
|
if colors_preset_role_ids_to_remove:
|
|
roles_to_actually_remove_from_member = []
|
|
for member_role in member.roles:
|
|
if member_role.id in colors_preset_role_ids_to_remove:
|
|
roles_to_actually_remove_from_member.append(member_role)
|
|
|
|
if roles_to_actually_remove_from_member:
|
|
try:
|
|
await member.remove_roles(
|
|
*roles_to_actually_remove_from_member,
|
|
reason="User set a custom color, removing preset color role(s).",
|
|
)
|
|
removed_preset_color_roles_names = [
|
|
r.name for r in roles_to_actually_remove_from_member
|
|
]
|
|
except discord.Forbidden:
|
|
await interaction.followup.send(
|
|
"I tried to remove your preset color role(s) but lack permissions.",
|
|
ephemeral=True,
|
|
)
|
|
except discord.HTTPException as e:
|
|
await interaction.followup.send(
|
|
f"Failed to remove your preset color role(s): {e}",
|
|
ephemeral=True,
|
|
)
|
|
|
|
feedback_message = (
|
|
f"Your custom role color has been set to {user_color_role_data.hex_color}!"
|
|
)
|
|
if removed_preset_color_roles_names:
|
|
feedback_message += f"\nRemoved preset color role(s): {', '.join(removed_preset_color_roles_names)}."
|
|
await interaction.followup.send(feedback_message, 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 = [], [], []
|
|
removed_custom_color_feedback = "" # Initialize here
|
|
|
|
# New logic: If adding a "Colors" preset role, remove custom color role
|
|
# The preset_id for "Colors" is 'default_colors'
|
|
is_colors_preset_category = (
|
|
self.category_config.is_preset
|
|
and self.category_config.preset_id == "default_colors"
|
|
)
|
|
|
|
# A color from the "Colors" preset is being added (roles_to_add is not empty)
|
|
if is_colors_preset_category and roles_to_add:
|
|
# Ensure member and guild are valid (they should be from earlier checks in lines 262-267)
|
|
if isinstance(member, discord.Member) and guild:
|
|
existing_user_custom_color_db = db.get_user_custom_color_role(
|
|
str(guild.id), str(member.id)
|
|
)
|
|
if existing_user_custom_color_db:
|
|
custom_color_role_to_remove = guild.get_role(
|
|
int(existing_user_custom_color_db.role_id)
|
|
)
|
|
if custom_color_role_to_remove:
|
|
try:
|
|
await member.remove_roles(
|
|
custom_color_role_to_remove,
|
|
reason="User selected a preset color, removing custom color role.",
|
|
)
|
|
db.delete_user_custom_color_role(
|
|
str(guild.id), str(member.id)
|
|
) # Delete from DB
|
|
removed_custom_color_feedback = f"\n- Removed custom color role '{custom_color_role_to_remove.name}'."
|
|
except discord.Forbidden:
|
|
error_messages.append(
|
|
"Could not remove your custom color role (permissions)."
|
|
)
|
|
except discord.HTTPException as e:
|
|
error_messages.append(
|
|
f"Error removing custom color role: {e}"
|
|
)
|
|
else: # Role not found in guild, but was in DB. Clean up DB.
|
|
db.delete_user_custom_color_role(str(guild.id), str(member.id))
|
|
removed_custom_color_feedback = "\n- Your previous custom color role was not found in the server and has been cleared from my records."
|
|
|
|
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)}"
|
|
feedback += removed_custom_color_feedback # Add the custom color removal feedback here
|
|
|
|
# Adjusted condition for "no changes" message
|
|
# Ensure removed_custom_color_feedback is considered. If it has content, changes were made.
|
|
if (
|
|
not added_names
|
|
and not removed_names
|
|
and not removed_custom_color_feedback.strip()
|
|
):
|
|
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."
|
|
)
|