diff --git a/cogs/react_roles.py b/cogs/react_roles.py new file mode 100644 index 0000000..4484c90 --- /dev/null +++ b/cogs/react_roles.py @@ -0,0 +1,132 @@ +import os + +import discord +from discord.ext import commands +from data import react_roles + +from util.console import console, panel, track_iterable as track + +GUILD_IDS = [int(os.getenv("DEV_GUILD_ID"))] +REACTION_ROLE_CHANNEL_ID = int(os.getenv("REACT_ROLE_CHANNEL_ID")) + + +def update_react_role_embed(category_name): + # Generate role list + roles = react_roles.get_react_roles_by_category(category_name) + role_list = [] + for role in roles: + emoji = role[2] + description = role[3] + role_list.append(f"{emoji} - {description}") + + # Create an embed for the react-role category + embed = discord.Embed(title=f"**{category_name.capitalize()} Roles**", + description="React to this message to gain access to the relevant text and voice channels.\n\n" + "\n".join(role_list), + color=discord.Color.dark_orange()) + return embed + +class ReactRoles(commands.Cog): + def __init__(self, bot): + self.bot = bot + + # Slash command to add a new reaction role Category / Message + @commands.slash_command(name="new_react_role_category", + description="Create a new category of react roles", + guild_ids=GUILD_IDS) + @commands.has_permissions(manage_roles=True) + async def new_react_role_category(self, ctx: discord.ApplicationContext, category_name: str): + # Verify the category is unique + existing_categories = react_roles.get_react_roles_categories() + if category_name.lower() in [cat[1] for cat in existing_categories]: + await ctx.respond(f"Category '{category_name}' already exists.", ephemeral=True) + return + + # Send a new Embed message + embed = discord.Embed(title=f"**{category_name.capitalize()} Roles**", + description="React to this message to gain access to the relevant text and voice channels.", + color=discord.Color.dark_orange()) + message = await ctx.guild.get_channel(REACTION_ROLE_CHANNEL_ID).send(embed=embed) + + # Create the category in the database + react_roles.add_react_role_category_to_db(message.id, category_name) + + # Send a confirmation message + await ctx.respond(f"React role category '{category_name}' created successfully!", ephemeral=True) + + #Slash command to add a new reaction role to an existing category + @commands.slash_command(name="new_react_role", + description="Create a new react role", + guild_ids=GUILD_IDS) + @commands.has_permissions(manage_roles=True) + async def new_react_role(self, ctx: discord.ApplicationContext, category_name: str, role: discord.Role, emoji: str, description: str): + # Verify the category exists + existing_categories = react_roles.get_react_roles_categories() + if category_name.lower() not in [cat[1] for cat in existing_categories]: + await ctx.respond(f"Category '{category_name}' does not exist.", ephemeral=True) + return + + # Get the message ID of the category + message_id = next(cat[0] for cat in existing_categories if cat[1] == category_name.lower()) + + # Add the react-role to the database + react_roles.add_react_role_to_db(message_id, role.id, emoji, description) + message = await self.bot.get_channel(REACTION_ROLE_CHANNEL_ID).fetch_message(message_id) + await message.edit(embed=update_react_role_embed(category_name)) + await message.add_reaction(emoji) + + # Send a confirmation message + await ctx.respond(f"React role '{emoji}' - '{role.name}' added to category '{category_name}'.", ephemeral=True) + + + # Event listener for reaction add + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + # Check if the reaction is in the correct channel and not from the bot itself + if payload.channel_id != REACTION_ROLE_CHANNEL_ID or payload.user_id == self.bot.user.id: + return + + # Ge all react roles for the message_id that was reacted to + message_id = payload.message_id + emoji = str(payload.emoji) + react_roles_list = react_roles.get_react_roles_by_message_id(message_id) + + if react_roles_list: + for role in react_roles_list: + if role[2] == emoji: + guild = self.bot.get_guild(payload.guild_id) + member = payload.member + role_obj = guild.get_role(role[1]) + + # Add the role to the member + if role_obj not in member.roles: + await member.add_roles(role_obj) + console.log (f"[green]✔ Added role:[/] {emoji} {role_obj.name} to {member.name}") + + # Event Listener for reaction remove + @commands.Cog.listener() + async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): + # Check if the reaction is in the correct channel and not from the bot itself + if payload.channel_id != REACTION_ROLE_CHANNEL_ID or payload.user_id == self.bot.user.id: + return + + # Ge all react roles for the message_id that was reacted to + message_id = payload.message_id + emoji = str(payload.emoji) + react_roles_list = react_roles.get_react_roles_by_message_id(message_id) + + if react_roles_list: + for role in react_roles_list: + if role[2] == emoji: + guild = self.bot.get_guild(payload.guild_id) + + # Member object is not available in the payload, so we need to fetch it... + member = await guild.fetch_member(payload.user_id) + role_obj = guild.get_role(role[1]) + + # Add the role to the member + if role_obj in member.roles: + await member.remove_roles(role_obj) + console.log (f"[red]✖ Removed role:[/] {emoji} {role_obj.name} from {member.name}") + +def setup(bot): + bot.add_cog(ReactRoles(bot)) \ No newline at end of file diff --git a/data/init.py b/data/init.py new file mode 100644 index 0000000..d9b6d64 --- /dev/null +++ b/data/init.py @@ -0,0 +1,4 @@ +import data.react_roles as data_react_roles + +def init_databses(): + data_react_roles.init_db() \ No newline at end of file diff --git a/data/react_roles.py b/data/react_roles.py new file mode 100644 index 0000000..fafbdbd --- /dev/null +++ b/data/react_roles.py @@ -0,0 +1,144 @@ +import sqlite3 + +DB_PATH = 'data.db' + +def init_db(): + """ + Initialize the database and create necessary tables. + """ + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create the react_roles table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS react_roles ( + message_id INTEGER, + role_id INTEGER, + emoji TEXT, + description TEXT + ) + ''') + + # Create the react_role_categories table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS react_role_categories ( + message_id INTEGER, + category_name TEXT + ) + ''') + + conn.commit() + conn.close() + +def add_react_role_category_to_db(message_id: int, category_name: str): + """ + Add a new react role category to the database. + :param message_id: The ID of the message to which the category is associated. + :param category_name: The name of the category (e.g., "Category1"). + """ + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create the table if it doesn't exist + cursor.execute(''' + CREATE TABLE IF NOT EXISTS react_role_categories ( + message_id INTEGER, + category_name TEXT + ) + ''') + + # Insert the new react-role category into the database + cursor.execute(''' + INSERT INTO react_role_categories (message_id, category_name) + VALUES (?, ?) + ''', (message_id, category_name.lower())) + + conn.commit() + conn.close() + +def add_react_role_to_db(message_id: int, role_id: int, emoji: str, description: str): + """ + Add a new react role to the database. + :param message_id: The message ID of the react-role message. + :param role_id: The ID of the role to assign. + :param emoji: The emoji to react with. + :param description: The description of the role. + """ + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create the table if it doesn't exist + cursor.execute(''' + CREATE TABLE IF NOT EXISTS react_roles ( + message_id INTEGER, + role_id INTEGER, + emoji TEXT, + description TEXT + ) + ''') + + # Insert the new react-role into the database + cursor.execute(''' + INSERT INTO react_roles (message_id, role_id, emoji, description) + VALUES (?, ?, ?, ?) + ''', (message_id, role_id, emoji, description)) + + conn.commit() + conn.close() + +def get_react_roles_categories(): + """ + Get all react role categories from the database. + :return: A list of tuples containing message_id and category_name. + """ + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Fetch all react-role categories from the database + cursor.execute(''' + SELECT * FROM react_role_categories + ''') + categories = cursor.fetchall() + + conn.close() + return categories + +def get_react_roles_by_category(category_name: str): + """ + Get all react roles for a specific category from the database. + :param category_name: The name of the category (e.g., "Category1"). + :return: A list of tuples containing message_id, role_id, emoji, and description. + """ + # Get the message id of the category + categories = get_react_roles_categories() + message_id = next((cat[0] for cat in categories if cat[1] == category_name.lower()), None) + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Fetch all react-roles for the specified category from the database + cursor.execute(''' + SELECT * FROM react_roles WHERE message_id = ? + ''', (message_id,)) + roles = cursor.fetchall() + + conn.close() + return roles + +def get_react_roles_by_message_id(message_id: int): + """ + Get all react roles for a specific message ID from the database. + :param message_id: The ID of the message to which the roles are associated. + :return: A list of tuples containing message_id, role_id, emoji, and description. + """ + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Fetch all react-roles for the specified message ID from the database + cursor.execute(''' + SELECT * FROM react_roles WHERE message_id = ? + ''', (message_id,)) + roles = cursor.fetchall() + + conn.close() + return roles diff --git a/main.py b/main.py index 056b067..a311d37 100644 --- a/main.py +++ b/main.py @@ -6,17 +6,14 @@ from dotenv import load_dotenv import signal from pathlib import Path -from pipenv.patched.safety.safety import session -from rich.console import Console -from rich.progress import track -from rich.panel import Panel +from util.console import console, panel, track_iterable as track +from data.init import init_databses +# Load environment variables from .env file load_dotenv() TOKEN = os.getenv("BOT_TOKEN") DEV_GUILD_ID = int(os.getenv("DEV_GUILD_ID")) - -# Initialize the console for rich output -console = Console() +REACT_ROLE_CHANNEL_ID = int(os.getenv("REACT_ROLE_CHANNEL_ID")) # Set up intents for the bot intents = discord.Intents.default() @@ -30,6 +27,14 @@ COG_PATH = Path(__file__).resolve().parent / "cogs" cogs_available = {} loaded_cog_modules = {} +# Arguments used in cog loading +COG_CONFIG = { + "react_roles": { + "guild_ids": [DEV_GUILD_ID], + "react_channel_id": REACT_ROLE_CHANNEL_ID + } +} + for file in COG_PATH.iterdir(): if file.suffix == ".py" and not file.name.startswith("__"): cog_name = file.stem @@ -44,11 +49,13 @@ async def load_cogs(): console.log(f"[red]❌ Cogs directory not found at {COG_PATH}[/red]") return - console.print(Panel.fit("🔌 [bold]Loading Cogs[/bold]", style="cyan")) + console.print(panel(content="🔌 [bold]Loading Cogs[/bold]", style="cyan")) for cog in track(cogs_available, description="Loading Cogs..."): module_path = f"cogs.{cog}" + config = COG_CONFIG.get(cog, {}) + try: bot.load_extension(module_path) loaded_cog_modules[cog] = module_path @@ -74,6 +81,8 @@ async def on_ready(): await load_cogs() await bot.sync_commands() console.log("[blue]🔁 Synced slash commands[/blue]") + init_databses() + console.log("[blue]💽 Initialized databases[/blue]") console.rule(f"[bold green]✅ Bot Ready — Logged in as {bot.user}[/]") console.print(f"ID: {bot.user.id}") handle_signals() diff --git a/react_roles/react_roles.py b/react_roles/react_roles.py new file mode 100644 index 0000000..212a7bc --- /dev/null +++ b/react_roles/react_roles.py @@ -0,0 +1,17 @@ +import discord +from data import react_roles + +def update_react_role_embed(category_name): + # Generate role list + roles = react_roles.get_react_roles_by_category(category_name) + role_list = [] + for role in roles: + emoji = role[2] + description = role[3] + role_list.append(f"{emoji} - {description}") + + # Create an embed for the react-role category + embed = discord.Embed(title=f"**{category_name.capitalize()} Roles**", + description="React to this message to gain access to the relevant text and voice channels.\n\n" + "\n".join(role_list), + color=discord.Color.dark_orange()) + return embed \ No newline at end of file diff --git a/util/console.py b/util/console.py new file mode 100644 index 0000000..db3761e --- /dev/null +++ b/util/console.py @@ -0,0 +1,17 @@ +from rich.console import Console +from rich.progress import track +from rich.panel import Panel + +console = Console() + +def panel(title: str = '', content: str = '', style: str = "cyan") -> Panel: + """ + Create a panel with the given title and content. + """ + return Panel.fit(content, title=title, style=style) + +def track_iterable(iterable, description: str): + """ + Create a progress bar for the given iterable. + """ + return track(iterable, description=description) \ No newline at end of file