From c94667ee527eac26b2a832ca8629f5858241ff07 Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Thu, 11 Sep 2025 17:18:22 -0400 Subject: [PATCH 1/5] feat: Adds ModMail cog with core setup Introduces a private, forum-based ModMail system for relaying user DMs to staff threads, enhancing moderation and support through persistent message storage. Relies on 'rich' requirement for better formatting; includes metadata for easy installation and help integration. --- modmail/README.md | 0 modmail/__init__.py | 0 modmail/info.json | 16 ++++++++++++++++ modmail/modmail.py | 0 4 files changed, 16 insertions(+) create mode 100644 modmail/README.md create mode 100644 modmail/__init__.py create mode 100644 modmail/info.json create mode 100644 modmail/modmail.py diff --git a/modmail/README.md b/modmail/README.md new file mode 100644 index 0000000..e69de29 diff --git a/modmail/__init__.py b/modmail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modmail/info.json b/modmail/info.json new file mode 100644 index 0000000..995c543 --- /dev/null +++ b/modmail/info.json @@ -0,0 +1,16 @@ +{ + "author": [ "unstableCogs" ], + "install_msg": "Thank you for installing the ModMail cog! Please use `[p]help ModMail` for setup commands.", + "name": "ModMail", + "short": "A private, forum-based ModMail system.", + "description": "A comprehensive ModMail system that relays user DMs to a private forum channel for staff to review and respond to. This serves as the core for all ticket-based interactions.", + "tags": [ + "modmail", + "moderation", + "support", + "tickets", + "utility" + ], + "requirements": [ "rich" ], + "end_user_data_statement": "This cog persistently stores user IDs and message content from ModMail threads for moderation and support history." +} \ No newline at end of file diff --git a/modmail/modmail.py b/modmail/modmail.py new file mode 100644 index 0000000..e69de29 -- 2.43.0 From 5ed8002b40dd2844559607fd509b90d3e29e8642 Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Thu, 11 Sep 2025 17:21:42 -0400 Subject: [PATCH 2/5] feat: add Mass Outreach & Review System Introduces multi-step workflow for user outreach, time selection, and mass reviews with temporary data storage for process efficiency. --- mors/README.md | 0 mors/__init__.py | 0 mors/info.json | 16 ++++++++++++++++ mors/mors.py | 0 4 files changed, 16 insertions(+) create mode 100644 mors/README.md create mode 100644 mors/__init__.py create mode 100644 mors/info.json create mode 100644 mors/mors.py diff --git a/mors/README.md b/mors/README.md new file mode 100644 index 0000000..e69de29 diff --git a/mors/__init__.py b/mors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mors/info.json b/mors/info.json new file mode 100644 index 0000000..b0c5a76 --- /dev/null +++ b/mors/info.json @@ -0,0 +1,16 @@ +{ + "author": [ "unstableCogs" ], + "install_msg": "Thank you for installing the Mass Outreach & Review System! For a full command list, please see the wiki.", + "name": "MassOutreach", + "short": "A multi-step system for mass outreach and reviews.", + "description": "Manages a complete workflow for user outreach. The process begins with /game, uses /over for time selection, and concludes with a /pudding-head command for the user to submit a mass review.", + "tags": [ + "mass", + "outreach", + "review", + "tickets", + "utility" + ], + "requirements": [ "rich" ], + "end_user_data_statement": "This cog stores user IDs and ticket information temporarily for the duration of the outreach process. Submitted reviews are stored persistently." +} \ No newline at end of file diff --git a/mors/mors.py b/mors/mors.py new file mode 100644 index 0000000..e69de29 -- 2.43.0 From 859ac84b6837f9bed0f50798f4cd4fdf843877e2 Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Thu, 11 Sep 2025 17:22:36 -0400 Subject: [PATCH 3/5] feat: add password protection cog for channels Adds new Discord bot cog enabling password-protected channels. Includes verification, channel management, whitelist/blacklist, and admin password recovery features. --- pp/__init__.py | 5 + pp/info.json | 10 ++ pp/passwdprotect.py | 366 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 pp/__init__.py create mode 100644 pp/info.json create mode 100644 pp/passwdprotect.py diff --git a/pp/__init__.py b/pp/__init__.py new file mode 100644 index 0000000..6277d92 --- /dev/null +++ b/pp/__init__.py @@ -0,0 +1,5 @@ +from .passwdprotect import Passwd + +async def setup(bot): + cog = Passwd(bot) + await bot.add_cog(cog) diff --git a/pp/info.json b/pp/info.json new file mode 100644 index 0000000..a407a96 --- /dev/null +++ b/pp/info.json @@ -0,0 +1,10 @@ +{ + "author": ["kitsunic"], + "description": "A cog for password protected channels", + "end_user_data_statement": "This cog stores user, avatar, and guild data. A simple delete request to bot to remove your data or guild data", + "install_msg": "Thanks for installing & testing.", + "min_bot_version": "3.5.0", + "short": "A pwd channel protection cog", + "tags": ["embed"], + "type": "COG" +} diff --git a/pp/passwdprotect.py b/pp/passwdprotect.py new file mode 100644 index 0000000..d19e936 --- /dev/null +++ b/pp/passwdprotect.py @@ -0,0 +1,366 @@ +import discord +import hashlib +import asyncio +import random +import string +import datetime + + +from redbot.core import commands, checks, Config + + +class Passwd(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=50204090) # Unique identifier + # Default settings within the config + default_guild = { + "password_channels": {}, + "password_attempts": {}, + "admin_channel": None, + "password_recovery_requests": {}, + "channel_data": {}, + "whitelist": [], + "blacklist": [], + } + self.config.register_guild(**default_guild) + +# +# ------------------------------------------------------------------------------------------------------------- +# + @commands.group(name="passwdprotect", aliases=["pwd"]) + async def passwdprotect(self, ctx): + """Password management commands""" + if not ctx.invoked_subcommand: + await ctx.send("Missing subcommand.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @passwdprotect.command(name="verify", aliases=["ver"]) + async def verify(self, ctx, password: str): + """Verifies the password for the current channel.""" + channel = ctx.channel + + async with self.config.guild(ctx.guild).password_channels() as password_channels: + if channel.id in password_channels: + stored_hash = password_channels[channel.id]["password_hash"] + provided_hash = hashlib.sha256(password.encode("utf-8")).hexdigest() + + if stored_hash == provided_hash: + # Password verified successfully + role_id = password_channels[channel.id]["role_id"] + role = ctx.guild.get_role(role_id) + if role: + await ctx.author.add_roles(role) + await ctx.send(f"Password verified! You now have access to {channel.mention}.") + else: + await ctx.send("Password verified, but the access role seems to be missing. Please contact an admin.") + else: + await ctx.send("Incorrect password.") + else: + await ctx.send("This channel is not password protected.") + + @passwdprotect.group(name="channels", aliases=["chs", "--ch"]) + async def channels(self, ctx): + """Manage protected channels""" + if not ctx.invoked_subcommand: + await ctx.send("Invalid subcommand.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @channels.command(name="add", aliases=["+"]) + @commands.has_permissions(manage_channels=True) + async def channels_add(self, ctx, channel: discord.TextChannel, password: str): + """Adds a password to a channel""" + hashed_password = self.generate_hashed_password(password) + async with self.config.guild(ctx.guild).password_channels() as password_channels: + # Check if the channel already has a password + if channel.id in password_channels: + await ctx.send(f"{channel.mention} already has a password. Use the `remove` command first.") + return + + # Create the role + role_name = f"{channel.name}_access" + role = await ctx.guild.create_role(name=role_name) + + # Grant permissions to the role + await channel.set_permissions(role, view_channel=True, send_messages=True) + await channel.set_permissions(ctx.guild.default_role, view_channel=False, read_message_history=False) # Restrict access for default role + + # Store password, channel ID, and role ID in the config + password_channels[channel.id] = { + "password_hash": hashed_password, + "role_id": role.id + } + + await ctx.send(f"Password set for {channel.mention}") +# +# ------------------------------------------------------------------------------------------------------------- +# + @channels.command(name="remove", aliases=["-"]) + @commands.has_permissions(manage_channels=True) + async def channels_remove(self, ctx, channel: discord.TextChannel): + """Removes the password from a text channel.""" + async with self.config.guild(ctx.guild).password_channels() as password_channels: + if channel.id in password_channels: + # Delete the channel's data from the config + del password_channels[channel.id] + + # Delete the associated role (optional) + role_id = channel.get("role_id") + if role_id: + role = ctx.guild.get_role(role_id) + if role: + await role.delete() + + await ctx.send(f"Password removed from {channel.mention}") + else: + await ctx.send(f"{channel.mention} does not have a password.") + + @channels.command(name="fails", aliases=["--fail"]) + @commands.has_permissions(manage_guild=True) + async def channels_fails(self, ctx, limit: int = None): + """Sets or checks the maximum failed password attempts.""" + # ... handle failed attempt limit ... +# +# ------------------------------------------------------------------------------------------------------------- +# + @channels.command(name="whitelist", aliases=["wlist"]) + @commands.has_permissions(manage_guild=True) + async def channels_white(self, ctx, subcommand: str, user_or_role: discord.Object = None): + """Manages the whitelist.""" + guild_id = str(ctx.guild.id) + password_collection = self.get_password_collection(guild_id) + + if not user_or_role: + await ctx.send("Please specify a user or role to add/remove.") + return + + if subcommand == "add": + await self.add_to_whitelist(password_collection, user_or_role) + elif subcommand == "remove": + await self.remove_from_whitelist(password_collection, user_or_role) + else: + await ctx.send("Invalid subcommand. Use `add` or `remove`.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @channels.command(name="blacklist", aliases=["blist"]) + @commands.has_permissions(manage_guild=True) + async def channels_black(self, ctx, subcommand: str, user_or_role: discord.Object = None): + """Manages the blacklist.""" + if not user_or_role: + await ctx.send("Please specify a user or role to add/remove.") + return + + if subcommand == "add": + await self.add_to_blacklist(ctx, user_or_role) + elif subcommand == "remove": + await self.remove_from_blacklist(ctx, user_or_role) + else: + await ctx.send("Invalid subcommand. Use `add` or `remove`.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @passwdprotect.group(name="admin", aliases=["adm"]) + async def admin(self, ctx): + """Admin commands for password protection""" + if not ctx.invoked_subcommand: + await ctx.send("Missing subcommand.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @admin.command(name="recover", aliases=["rec"]) + async def admin_recover(self, ctx): + """Initiates password recovery process.""" + requests = await self.config.guild(ctx.guild).password_recovery_requests() + if not requests: + await ctx.send("No pending password recovery requests.") + return + + admin_channel_id = await self.config.guild(ctx.guild).admin_channel() + if not admin_channel_id: + await ctx.send("Admin notification channel not set. Use `[p]passwdprotect admin notify` to set it.") + return + + admin_channel = self.bot.get_channel(admin_channel_id) + # Add Approve/Deny buttons using View + view = ui.View() + view.add_item(ui.Button(style=discord.ButtonStyle.green, label="Approve", custom_id=f"approve_{user_id}_{channel.id}")) + view.add_item(ui.Button(style=discord.ButtonStyle.red, label="Deny", custom_id=f"deny_{user_id}_{channel.id}")) + + for user_id, request_data in requests.items(): + user = self.bot.get_user(int(user_id)) + channel = self.bot.get_channel(request_data["channel_id"]) + timestamp = request_data["timestamp"] + + # Log the request to the admin channel + embed = discord.Embed(title="Password Recovery Request", color=discord.Color.gold()) + embed.add_field(name="User", value=user.mention if user else f"User ID: {user_id}", inline=False) + embed.add_field(name="Server", value=ctx.guild.name, inline=False) + embed.add_field(name="Channel", value=channel.mention if channel else f"Channel ID: {request_data['channel_id']}", inline=False) + embed.add_field(name="Highest Role", value=user.top_role.mention if user else "Unknown", inline=False) + embed.set_footer(text=f"Requested at {timestamp}") + + # Add Approve/Deny buttons + view = SimpleView() + view.add_item(Button(style=discord.ButtonStyle.green, label="Approve", custom_id=f"approve_{user_id}_{channel.id}")) + view.add_item(Button(style=discord.ButtonStyle.red, label="Deny", custom_id=f"deny_{user_id}_{channel.id}")) + + # Ping the role (if set) in the admin channel + role_to_ping = await self.config.guild(ctx.guild).get_raw("admin_role") # Assuming you store the role ID in config + if role_to_ping: + await admin_channel.send(f"<@&{role_to_ping}>", embed=embed, view=view) + else: + await admin_channel.send(embed=embed, view=view) +# +# ------------------------------------------------------------------------------------------------------------- +# + @admin.command(name="notify", aliases=["noti"]) + @commands.has_permissions(manage_guild=True) + async def admin_notify(self, ctx, channel: discord.TextChannel = None): + """Sets or removes the admin notification channel.""" + if channel: + # Set the admin notification channel + await self.config.guild(ctx.guild).admin_channel.set(channel.id) + await ctx.send(f"Admin notification channel set to {channel.mention}") + else: + # Remove the admin notification channel + await self.config.guild(ctx.guild).admin_channel.set(None) + await ctx.send("Admin notification channel removed.") +# +# ------------------------------------------------------------------------------------------------------------- +# + @commands.Cog.listener() + async def on_interaction(self, interaction, channel: discord.TextChannel): + if interaction.type == discord.InteractionType.component: + custom_id = interaction.data["custom_id"] + if custom_id.startswith("approve_") or custom_id.startswith("deny_"): + action, user_id, channel_id = custom_id.split("_") + user_id = int(user_id) + channel_id = int(channel_id) + + async with self.config.guild(interaction.guild).password_recovery_requests() as requests: + if user_id in requests: + del requests[user_id] # Remove the request + + if action == "approve": + temp_code = self.generate_temporary_access_code() # Implement this function + expiry_time = datetime.datetime.now() + datetime.timedelta(hours=1) # 1-hour validity + + async with self.config.guild(interaction.guild).temp_access_codes() as temp_codes: + temp_codes[temp_code] = {"user_id": user_id, "channel_id": channel_id, "expiry": expiry_time.isoformat()} + + user = self.bot.get_user(user_id) + if user: + await user.send(f"Here's your temporary access code for channel {channel.mention}: `{temp_code}`. It's valid for 1 hour.") + elif user: + await interaction.response.send_message(f"Approved password recovery for {user.mention if user else user_id} on channel {channel.mention}. Temporary code sent.", ephemeral=True) + else: + await interaction.response.send_message("Channel data not found. Cannot reset password.", ephemeral=True) +# +# ------------------------------------------------------------------------------------------------------------- +# + # Helper functions + def generate_hashed_password(self, password): + # This remains the same, no changes needed + return hashlib.sha256(password.encode("utf-8")).hexdigest() +# +# ------------------------------------------------------------------------------------------------------------- +# + async def set_channel_password(self, ctx: commands.Context, channel_id, password): + """Sets the password for a channel using the config. + + Args: + ctx (commands.Context): The command context to access guild information. + channel_id (int): The ID of the channel. + password (str): The password to set. + """ + hashed_password = self.generate_hashed_password(password) + async with self.config.guild(ctx.guild).password_channels() as password_channels: + password_channels[channel_id] = { + "password_hash": hashed_password + } +# +# ------------------------------------------------------------------------------------------------------------- +# + def generate_temporary_access_code(self): + """Generates a temporary access code.""" + characters = string.ascii_letters + string.digits + code = ''.join(random.choice(characters) for i in range(10)) # 10-character code + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + return f"{code}-{timestamp}" +# +# ------------------------------------------------------------------------------------------------------------- +# + async def add_to_whitelist(self, ctx, collection, user_or_role): + """Adds a user or role to the whitelist.""" + async with self.config.guild(ctx.guild).whitelist() as whitelist: + user_or_role_id = user_or_role.id + if any(item["id"] == user_or_role_id for item in whitelist): + await ctx.send(f"{user_or_role} is already in the whitelist.") + return + + whitelist.append({"id": user_or_role_id, "type": "user" if isinstance(user_or_role, discord.Member) else "role"}) + + embed = discord.Embed(title="Whitelist Confirmation", description=f"Are you sure you want to add {user_or_role} to the whitelist?") + embed.add_field(name="Action", value="Add to Whitelist") + embed.set_footer(text="React with ✅ to confirm or ❌ to cancel.") + confirmation_msg = await ctx.send(embed=embed) + + try: + reaction, user = await ctx.wait_for('reaction_add', timeout=30.0, check=lambda r, u: r.message.id == confirmation_msg.id and u == ctx.author and str(r.emoji) in ['✅', '❌']) + if str(reaction.emoji) == '✅': + await confirmation_msg.delete() + await ctx.send(f"{user_or_role} added to the whitelist.") + else: + await confirmation_msg.delete() + await ctx.send("Action cancelled.") + except asyncio.TimeoutError: + await confirmation_msg.delete() + await ctx.send("Confirmation timed out. Action cancelled.") +# +# ------------------------------------------------------------------------------------------------------------- +# + async def remove_from_whitelist(self, ctx, collection, user_or_role): + """Removes a user or role from the whitelist.""" + async with self.config.guild(ctx.guild).whitelist() as whitelist: + user_or_role_id = user_or_role.id + whitelist[:] = [item for item in whitelist if item["id"] != user_or_role_id] + await ctx.send(f"{user_or_role} removed from the whitelist.") +# +# ------------------------------------------------------------------------------------------------------------- +# + async def add_to_blacklist(self, ctx, user_or_role): + """Adds a user or role to the blacklist.""" + async with self.config.guild(ctx.guild).blacklist() as blacklist: + user_or_role_id = user_or_role.id + if any(item["id"] == user_or_role_id for item in blacklist): + await ctx.send(f"{user_or_role} is already in the blacklist.") + return + + blacklist.append({"id": user_or_role_id, "type": "user" if isinstance(user_or_role, discord.Member) else "role"}) + + await ctx.send(f"{user_or_role} added to the blacklist.") +# +# ------------------------------------------------------------------------------------------------------------- +# + async def remove_from_blacklist(self, ctx, user_or_role): + """Removes a user or role from the blacklist.""" + async with self.config.guild(ctx.guild).blacklist() as blacklist: + user_or_role_id = user_or_role.id + blacklist[:] = [item for item in blacklist if item["id"] != user_or_role_id] + await ctx.send(f"{user_or_role} removed from the blacklist.") +# +# ------------------------------------------------------------------------------------------------------------- +# + async def cog_load(self): + self.bot.add_listener(self.on_cog_reload_error, "Package loading failed") + self.bot.add_listener(self.on_cog_reload_error, "SyntaxError:") + self.bot.add_listener(self.on_cog_reload_error, "IndentationError:") +# +# ------------------------------------------------------------------------------------------------------------- +# + async def on_cog_reload_error(self, ctx, error): + error_message = f"Error reloading cog '{ctx.cog.qualified_name}':\n```{error}```" + self.error_logs.append(error_message) \ No newline at end of file -- 2.43.0 From e0958f6f2a0aadedf6309f6781ff982744c9bb2f Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Thu, 11 Sep 2025 17:23:45 -0400 Subject: [PATCH 4/5] feat: Add RPG cog with character and inventory features Sets up RPG system as a Redbot cog with core mechanics Implements character creation, class selection, and stat management Adds action commands for attacking and healing Provides inventory and shop functionality for item management Introduces interactive UI menus for user engagement --- rpg/__init__.py | 6 ++ rpg/actions.py | 126 +++++++++++++++++++++++++++++++ rpg/check.py | 94 ++++++++++++++++++++++++ rpg/commands.py | 21 ++++++ rpg/info.json | 11 +++ rpg/inventory.py | 80 ++++++++++++++++++++ rpg/menu.py | 55 ++++++++++++++ rpg/rpg.py | 187 +++++++++++++++++++++++++++++++++++++++++++++++ rpg/shop.py | 176 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 756 insertions(+) create mode 100644 rpg/__init__.py create mode 100644 rpg/actions.py create mode 100644 rpg/check.py create mode 100644 rpg/commands.py create mode 100644 rpg/info.json create mode 100644 rpg/inventory.py create mode 100644 rpg/menu.py create mode 100644 rpg/rpg.py create mode 100644 rpg/shop.py diff --git a/rpg/__init__.py b/rpg/__init__.py new file mode 100644 index 0000000..a963130 --- /dev/null +++ b/rpg/__init__.py @@ -0,0 +1,6 @@ +from .rpg import RPG + +async def setup(bot): + cog = RPG(bot) + await bot.add_cog(cog) + await bot.add_cog(cog.commands) \ No newline at end of file diff --git a/rpg/actions.py b/rpg/actions.py new file mode 100644 index 0000000..29472be --- /dev/null +++ b/rpg/actions.py @@ -0,0 +1,126 @@ +import asyncio +import random +import discord +from .check import Check +from redbot.core import commands, Config +from .inventory import RPGInventory +from typing import Literal + +class RPGActions: + """ + Implements the core RPG mechanics and actions. + """ + + def __init__(self, rpg_cog): + self.rpg_cog = rpg_cog + self.inventory = RPGInventory(rpg_cog) + + async def create_character(self, interaction: discord.Interaction, user, character_name): + """ + Creates a new character for the user. + """ + + # Character Name Validation + check = Check(interaction, length=30) + if not check.length_under(interaction.message) or not character_name.isalnum(): + return await self.rpg_cog.send_message(interaction, "Invalid character name. Please choose a name between 2 and 30 characters long, containing only letters and numbers.") + + # Retrieve the 'characters' group + characters_group = self.rpg_cog.config.member(user).characters + + # Then, get all characters within that group (await the .all() call) + existing_characters = await characters_group.all() + + # Check for duplicate character names (case-insensitive) + if any(existing_name.lower() == character_name.lower() for existing_name in existing_characters): + return await self.rpg_cog.send_message(interaction, f"You already have a character named '{character_name}'. Choose another name.") + + # Retrieve available classes from config + try: + available_classes = await self.rpg_cog.config.guild(user.guild).get_raw("classes", default=[]) + except KeyError: + return await self.rpg_cog.send_message(interaction, "No classes have been configured yet. Please contact an admin.") + + if not available_classes: + return await self.rpg_cog.send_message(interaction, "No classes are available yet. Please contact an admin.") + + # Prompt user to choose a class + class_options = "\n".join([f"{i+1}. {class_name}" for i, class_name in enumerate(available_classes)]) + await self.rpg_cog.send_message(interaction, f"Choose a class for your character:\n{class_options}") + + def check(m): + return m.author == user and m.channel == interaction.channel and m.content.isdigit() and 1 <= int(m.content) <= len(available_classes) + + try: + class_choice = await self.rpg_cog.bot.wait_for("message", check=check, timeout=30.0) + except asyncio.TimeoutError: + return await self.rpg_cog.send_message(interaction, "Class selection timed out. Character creation canceled.") + + selected_class = available_classes[int(class_choice.content) - 1] + + # Retrieve default stats for the selected class + try: + default_stats = await self.rpg_cog.config.guild(user.guild).get_raw("characters", "classes", selected_class, "stats") + except KeyError: + return await self.rpg_cog.send_message(interaction, f"Default stats for class '{selected_class}' haven't been configured yet. Please contact an admin.") + + # Create character data + character_data = { + "name": character_name, + "class": [selected_class], # Store the selected class + "level": 1, + "experience": 0, + "stats": default_stats.copy(), + "inventory": [], + "equipment": {}, + "skills": [], + "gold": 0, + "max_health": 100, + "health": 100, + "mana": 50, + "max_mana": 50, + } + + # Store character data in config + await characters_group.set_raw(character_name, value=character_data) + await self.rpg_cog.config.member(user).active_character.set(character_name) + + await interaction.response.send_message(interaction, f"Character '{character_name}' (Class: {selected_class}) created for {user.mention}!") + + async def attack(self, ctx, attacker, target): + """ + Handles the attack action. + """ + # Check if attacker and target have active characters + if not await self._has_active_character(ctx, attacker): + return + if not await self._has_active_character(ctx, target): + return + + attacker_data = await self.rpg_cog.config.member(attacker).active_character + target_data = await self.rpg_cog.config.member(target).active_character + + # ... (rest of the attack logic) + + async def heal(self, ctx, user): + """ + Handles the heal action. + """ + # ... (rest of the heal logic) + + # Helper function to check if a user has an active character + async def _has_active_character(self, ctx, user): + active_character = await self.rpg_cog.config.member(user).active_character + if not active_character: + await self.rpg_cog.send_message(ctx, f"{user.mention}, you don't have an active character. Create one using `[p]create_character `.") + return False + return True + + async def use_item(self, ctx, user, item_name): + """ + Handles the use of an item from the inventory. + """ + # ... (logic to check if the user has the item and handle its effects) + + # Remove the used item from the inventory + await self.inventory.remove_item(ctx, user, item_name) \ No newline at end of file diff --git a/rpg/check.py b/rpg/check.py new file mode 100644 index 0000000..e096dc2 --- /dev/null +++ b/rpg/check.py @@ -0,0 +1,94 @@ +from redbot.core import Config, commands +from collections.abc import Iterable +import validators as vals +import logging +import discord + + +log = logging.getLogger("red.rpg") + +class Check: + + def __init__(self, ctx_or_interaction, custom: Iterable = None, length: int = None): + self.ctx_or_interaction = ctx_or_interaction # Store the context or interaction object + self.custom = custom + self.length = length + + def _get_author(self): + """ + Helper function to get the author from either ctx or interaction. + """ + if isinstance(self.ctx_or_interaction, commands.Context): + return self.ctx_or_interaction.author + elif isinstance(self.ctx_or_interaction, discord.Interaction): + return self.ctx_or_interaction.user + else: + log.error(f"Unexpected object type in Check._get_author: {type(self.ctx_or_interaction)}") + return None + + def same(self, m): + author = self._get_author() + if author is None: + return False # Handle the case where author couldn't be determined + + if isinstance(m, discord.Message): + return author == m.author + elif isinstance(m, discord.Interaction): + return author == m.user + else: + log.error(f"Unexpected object type in Check.same: {type(m)}") + return False + + + def comfirm(self, m): + return self.same(m) and m.content.lower() in ("yes", "no") + + def valid_int(self, m): + return self.same and m.content.isdigit() + + def valid_float(self, m): + try: + return self.same(m) and float(m.content) >= 1 + except ValueError: + return False + + def positive(self, m): + return self.same(m) and m.content.isdigit() and int(m.content) > 0 + + def role(self, m): + roles = [r.name for r in self.ctx.guild.roles if r.name != "Bot"] + return self.same(m) and m.content in roles + + def member(self, m): + return self.same(m) and m.content in [x.name for x in self.ctx.guild.members] + + def length_under(self, m): + try: + if isinstance(m, discord.Message): + content = m.content + elif isinstance(m, discord.Interaction): + content = m.data['components'][0]['components'][0]['value'] # Access modal input value + else: + raise ValueError("Unsupported object type for length_under check") + + return self.same(m) and len(content) <= self.length + except TypeError: + raise ValueError("Length was not specified in Check") + + def valid_image_url(self, m): + url = m.content.strip() + + if not vals.url(url): + return False + + valid_extensions = (".jpg", ".jpeg", ".png", ".gif") + if not any(url.lower().endswith(ext) for ext in valid_extensions): + return False + + return True + + def content(self, m): + try: + return self.same(m) and m.content in self.custom + except TypeError: + raise ValueError("A custom iterable was not set in Check") \ No newline at end of file diff --git a/rpg/commands.py b/rpg/commands.py new file mode 100644 index 0000000..f7ac838 --- /dev/null +++ b/rpg/commands.py @@ -0,0 +1,21 @@ +import discord +from redbot.core import commands +from .menu import RPGMenu + +class RPGCommands(commands.Cog): + """ + Handles user commands (primarily menus) for the RPG system. + """ + + def __init__(self, rpg_cog, bot): + self.rpg_cog = rpg_cog + self.bot = bot + super().__init__() + + @commands.command(name="menu") + async def menu_command(self, ctx): + """ + Display the RPG menu. + """ + view = RPGMenu(self.rpg_cog) + await ctx.send("Choose an action:", view=view) \ No newline at end of file diff --git a/rpg/info.json b/rpg/info.json new file mode 100644 index 0000000..253e79d --- /dev/null +++ b/rpg/info.json @@ -0,0 +1,11 @@ +{ + "author" : ["UnstableKitsune (unstablekitsune)"], + "install _msg" : "Oh you installed me! How dare you obtain me! return me right now!", + "name" : "RPG", + "short" : "Its a TTRPG", + "requirements" : [""], + "permissions" : [""], + "tags" : [""], + "min_python_version" : [3, 1, 1], + "end_user_data_statement" : "" +} \ No newline at end of file diff --git a/rpg/inventory.py b/rpg/inventory.py new file mode 100644 index 0000000..87c8580 --- /dev/null +++ b/rpg/inventory.py @@ -0,0 +1,80 @@ +import discord +from redbot.core import commands, Config + +class RPGInventory: + """ + Manages character inventories for the RPG system. + """ + + def __init__(self, rpg_cog): + self.rpg_cog = rpg_cog + super().__init__() + + @commands.group() + async def inventory(self, ctx): + """Main RPG command group.""" + pass + + @inventory.command(name="add") + async def add_item(self, ctx, user: discord.Member, item_name, quantity=1): + """ + Adds an item to the user's active character's inventory. + """ + character_data = await self.rpg_cog.config.member(user).get_raw("active_character") + if not character_data: + return await self.rpg_cog.send_message(ctx, f"{user.mention}, you don't have an active character.") + + inventory = character_data.get("inventory", {}) + if item_name in inventory: + inventory[item_name] += quantity + else: + inventory[item_name] = quantity + + await self.rpg_cog.config.member(user).set_raw("active_character", "inventory", value=inventory) + await self.rpg_cog.send_message(ctx, f"{quantity} {item_name}(s) added to your inventory!") + + @inventory.command(name="remove") + async def remove_item(self, ctx, user: discord.Member, item_name, quantity=1): + """ + Removes an item from the user's active character's inventory. + """ + character_data = await self.rpg_cog.config.member(user).get_raw("active_character") + if not character_data: + return await self.rpg_cog.send_message(ctx, f"{user.mention}, you don't have an active character.") + + inventory = character_data.get("inventory", {}) + if item_name in inventory: + if inventory[item_name] >= quantity: + inventory[item_name] -= quantity + if inventory[item_name] == 0: + del inventory[item_name] + await self.rpg_cog.config.member(user).set_raw("active_character", "inventory", value=inventory) + await self.rpg_cog.send_message(ctx, f"{quantity} {item_name}(s) removed from your inventory!") + else: + await self.rpg_cog.send_message(ctx, f"You don't have enough {item_name}s.") + else: + await self.rpg_cog.send_message(ctx, f"You don't have any {item_name}s in your inventory.") + + @inventory.command(name="bag") + async def get_inventory(self, user): + """ + Retrieves the inventory of the user's active character. + """ + character_data = await self.rpg_cog.config.member(user).get_raw("active_character") + if not character_data: + return None # Or handle the case where the user has no active character + return character_data.get("inventory", {}) + + @inventory.command(name="showbag") + async def display_inventory(self, ctx, user: discord.Member): + """ + Displays the user's inventory in a user-friendly format. + """ + inventory = await self.get_inventory(user) + if not inventory: + return await self.rpg_cog.send_message(ctx, f"{user.mention}, your inventory is empty!") + + # Format the inventory for display (you can customize this) + inventory_str = "\n".join([f"{item}: {quantity}" for item, quantity in inventory.items()]) + embed = discord.Embed(title=f"{user.name}'s Inventory", description=inventory_str) + await self.rpg_cog.send_message(ctx, embed=embed) \ No newline at end of file diff --git a/rpg/menu.py b/rpg/menu.py new file mode 100644 index 0000000..7f4eb47 --- /dev/null +++ b/rpg/menu.py @@ -0,0 +1,55 @@ +import discord +from .check import Check + +class RPGMenu(discord.ui.View): + def __init__(self, rpg_cog): + super().__init__() + self.rpg_cog = rpg_cog + + @discord.ui.button(label="Create Character", style=discord.ButtonStyle.primary, custom_id="create_character_button") + async def create_character_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(CreateCharacterModal(self.rpg_cog)) + + @discord.ui.button(label="Attack", style=discord.ButtonStyle.red, custom_id="attack_button") + async def attack_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + # You'll need to implement the target selection logic here (e.g., using a dropdown or another interaction) + target = None # Placeholder for now + if target: + await self.rpg_cog.actions.attack(interaction, interaction.user, target) + else: + await interaction.response.send_message("You need to select a target to attack.", ephemeral=True) + + @discord.ui.button(label="Heal", style=discord.ButtonStyle.green, custom_id="heal_button") + async def heal_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.rpg_cog.actions.heal(interaction, interaction.user) + + @discord.ui.button(label="Stats", style=discord.ButtonStyle.blurple, custom_id="stats_button") + async def stats_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.rpg_cog.actions.display_stats(interaction, interaction.user) + + @discord.ui.button(label="Inventory", style=discord.ButtonStyle.grey, custom_id="inventory_button") + async def inventory_button_callback(self, interaction: discord.Interaction, button: discord.ui.Button): + await self.rpg_cog.inventory.display_inventory(interaction, interaction.user) + + +class CreateCharacterModal(discord.ui.Modal): + def __init__(self, rpg_cog): + super().__init__(title="Create Character") + self.rpg_cog = rpg_cog + self.character_name = discord.ui.TextInput( + label="Enter your character's name:", + placeholder="e.g., BraveAdventurer", + required=True, + max_length=30 + ) + self.add_item(self.character_name) + + async def on_submit(self, interaction: discord.Interaction): + character_name = self.character_name.value + #author = interaction.user + + try: + await self.rpg_cog.actions.create_character(interaction, interaction.user, character_name) + except Exception as e: + await interaction.response.send_message(f"```An error occurred while creating your character: {e}```") + self.rpg_cog.log.error(f"Error in create_character: {e}") \ No newline at end of file diff --git a/rpg/rpg.py b/rpg/rpg.py new file mode 100644 index 0000000..336c0fa --- /dev/null +++ b/rpg/rpg.py @@ -0,0 +1,187 @@ +from atexit import register +import logging +import discord + +from distutils import config +from redbot.core import commands, Config +from .check import Check +from .commands import RPGCommands +from .actions import RPGActions +from .shop import RPGShops +from .menu import RPGMenu +from .inventory import RPGInventory + + +class RPG(commands.Cog): + def __init__(self, bot): + self.bot = bot + self.menu = RPGMenu(self) + self.commands = RPGCommands(self, bot) + self.actions = RPGActions(self) + self.shop = RPGShops(self) + self.inventory = RPGInventory(self) + self.log = logging.getLogger("red.rpg") + self.config = Config.get_conf(self, identifier=6857309412) + + default_guild_settings = { + "characters": { + "default_stats": { # Nested under 'characters' + "Strength": 1, + "Defense": 1, + "Agility": 1, + "Magic": 1, + "Luck": 1, + "Health": 100, + "Mana": 50, + "Stamina": 50, + "Experience": 0, + "Gold": 0, + "Damage": 1, + "Recovery": 1, + "Resist": 1, + "Critical": 1, + "Speed": 1, + "Level": 1, + "Class": [], + "Skills": [], + "Inventory": [], + "Equipment": {}, + "Quests": [], + "Guild": None, + "Location": None, + "Followers": None, + "Pets": None, + }, + "default_statsmax": { # Nested under 'characters' + "Strength": 999, + "Defense": 999, + "Agility": 999, + "Magic": 999, + "Luck": 999, + "Health": 999, + "Mana": 999, + "Stamina": 999, + "Experience": 999999999, + "Gold": 999999999, + "Damage": 999, + "Recovery": 999, + "Resist": 999, + "Critical": 999, + "Speed": 999, + "Level": 999999999, + } + }, + "classes": { # Definitions of available classes + "Warrior": { + "stats": { + "Strength": 5, + "Defense": 4, + "Agility": 3, + # ... other stats with appropriate values for a warrior + }, + "description": "A brave and sturdy warrior, skilled in melee combat." + }, + "Mage": { + "stats": { + "Strength": 2, + "Defense": 2, + "Magic": 5, + # ... other stats with appropriate values for a mage + }, + "description": "A wise and powerful mage, capable of wielding arcane magic." + }, + "Rogue": { + "stats": { + "Strength": 3, + "Defense": 3, + "Agility": 5, + # ... other stats with appropriate values for a rogue + }, + "description": "A nimble and cunning rogue, adept at stealth and trickery." + } + # ... add more classes as needed + }, + "active_character": {}, # Store the name of the currently active character for each member + } + + + default_user_settings = { + "active_character": None, + } + + default_shop = { + "Items": [], + "Prices": [], + "Currency": "Gold", + "Currency_Symbol": "G", + "Currency_Plural": "Gold", + "Currency_Plural_Symbol": "G", + "Currency_Emoji": "💰", + } + + default_quest = { + "Name": None, + "Description": None, + "Reward": None, + "Requirements": None, + "Progress": None, + "Status": None, + "Type": None, + "Repeat": None, + "Time": None, + "Location": None, + "Guild": None, + "Followers": None, + "Pets": None, + } + self.config.register_guild(**default_guild_settings) + self.config.register_user(**default_user_settings) + self.config.register_global(**default_shop) + self.config.register_global(**default_quest) + + + self.commands = RPGCommands(self, bot) + self.actions = RPGActions(self) + self.shop = RPGShops(self) + self.menu = RPGMenu(self) + self.inventory = RPGInventory(self) + + async def register_guild(self, ctx): + await self.config.register_guild(ctx.guild, **default_guild_settings) + await self.send_message(ctx, "Guild registered for RPG system.") + + async def register_user(self, ctx): + await self.config.register_user(ctx.author, **default_user_settings) + await self.send_message(ctx, "User registered for RPG system.") + + async def register_shop(self, ctx): + await self.config.register_global(**default_shop) + await self.send_message(ctx, "Shop registered for RPG system.") + + async def register_quest(self, ctx): + await self.config.register_global(**default_quest) + await self.send_message(ctx, "Quest registered for RPG system.") + + async def unregister_guild(self, ctx): + await self.config.unregister_guild(ctx.guild) + await self.send_message(ctx, "Guild unregistered for RPG system.") + + async def unregister_user(self, ctx): + await self.config.unregister_user(ctx.author) + await self.send_message(ctx, "User unregistered for RPG system.") + + async def unregister_shop(self, ctx): + await self.config.unregister_global("Shop") + await self.send_message(ctx, "Shop unregistered for RPG system.") + + async def unregister_quest(self, ctx): + await self.config.unregister_global("Quest") + await self.send_message(ctx, "Quest unregistered for RPG system.") + + async def send_message(self, ctx, message, interaction, embed=None): + if embed: + await ctx.send(message, embed=embed) + elif interaction: + await interaction.response.send(message) + else: + await ctx.send(message) \ No newline at end of file diff --git a/rpg/shop.py b/rpg/shop.py new file mode 100644 index 0000000..27b1995 --- /dev/null +++ b/rpg/shop.py @@ -0,0 +1,176 @@ +from ast import alias +from enum import member +from locale import currency +import discord +from redbot.core import commands +from .inventory import RPGInventory + + +class RPGShops(commands.Cog): + + def __init__(self, rpg_cog): + self.rpg_cog = rpg_cog + self.config = self.rpg_cog + + default_guild = { + "shops": {}, + "status": { + "active": bool, + "deactive": bool, + }, + "currency": { + "name": str, + "symbol": str, + "emoji": str, + } + } + #self.config.register_guild(**default_guild_settings) + +# ----------------------------------------------------------------------------------------- +# --------------- SHOP GROUP COMMANDS ----------------------------------------------------- +# ----------------------------------------------------------------------------------------- + + @commands.group(name="shop", alias=["sp"]) + async def shop(self, ctx): + pass + + # Comes from Base Shop Group Above + @shop.group(name="manager", alias=["mgr"]) + async def manager(self, ctx): + pass + + # Comes from Manager Group Above + @manager.group(name="set", alias=["st"]) + async def set(self, ctx): + pass + + # Comes From Set Group From Above + @set.group(name="status", alias=["sta"]) + async def status(self, ctx): + pass + + # Comes From Set Group From Above + @set.group(name="currency", alias=["cur"]) + async def currency(self, ctx): + pass + +# ----------------------------------------------------------------------------------------- +# ------------------ SHOP BASE COMMANDS -------------------------------------------------- +# ----------------------------------------------------------------------------------------- + + @shop.command(name="buy", alias=["by"]) # shop buy + async def buy(self, ctx, shop_name: str, item_name: str, quantity: int = 1): + """ + Buy an item from the shop. + """ + # Retrieve shop data + shops = await self.config.guild(ctx.guild).shops.all() + if shop_name not in shops: + return await ctx.send(f"Shop '{shop_name}' not found.") + + shop_data = shops[shop_name] + items = shop_data.get("items", {}) # Assuming you store items in an 'items' dictionary within the shop data + if item_name not in items: + return await ctx.send(f"Item '{item_name}' not found in '{shop_name}'.") + + item_data = items[item_name] + price = item_data.get("price") + stock = item_data.get("quantity") + + # Check if the shop has enough stock + if stock is not None and stock < quantity: + return await ctx.send(f"Not enough '{item_name}' in stock. Only {stock} available.") + + # Check if the user has enough currency + currency_name = await self.config.guild(ctx.guild).shops_currency_name.get() + user_balance = await self.rpg_cog.config.member(ctx.author).get_raw("gold") # Assuming gold is the currency + total_cost = price * quantity + if user_balance < total_cost: + return await ctx.send(f"You don't have enough {currency_name} to buy {quantity} {item_name}(s).") + + # Deduct currency and add item to inventory + await self.rpg_cog.config.member(ctx.author).set_raw("gold", value=user_balance - total_cost) + await self.rpg_cog.inventory.add_item(ctx, ctx.author, item_name, quantity) + + # Update shop inventory (if applicable) + if stock is not None: + shop_data["items"][item_name]["quantity"] -= quantity + await self.config.guild(ctx.guild).shops.set_raw(shop_name, value=shop_data) + + await ctx.send(f"You bought {quantity} {item_name}(s) for {total_cost} {currency_name}!") + + @shop.command(name="sell", alias=["sl"]) # shop sell + async def buy(self, ctx, item_name: str): + """ + Sell an item back to the shop. + """ + # ... (logic to handle the purchase and currency) + + # Add the purchased item to the user's inventory + await self.inventory.remove_item(ctx, ctx.author, item_name) + + @shop.command(name="give", alias=["gi"]) # shop give + async def give(self, ctx, item_name: str, target: member): + """ + Give an item to another player. + """ + # ... (logic to handle the transfer of items between players) + await self.inventory.remove_item(ctx, ctx.author, item_name) + await self.inventory.add_item(ctx, target, item_name) + + @shop.command(name="list", alias=["ls"]) # shop list + async def list(self, ctx, shop_name: str): + """ + List the items available in a shop. + """ + # ... (logic to retrieve and display the items in the shop) + items = await self.rpg_cog.config.guild(ctx.guild).shops_items.get_raw(shop_name) + prices = await self.rpg_cog.config.guild(ctx.guild).shops_prices.get_raw(shop_name) + + if items: + item_list = "\n".join([f"{item} - {price}" for item, price in zip(items, prices)]) + await ctx.send(f"Items available in {shop_name}:\n{item_list}") + else: + await ctx.send(f"No items available in {shop_name}.") + +# ----------------------------------------------------------------------------------------- +# ------------------------- Manager Base Commands ----------------------------------------- +# ----------------------------------------------------------------------------------------- + + @manager.command(name="create", alias=["cr"]) + async def create_shop(self, ctx, shop_name: str, *, description: str): + await ctx.send(f"Shop '{shop_name}' created!") + + @manager.command(name="delete", alias=["del"]) + async def delete_shop(self, ctx, shop_name: str): + await ctx.send(f"Shop '{shop_name}' deleted!") + +# ----------------------------------------------------------------------------------------- +# --------------------------- SET BASE GROUP ---------------------------------------------- +# ----------------------------------------------------------------------------------------- +# --------------------------- STATUS BASE COMMANDS ---------------------------------------- +# ----------------------------------------------------------------------------------------- + + @status.command(name="active", alias=["act"]) + async def active_shop(self, ctx, shop_name: str): + await ctx.send(f"Shop '{shop_name}' activated!") + + @status.command(name="deactive", alias=["deact"]) + async def deactive_shop(self, ctx, shop_name: str): + await ctx.send(f"Shop '{shop_name}' deactivated!") + +# ----------------------------------------------------------------------------------------- +# --------------------------- CURRENCY BASE COMMANDS -------------------------------------- +# ----------------------------------------------------------------------------------------- + + @currency.command(name="name", alias=["nm"]) # Use @currency.command + async def name_currency(self, ctx, currency_name: str): + await ctx.send(f"Currency name set to '{currency_name}'!") + + @currency.command(name="symbol", alias=["sym"]) # Use @currency.command + async def symbol_currency(self, ctx, currency_symbol: str): + await ctx.send(f"Currency symbol set to '{currency_symbol}'!") + + @currency.command(name="emoji", alias=["emo"]) # Use @currency.command + async def emoji_currency(self, ctx, currency_emoji: str): + await ctx.send(f"Currency emoji set to '{currency_emoji}'!") -- 2.43.0 From eec57c7e23e71b87892d87ddbb5d16781e25582c Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Thu, 11 Sep 2025 17:24:31 -0400 Subject: [PATCH 5/5] feat: adds ServiceReview cog Introduces functionality for users to submit independent service reviews, sent to a designated staff channel. Includes metadata and initial structure files. --- servicereview/README.md | 0 servicereview/__init__.py | 0 servicereview/info.json | 15 +++++++++++++++ servicereview/iservice.py | 0 4 files changed, 15 insertions(+) create mode 100644 servicereview/README.md create mode 100644 servicereview/__init__.py create mode 100644 servicereview/info.json create mode 100644 servicereview/iservice.py diff --git a/servicereview/README.md b/servicereview/README.md new file mode 100644 index 0000000..e69de29 diff --git a/servicereview/__init__.py b/servicereview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/servicereview/info.json b/servicereview/info.json new file mode 100644 index 0000000..5ca3a70 --- /dev/null +++ b/servicereview/info.json @@ -0,0 +1,15 @@ +{ + "author": [ "unstableCogs" ], + "install_msg": "Thank you for installing the Service Review cog!", + "name": "ServiceReview", + "short": "A command for users to leave a service review.", + "description": "Allows users to submit a service review at any time, independently of any ticket system. Submissions are sent to a designated channel for staff.", + "tags": [ + "review", + "service", + "feedback", + "utility" + ], + "requirements": [ "rich" ], + "end_user_data_statement": "This cog persistently stores the user's ID and the content of their submitted review for record-keeping purposes." +} \ No newline at end of file diff --git a/servicereview/iservice.py b/servicereview/iservice.py new file mode 100644 index 0000000..e69de29 -- 2.43.0