diff --git a/.gitignore b/.gitignore index a33a735..91885b3 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,5 @@ cython_debug/ .vs/ pyproject.toml + +treegen.py diff --git a/datamanager/__init__.py b/datamanager/__init__.py new file mode 100644 index 0000000..08a1ac9 --- /dev/null +++ b/datamanager/__init__.py @@ -0,0 +1,4 @@ +from .datamanager import DataManager + +async def setup(bot): + await bot.add_cog(DataManager(bot)) diff --git a/datamanager/datamanager.py b/datamanager/datamanager.py new file mode 100644 index 0000000..f9511dc --- /dev/null +++ b/datamanager/datamanager.py @@ -0,0 +1,128 @@ +import discord +from redbot.core import commands, Config +import datetime +from typing import Dict, Optional, Literal +from discord.ext import tasks + +SUPPORTED_COGS = Literal["ModMail", "Hiring", "ServiceReview", "StaffMsg", "Logging"] + +class DataManager(commands.Cog): + """ + A cog to automatically manage and purge old data from other cogs. + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) + default_guild = { + "policies": {} # {cog_name: days} + } + self.config.register_guild(**default_guild) + self.data_purge_loop.start() + + def cog_unload(self): + self.data_purge_loop.cancel() + + @tasks.loop(hours=24) + async def data_purge_loop(self): + """Periodically purges old data based on configured policies.""" + await self.bot.wait_until_ready() + all_guilds = self.bot.guilds + for guild in all_guilds: + policies = await self.config.guild(guild).policies() + if not policies: + continue + + for cog_name, days in policies.items(): + cog = self.bot.get_cog(cog_name) + if not cog or not hasattr(cog, "config"): + continue + + retention_delta = datetime.timedelta(days=days) + + # Determine the correct config group to purge + # This needs to match what we defined in the other cogs + data_group_name = "" + if cog_name == "ModMail": + data_group_name = "closed_threads" + elif cog_name == "Hiring": + data_group_name = "closed_applications" + elif cog_name == "ServiceReview": + data_group_name = "reviews" + elif cog_name in ["StaffMsg", "Logging"]: + data_group_name = "logged_events" + + if not data_group_name: + continue + + async with cog.config.guild(guild).get_attr(data_group_name)() as data_log: + to_delete = [] + for entry_id, timestamp_str in data_log.items(): + try: + entry_timestamp = datetime.datetime.fromisoformat(timestamp_str) + if (datetime.datetime.now(datetime.timezone.utc) - entry_timestamp) > retention_delta: + to_delete.append(entry_id) + except (ValueError, TypeError): + continue # Skip invalid timestamps + + for entry_id in to_delete: + del data_log[entry_id] + + # --- SETTINGS COMMANDS --- + @commands.group(aliases=["dmset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def datamanagerset(self, ctx: commands.Context): + """Configure DataManager settings.""" + pass + + @datamanagerset.command(name="policy") + async def set_purge_policy(self, ctx: commands.Context, cog_name: SUPPORTED_COGS, days: int): + """ + Set the data retention policy for a cog. + + Use 0 days to disable purging for a cog. + """ + if not ctx.guild: + return + if days < 0: + await ctx.send("Please provide a non-negative number of days.") + return + + async with self.config.guild(ctx.guild).policies() as policies: + if days == 0: + if cog_name in policies: + del policies[cog_name] + await ctx.send(f"Purge policy for `{cog_name}` has been removed.") + else: + await ctx.send(f"No purge policy was set for `{cog_name}`.") + else: + policies[cog_name] = days + await ctx.send(f"Data from `{cog_name}` will now be purged after {days} days.") + + @datamanagerset.command(name="view") + async def view_policies(self, ctx: commands.Context): + """View the current data retention policies.""" + if not ctx.guild: + return + + policies = await self.config.guild(ctx.guild).policies() + if not policies: + await ctx.send("No data retention policies have been set for this server.") + return + + embed = discord.Embed( + title="Data Retention Policies", + color=await ctx.embed_color() + ) + description = "Data from the following cogs will be automatically purged after the specified duration:" + for cog_name, days in policies.items(): + description += f"\n- **{cog_name}**: {days} days" + + embed.description = description + await ctx.send(embed=embed) + + +async def setup(bot): + await bot.add_cog(DataManager(bot)) + diff --git a/datamanager/info.json b/datamanager/info.json new file mode 100644 index 0000000..2ecae4d --- /dev/null +++ b/datamanager/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Data Manager cog! Please use the setup commands to configure your data retention policies.", + "name": "DataManager", + "short": "A cog to manage long-term data retention.", + "description": "Provides tools to automatically purge or archive old data from other cogs to prevent database bloat.", + "tags": [ + "data", + "database", + "utility", + "admin" + ], + "requirements": [], + "end_user_data_statement": "This cog reads and deletes data from other cogs based on administrator configuration. It does not store any unique user data itself." +} \ No newline at end of file diff --git a/hiring/hiring.py b/hiring/hiring.py index 1a79c28..d60d027 100644 --- a/hiring/hiring.py +++ b/hiring/hiring.py @@ -1,10 +1,10 @@ import discord -from redbot.core import commands, Config -from redbot.core.bot import Red -from typing import Optional, TYPE_CHECKING +from redbot.core import commands, Config, app_commands +from typing import Literal, Optional, TYPE_CHECKING, Type +import datetime if TYPE_CHECKING: - from hiring.hiring import Hiring + from redbot.core.bot import Red # --- Modals for the Application Forms --- @@ -12,203 +12,224 @@ class StaffApplicationModal(discord.ui.Modal, title="Staff Application"): chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") describe_server = discord.ui.TextInput(label="Please describe your server.", style=discord.TextStyle.paragraph) - server_link = discord.ui.TextInput(label="Server Link") + server_link = discord.ui.TextInput(label="Server Invite Link") + + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel async def on_submit(self, interaction: discord.Interaction): - embed = discord.Embed( - title="New Staff Application", - description=f"Submitted by {interaction.user.mention}", - color=0xadd8e6 - ) + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + + embed = discord.Embed(title="New Staff Application", color=discord.Color.blue()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) embed.add_field(name="Chosen Plan", value=self.chosen_plan.value, inline=False) embed.add_field(name="Toxicity Level", value=self.tox_level.value, inline=False) embed.add_field(name="Server Description", value=self.describe_server.value, inline=False) embed.add_field(name="Server Link", value=self.server_link.value, inline=False) - + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your staff application has been submitted.", ephemeral=True) + await interaction.response.send_message("Your application has been submitted!", ephemeral=True) if isinstance(interaction.channel, discord.TextChannel): await interaction.channel.send(embed=embed) class PMApplicationModal(discord.ui.Modal, title="PM Application"): - ad = discord.ui.TextInput(label="Please provide your server ad.", style=discord.TextStyle.paragraph) - reqs = discord.ui.TextInput(label="What are your requirements?") + ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) + reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) tox_level = discord.ui.TextInput(label="What is your toxicity level tolerance?") chosen_plan = discord.ui.TextInput(label="What plan are you interested in?") + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel + async def on_submit(self, interaction: discord.Interaction): - embed = discord.Embed( - title="New PM Application", - description=f"Submitted by {interaction.user.mention}", - color=0xadd8e6 - ) - embed.add_field(name="Server Ad", value=f"```\n{self.ad.value}\n```", inline=False) - embed.add_field(name="Requirements", value=self.reqs.value, inline=False) + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + + embed = discord.Embed(title="New PM Application", color=discord.Color.green()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Ad", value=f"```\n{self.ad.value}\n```", inline=False) + embed.add_field(name="Requirements", value=f"```\n{self.reqs.value}\n```", inline=False) embed.add_field(name="Toxicity Level", value=self.tox_level.value, inline=False) embed.add_field(name="Chosen Plan", value=self.chosen_plan.value, inline=False) - await interaction.response.send_message("Your application has been submitted!", ephemeral=True) - if isinstance(interaction.channel, discord.TextChannel): - await interaction.channel.send(embed=embed) + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your PM application has been submitted.", ephemeral=True) class HPMApplicationModal(discord.ui.Modal, title="HPM Application"): - ad = discord.ui.TextInput(label="Please provide your server ad.", style=discord.TextStyle.paragraph) - reqs = discord.ui.TextInput(label="What are your requirements?") - + ad = discord.ui.TextInput(label="Your Ad", style=discord.TextStyle.paragraph) + reqs = discord.ui.TextInput(label="Your Requirements", style=discord.TextStyle.paragraph) + + def __init__(self, ticket_channel: discord.TextChannel): + super().__init__() + self.ticket_channel = ticket_channel + async def on_submit(self, interaction: discord.Interaction): - embed = discord.Embed( - title="New HPM Application", - description=f"Submitted by {interaction.user.mention}", - color=0xadd8e6 - ) - embed.add_field(name="Server Ad", value=f"```\n{self.ad.value}\n```", inline=False) - embed.add_field(name="Requirements", value=self.reqs.value, inline=False) - await interaction.response.send_message("Your application has been submitted!", ephemeral=True) - if isinstance(interaction.channel, discord.TextChannel): - await interaction.channel.send(embed=embed) + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("Hiring cog not loaded.", ephemeral=True) + return + embed = discord.Embed(title="New HPM Application", color=discord.Color.purple()) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Ad", value=f"```\n{self.ad.value}\n```", inline=False) + embed.add_field(name="Requirements", value=f"```\n{self.reqs.value}\n```", inline=False) + await self.ticket_channel.send(embed=embed) + await interaction.response.send_message("Your HPM application has been submitted.", ephemeral=True) -# --- Reusable Ticket Creation Logic --- -async def create_ticket(interaction: discord.Interaction, role_type: str, category_id: Optional[int], modal: discord.ui.Modal): - if not interaction.guild: - await interaction.response.send_message("This interaction must be used in a server.", ephemeral=True) +# --- Views with Buttons --- + +async def create_ticket(interaction: discord.Interaction, ticket_type: str, modal_class: Type[discord.ui.Modal]): + """Helper function to create a ticket, used by multiple views.""" + cog: Optional["Hiring"] = interaction.client.get_cog("Hiring") # type: ignore + if not cog: + await interaction.response.send_message("The Hiring cog is not loaded.", ephemeral=True) return - if not category_id: - await interaction.response.send_message(f"The category for '{role_type}' applications has not been set by an admin.", ephemeral=True) + guild = interaction.guild + if not guild: + await interaction.response.send_message("This action can only be performed in a server.", ephemeral=True) return - - category = interaction.guild.get_channel(category_id) - if not isinstance(category, discord.CategoryChannel): - await interaction.response.send_message(f"The category for '{role_type}' applications is invalid or has been deleted.", ephemeral=True) + + category_id_raw = await cog.config.guild(guild).get_raw(f"{ticket_type}_category") + if not isinstance(category_id_raw, int): + await interaction.response.send_message(f"The category for '{ticket_type}' applications has not been set correctly.", ephemeral=True) return - - ticket_name = f"{role_type}-app-{interaction.user.name}" + category = guild.get_channel(category_id_raw) + if not isinstance(category, discord.CategoryChannel): + await interaction.response.send_message(f"The configured category for '{ticket_type}' is invalid.", ephemeral=True) + return + + assert isinstance(interaction.user, discord.Member) + try: + thread_name = f"{ticket_type}-application-{interaction.user.name}" overwrites = { - interaction.guild.default_role: discord.PermissionOverwrite(read_messages=False), - interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True), - interaction.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True) + guild.default_role: discord.PermissionOverwrite(read_messages=False), + interaction.user: discord.PermissionOverwrite(read_messages=True, send_messages=True) } - ticket_channel = await interaction.guild.create_text_channel( - name=ticket_name, - category=category, - overwrites=overwrites - ) - await ticket_channel.send(f"Welcome {interaction.user.mention}! Please fill out the form to complete your application.") - await interaction.response.send_modal(modal) + ticket_channel = await category.create_text_channel(name=thread_name, overwrites=overwrites) + + modal_instance = modal_class(ticket_channel) # type: ignore + await interaction.response.send_modal(modal_instance) + + async with cog.config.guild(guild).closed_applications() as closed_apps: + closed_apps[str(ticket_channel.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() except discord.Forbidden: - if interaction.response.is_done(): - await interaction.followup.send("I don't have permission to create channels in that category.", ephemeral=True) - else: - await interaction.response.send_message("I don't have permission to create channels in that category.", ephemeral=True) + if not interaction.response.is_done(): + await interaction.response.send_message("I don't have permission to create channels in that category.", ephemeral=True) except Exception as e: - if interaction.response.is_done(): - await interaction.followup.send(f"An unexpected error occurred: {e}", ephemeral=True) - else: + if not interaction.response.is_done(): await interaction.response.send_message(f"An unexpected error occurred: {e}", ephemeral=True) # --- Button Views for Commands --- class HireView(discord.ui.View): - def __init__(self, cog: "Hiring"): + def __init__(self): super().__init__(timeout=None) self.cog = cog - @discord.ui.button(label="Staff", style=discord.ButtonStyle.primary, custom_id="staff_apply_button") + @discord.ui.button(label="Staff", style=discord.ButtonStyle.primary, custom_id="staff_apply_button_persistent") async def staff_button(self, interaction: discord.Interaction, button: discord.ui.Button): - if not interaction.guild: return - category_id = await self.cog.config.guild(interaction.guild).staff_category() - await create_ticket(interaction, "staff", category_id, StaffApplicationModal()) + await create_ticket(interaction, "staff", StaffApplicationModal) - @discord.ui.button(label="PM", style=discord.ButtonStyle.secondary, custom_id="pm_apply_button") + @discord.ui.button(label="PM", style=discord.ButtonStyle.secondary, custom_id="pm_apply_button_persistent") async def pm_button(self, interaction: discord.Interaction, button: discord.ui.Button): - if not interaction.guild: return - category_id = await self.cog.config.guild(interaction.guild).pm_category() - await create_ticket(interaction, "pm", category_id, PMApplicationModal()) + await create_ticket(interaction, "pm", PMApplicationModal) - @discord.ui.button(label="HPM", style=discord.ButtonStyle.success, custom_id="hpm_apply_button") + @discord.ui.button(label="HPM", style=discord.ButtonStyle.success, custom_id="hpm_apply_button_persistent") async def hpm_button(self, interaction: discord.Interaction, button: discord.ui.Button): - if not interaction.guild: return - category_id = await self.cog.config.guild(interaction.guild).hpm_category() - await create_ticket(interaction, "hpm", category_id, HPMApplicationModal()) + await create_ticket(interaction, "hpm", HPMApplicationModal) class WorkView(discord.ui.View): - def __init__(self, cog: "Hiring"): + def __init__(self): super().__init__(timeout=None) self.cog = cog - @discord.ui.button(label="Apply for PM Position", style=discord.ButtonStyle.blurple, custom_id="work_apply_button") - async def work_button(self, interaction: discord.Interaction, button: discord.ui.Button): - if not interaction.guild: return - category_id = await self.cog.config.guild(interaction.guild).pm_category() - await create_ticket(interaction, "pm", category_id, PMApplicationModal()) + @discord.ui.button(label="Apply for PM Position", style=discord.ButtonStyle.green, custom_id="work_apply_pm_persistent") + async def work_apply_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await create_ticket(interaction, "pm", PMApplicationModal) +# --- Main Cog Class --- + class Hiring(commands.Cog): """ - A cog for managing staff and PM applications. + A cog for handling staff hiring applications. """ - - def __init__(self, bot: Red): + def __init__(self, bot: "Red"): self.bot = bot - self.config = Config.get_conf(self, identifier=1122334455, force_registration=True) - default_guild = { - "hpm_category": None, - "pm_category": None, "staff_category": None, - "work_channel": None # New setting for the /work command channel + "pm_category": None, + "hpm_category": None, + "work_channel": None, + "closed_applications": {} } self.config.register_guild(**default_guild) # We need to make sure the views are persistent so buttons work after a restart self.bot.add_view(HireView(self)) self.bot.add_view(WorkView(self)) + async def cog_load(self): + self.bot.add_view(HireView()) + self.bot.add_view(WorkView()) # --- Commands --- @commands.hybrid_command() # type: ignore - @commands.guild_only() + @app_commands.guild_only() @commands.admin_or_permissions(manage_guild=True) async def hire(self, ctx: commands.Context): - """Post the application message with buttons in the current channel.""" + """Sends the hiring application view.""" + view = HireView() embed = discord.Embed( - title="Start an Application", - description="Click a button below to open a ticket for the role you are interested in.", - color=0xadd8e6 + title="Hiring Applications", + description="Please select the position you are applying for below.", + color=await ctx.embed_color() ) - await ctx.send(embed=embed, view=HireView(self)) + await ctx.send(embed=embed, view=view) @commands.hybrid_command() # type: ignore - @commands.guild_only() + @app_commands.guild_only() @commands.admin_or_permissions(manage_guild=True) async def work(self, ctx: commands.Context): - """Post the PM hiring message in the configured work channel.""" - if not ctx.guild: - return - work_channel_id = await self.config.guild(ctx.guild).work_channel() - if not work_channel_id: - await ctx.send("The work channel has not been set. Please use `[p]hiringset workchannel` to set it.", ephemeral=True) - return - - work_channel = ctx.guild.get_channel(work_channel_id) - if not isinstance(work_channel, discord.TextChannel): - await ctx.send("The configured work channel is invalid or has been deleted.", ephemeral=True) + """Posts the persistent PM application button.""" + guild = ctx.guild + if not guild: return + work_channel_id = await self.config.guild(guild).work_channel() + if not work_channel_id: + await ctx.send("The work channel has not been set. Please use `/hiringset workchannel`.") + return + + channel = guild.get_channel(work_channel_id) + if not isinstance(channel, discord.TextChannel): + await ctx.send("The configured work channel is invalid.") + return + + view = WorkView() embed = discord.Embed( - title="Now Hiring: Partnership Managers", - description="We are for talented Partnership Managers (PMs) to join our team. If you are interested in applying, please click the button below to begin the application process.", - color=0xadd8e6 + title="Partnership Manager Applications", + description="Click the button below to apply for a Partnership Manager (PM) position.", + color=discord.Color.green() ) try: - await work_channel.send(embed=embed, view=WorkView(self)) - await ctx.send(f"Hiring message posted in {work_channel.mention}.", ephemeral=True) + await channel.send(embed=embed, view=view) + await ctx.send(f"Application message posted in {channel.mention}.", ephemeral=True) except discord.Forbidden: - await ctx.send(f"I don't have permission to send messages in {work_channel.mention}.", ephemeral=True) + await ctx.send("I don't have permission to send messages in that channel.", ephemeral=True) # --- Settings Commands --- @@ -216,38 +237,41 @@ class Hiring(commands.Cog): @commands.guild_only() @commands.admin_or_permissions(manage_guild=True) async def hiringset(self, ctx: commands.Context): - """Configure the Hiring cog settings.""" + """Configure Hiring settings.""" pass @hiringset.command(name="staffcategory") async def set_staff_category(self, ctx: commands.Context, category: discord.CategoryChannel): """Set the category for Staff applications.""" - if not ctx.guild: return + if not ctx.guild: + return await self.config.guild(ctx.guild).staff_category.set(category.id) await ctx.send(f"Staff application category set to **{category.name}**.") @hiringset.command(name="pmcategory") async def set_pm_category(self, ctx: commands.Context, category: discord.CategoryChannel): """Set the category for PM applications.""" - if not ctx.guild: return + if not ctx.guild: + return await self.config.guild(ctx.guild).pm_category.set(category.id) await ctx.send(f"PM application category set to **{category.name}**.") @hiringset.command(name="hpmcategory") async def set_hpm_category(self, ctx: commands.Context, category: discord.CategoryChannel): """Set the category for HPM applications.""" - if not ctx.guild: return + if not ctx.guild: + return await self.config.guild(ctx.guild).hpm_category.set(category.id) await ctx.send(f"HPM application category set to **{category.name}**.") - + @hiringset.command(name="workchannel") async def set_work_channel(self, ctx: commands.Context, channel: discord.TextChannel): - """Set the channel where the /work hiring message will be posted.""" - if not ctx.guild: return + """Set the channel for the /work command announcements.""" + if not ctx.guild: + return await self.config.guild(ctx.guild).work_channel.set(channel.id) - await ctx.send(f"The work channel has been set to {channel.mention}.") + await ctx.send(f"Work announcement channel set to {channel.mention}.") - -async def setup(bot: Red): +async def setup(bot): await bot.add_cog(Hiring(bot)) diff --git a/kofishop/kofishop.py b/kofishop/kofishop.py index dfd49b4..04334d4 100644 --- a/kofishop/kofishop.py +++ b/kofishop/kofishop.py @@ -1,6 +1,6 @@ import discord from redbot.core import commands, Config -from redbot.core.bot import Red + from redbot.core.bot import Red from typing import Optional # --- Modals for the Commands --- @@ -18,11 +18,11 @@ class OrderModal(discord.ui.Modal, title="Commission/Shop Order"): async def on_submit(self, interaction: discord.Interaction): if not interaction.guild: return await interaction.response.send_message("This must be used in a server.", ephemeral=True) - + order_channel_id = await self.cog.config.guild(interaction.guild).order_channel() if not order_channel_id: return await interaction.response.send_message("The order channel has not been set by an admin.", ephemeral=True) - + order_channel = interaction.guild.get_channel(order_channel_id) if not isinstance(order_channel, discord.TextChannel): return await interaction.response.send_message("The configured order channel is invalid.", ephemeral=True) @@ -121,15 +121,15 @@ class KofiShop(commands.Cog): """Add a user and their requested item to the waitlist.""" if not ctx.guild or not isinstance(ctx.channel, discord.TextChannel): return await ctx.send("This command must be used in a server's text channel.", ephemeral=True) - + waitlist_channel_id = await self.config.guild(ctx.guild).waitlist_channel() if not waitlist_channel_id: return await ctx.send("The waitlist channel has not been set by an admin.", ephemeral=True) - + waitlist_channel = ctx.guild.get_channel(waitlist_channel_id) if not isinstance(waitlist_channel, discord.TextChannel): return await ctx.send("The configured waitlist channel is invalid.", ephemeral=True) - + message = f"**{item}** ིྀ {user.mention} ✧ in {ctx.channel.mention}" try: diff --git a/logging/__init__.py b/logging/__init__.py new file mode 100644 index 0000000..1f518e8 --- /dev/null +++ b/logging/__init__.py @@ -0,0 +1,4 @@ +from .logging import Logging + +async def setup(bot): + await bot.add_cog(Logging(bot)) \ No newline at end of file diff --git a/logging/info.json b/logging/info.json new file mode 100644 index 0000000..455bc33 --- /dev/null +++ b/logging/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Logging cog! Please configure a log channel to begin.", + "name": "Logging", + "short": "A cog for comprehensive server event logging.", + "description": "Logs various server events (joins, leaves, message edits/deletes, etc.) to a designated channel for moderation and auditing purposes.", + "tags": [ + "logging", + "moderation", + "utility", + "events" + ], + "requirements": [], + "end_user_data_statement": "This cog does not persistently store any user data." +} \ No newline at end of file diff --git a/logging/logging.py b/logging/logging.py new file mode 100644 index 0000000..7abbbf6 --- /dev/null +++ b/logging/logging.py @@ -0,0 +1,276 @@ +import discord +from redbot.core import commands, Config +import datetime +from typing import Dict, Optional, Union + +class Logging(commands.Cog): + """ + A cog for comprehensive server event logging. + """ + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567893, force_registration=True) + default_guild = { + "log_channel": None, + "logged_events": {}, # For DataManager + "enabled_events": { + "on_message_delete": True, + "on_message_edit": True, + "on_member_join": True, + "on_member_remove": True, + "on_member_update": True, + "on_voice_state_update": True, + "on_invite_create": True, + }, + } + self.config.register_guild(**default_guild) + + async def _send_log(self, guild: discord.Guild, embed: discord.Embed, event_name: str): + """Helper function to send logs.""" + enabled_events = await self.config.guild(guild).enabled_events() + if not enabled_events.get(event_name, False): + return + + log_channel_id = await self.config.guild(guild).log_channel() + if not log_channel_id: + return + + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + try: + log_message = await log_channel.send(embed=embed) + if event_name in ["on_message_delete", "on_message_edit"]: + async with self.config.guild(guild).logged_events() as events: + events[str(log_message.id)] = datetime.datetime.now( + datetime.timezone.utc + ).isoformat() + except discord.Forbidden: + pass # Bot lacks permissions + + # --- SETTINGS COMMANDS --- + @commands.group(aliases=["logset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def loggingset(self, ctx: commands.Context): + """Configure logging settings.""" + pass + + @loggingset.command(name="channel") + async def set_log_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where logs will be sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).log_channel.set(channel.id) + await ctx.send(f"Log channel has been set to {channel.mention}") + + @loggingset.command(name="toggle") + async def toggle_log_event(self, ctx: commands.Context, event: str): + """Toggle logging for a specific event.""" + if not ctx.guild: + return + + valid_events = list((await self.config.guild(ctx.guild).enabled_events()).keys()) + if event not in valid_events: + await ctx.send(f"Invalid event. Valid events are: `{'`, `'.join(valid_events)}`") + return + + async with self.config.guild(ctx.guild).enabled_events() as events: + current_status = events.get(event, False) + events[event] = not current_status + new_status = "enabled" if not current_status else "disabled" + await ctx.send(f"Logging for `{event}` has been {new_status}.") + + # --- EVENT LISTENERS --- + @commands.Cog.listener() + async def on_message_delete(self, message: discord.Message): + """Log when a message is deleted.""" + if message.author.bot or not message.guild: + return + + location = "" + if isinstance(message.channel, (discord.TextChannel, discord.Thread)): + location = message.channel.mention + elif isinstance(message.channel, discord.DMChannel): + location = f"DM with {message.channel.recipient}" + elif isinstance(message.channel, discord.GroupChannel): + location = f"Group DM ({message.channel.id})" + else: + location = f"Channel ID: {message.channel.id}" + + embed = discord.Embed( + description=f"**Message deleted in {location}**\n{message.content or 'No text content.'}", + color=discord.Color.red(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{message.author.name} ({message.author.id})", + icon_url=message.author.display_avatar.url, + ) + await self._send_log(message.guild, embed, "on_message_delete") + + @commands.Cog.listener() + async def on_message_edit(self, before: discord.Message, after: discord.Message): + """Log when a message is edited.""" + if before.author.bot or not before.guild or before.content == after.content: + return + + location = "" + if isinstance(before.channel, (discord.TextChannel, discord.Thread)): + location = before.channel.mention + elif isinstance(before.channel, discord.DMChannel): + location = f"DM with {before.channel.recipient}" + elif isinstance(before.channel, discord.GroupChannel): + location = f"Group DM ({before.channel.id})" + else: + location = f"Channel ID: {before.channel.id}" + + embed = discord.Embed( + description=f"**Message edited in {location}** [Jump to Message]({after.jump_url})", + color=discord.Color.orange(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{before.author.name} ({before.author.id})", + icon_url=before.author.display_avatar.url, + ) + embed.add_field(name="Before", value=before.content or "Empty", inline=False) + embed.add_field(name="After", value=after.content or "Empty", inline=False) + await self._send_log(before.guild, embed, "on_message_edit") + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + """Log when a user joins the server.""" + embed = discord.Embed( + description=f"**{member.mention} has joined the server.**", + color=discord.Color.green(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(member.guild, embed, "on_member_join") + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + """Log when a user leaves the server.""" + embed = discord.Embed( + description=f"**{member.mention} has left the server.**", + color=discord.Color.dark_red(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(member.guild, embed, "on_member_remove") + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Log when a member's roles or nickname changes.""" + guild = after.guild + if before.nick != after.nick: + embed = discord.Embed( + description=f"**{after.mention} changed their nickname.**", + color=discord.Color.blue(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{after.name} ({after.id})", icon_url=after.display_avatar.url + ) + embed.add_field(name="Before", value=before.nick or "None", inline=False) + embed.add_field(name="After", value=after.nick or "None", inline=False) + await self._send_log(guild, embed, "on_member_update") + + if before.roles != after.roles: + added_roles = [r.mention for r in after.roles if r not in before.roles] + removed_roles = [r.mention for r in before.roles if r not in after.roles] + if added_roles or removed_roles: + embed = discord.Embed( + description=f"**{after.mention}'s roles were updated.**", + color=discord.Color.blue(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{after.name} ({after.id})", + icon_url=after.display_avatar.url, + ) + if added_roles: + embed.add_field( + name="Added Roles", value=", ".join(added_roles), inline=False + ) + if removed_roles: + embed.add_field( + name="Removed Roles", + value=", ".join(removed_roles), + inline=False, + ) + await self._send_log(guild, embed, "on_member_update") + + @commands.Cog.listener() + async def on_voice_state_update( + self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState + ): + """Log when a user's voice state changes.""" + if before.channel == after.channel: + return # Ignore mutes, deafens, etc. for now + + guild = member.guild + action = "" + if before.channel and not after.channel: + action = f"left voice channel {before.channel.mention}." + elif not before.channel and after.channel: + action = f"joined voice channel {after.channel.mention}." + elif before.channel and after.channel: + action = f"moved from {before.channel.mention} to {after.channel.mention}." + + if action: + embed = discord.Embed( + description=f"**{member.mention} {action}**", + color=discord.Color.light_grey(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.set_author( + name=f"{member.name} ({member.id})", icon_url=member.display_avatar.url + ) + await self._send_log(guild, embed, "on_voice_state_update") + + @commands.Cog.listener() + async def on_invite_create(self, invite: discord.Invite): + """Log when an invite is created.""" + guild = invite.guild + if not guild or not isinstance(guild, discord.Guild): + return + + inviter_str = "Unknown User" + if invite.inviter and isinstance(invite.inviter, (discord.User, discord.Member)): + inviter_str = invite.inviter.mention + elif invite.inviter: + inviter_str = f"User ({invite.inviter.id})" + + channel_str = "Unknown Channel" + if invite.channel and isinstance(invite.channel, (discord.abc.GuildChannel)): + if hasattr(invite.channel, 'mention'): + channel_str = invite.channel.mention + else: + channel_str = f"`{invite.channel.name}`" + elif invite.channel: + channel_str = f"Channel ID: {invite.channel.id}" + + + embed = discord.Embed( + title="Invite Created", + description=f"Invite `{invite.code}` to {channel_str} created by {inviter_str}.", + color=discord.Color.teal(), + timestamp=datetime.datetime.now(datetime.timezone.utc), + ) + embed.add_field(name="Max Uses", value=invite.max_uses or "Unlimited") + embed.add_field( + name="Max Age", + value=f"{invite.max_age // 3600} hours" if invite.max_age else "Permanent", + ) + await self._send_log(guild, embed, "on_invite_create") + + +async def setup(bot): + await bot.add_cog(Logging(bot)) + diff --git a/mors/__init__.py b/mors/__init__.py index e69de29..cae2379 100644 --- a/mors/__init__.py +++ b/mors/__init__.py @@ -0,0 +1,4 @@ +from .mors import MORS + +async def setup(bot): + await bot.add_cog(MORS(bot)) diff --git a/mors/info.json b/mors/info.json index b0c5a76..78fb280 100644 --- a/mors/info.json +++ b/mors/info.json @@ -1,9 +1,11 @@ { - "author": [ "unstableCogs" ], - "install_msg": "Thank you for installing the Mass Outreach & Review System! For a full command list, please see the wiki.", - "name": "MassOutreach", + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Mass Outreach & Review System cog!", + "name": "MORS", "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.", + "description": "Manages a complete workflow for user outreach, including ad submission, time selection, and final reviews.", "tags": [ "mass", "outreach", @@ -11,6 +13,6 @@ "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." + "requirements": [], + "end_user_data_statement": "This cog stores user IDs, ticket information, and submitted reviews persistently for record-keeping purposes." } \ No newline at end of file diff --git a/mors/mors.py b/mors/mors.py index e69de29..847be9a 100644 --- a/mors/mors.py +++ b/mors/mors.py @@ -0,0 +1,444 @@ +import discord +import asyncio +from redbot.core import commands, Config, app_commands +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from redbot.core.bot import Red + +# --- UI Components --- + +class AdSubmissionModal(discord.ui.Modal, title="MORS Ad Submission"): + ad_text = discord.ui.TextInput( + label="Your Server Advertisement", + style=discord.TextStyle.paragraph, + placeholder="Please enter the full text of your ad here.", + required=True, + max_length=2000, + ) + ad_link = discord.ui.TextInput( + label="Your Server Invite Link", + placeholder="https://discord.gg/your-invite", + required=True, + max_length=100, + ) + + def __init__(self, cog: "MORS"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + await interaction.response.send_message("Something went wrong.", ephemeral=True) + return + + conf = self.cog.config.guild(guild) + ad_channel_id = await conf.ad_channel() + + if not ad_channel_id: + await interaction.response.send_message( + "The MORS system has not been fully configured by an administrator.", + ephemeral=True + ) + return + + ad_channel = guild.get_channel(ad_channel_id) + + if not isinstance(ad_channel, discord.TextChannel): + await interaction.response.send_message( + "The configured ad channel is invalid. Please contact an administrator.", + ephemeral=True + ) + return + + try: + thread = await ad_channel.create_thread( + name=f"mors-ticket-{interaction.user.name}", + type=discord.ChannelType.private_thread + ) + await thread.add_user(interaction.user) + except (discord.Forbidden, discord.HTTPException): + await interaction.response.send_message( + "I don't have permissions to create private threads in the ad channel.", + ephemeral=True + ) + return + + await thread.send(f"Welcome {interaction.user.mention}! Your MORS ticket has been created and is awaiting admin approval.") + await thread.send(f"For easy access on mobile, your thread ID is: `{thread.id}`") + + async with conf.active_tickets() as active_tickets: + active_tickets[str(interaction.user.id)] = { + "thread_id": thread.id, + "ad_text": self.ad_text.value, + "ad_link": self.ad_link.value, + "status": "pending_approval" + } + + await interaction.response.send_message("Your submission has been received and is awaiting approval!", ephemeral=True) + +class TimeSelect(discord.ui.Select): + def __init__(self, cog: "MORS"): + self.cog = cog + options = [ + discord.SelectOption(label="30 Minutes", value="30m"), + discord.SelectOption(label="1 Hour", value="1h"), + discord.SelectOption(label="2 Hours", value="2h"), + discord.SelectOption(label="3 Hours", value="3h"), + discord.SelectOption(label="4 Hours (Bypass Only)", value="4h"), + discord.SelectOption(label="Once a Week (Bypass Only)", value="ovn"), + ] + super().__init__(placeholder="Select the advertisement duration...", min_values=1, max_values=1, options=options) + + async def callback(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild or not isinstance(interaction.user, discord.Member): + await interaction.response.send_message("An error occurred.", ephemeral=True) + return + + member = interaction.user + selected_time = self.values[0] + conf = self.cog.config.guild(guild) + + # Logic for bypass role and cooldowns will be added here + bypass_role_id = await conf.bypass_role() + has_bypass = False + if bypass_role_id: + bypass_role = guild.get_role(bypass_role_id) + if bypass_role and bypass_role in member.roles: + has_bypass = True + + if selected_time in ["4h", "ovn"] and not has_bypass: + await interaction.response.send_message("You do not have the required role for this duration.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(interaction.user.id) + if user_id_str not in active_tickets: + await interaction.response.send_message("You don't have an active MORS ticket.", ephemeral=True) + return + + thread_id = active_tickets[user_id_str]["thread_id"] + thread = guild.get_thread(thread_id) + + if not thread: + await interaction.response.send_message("Could not find your ticket thread.", ephemeral=True) + del active_tickets[user_id_str] + return + + try: + await thread.edit(name=f"n2p-{selected_time}-{interaction.user.name}") + # Logic to add the role will go here once the role ID is configured + except discord.Forbidden: + await interaction.response.send_message("I don't have permission to rename your ticket thread.", ephemeral=True) + return + + # Send confirmation messages + queue_channel_id = await conf.queue_channel() + if queue_channel_id: + queue_channel = guild.get_channel(queue_channel_id) + if isinstance(queue_channel, discord.TextChannel): + queue_link = f"https://discord.com/channels/{guild.id}/{queue_channel.id}" + await queue_channel.send(f"{thread.mention} :mooncatblue: {interaction.user.mention} queued for __{selected_time}__ *!*") + await thread.send(f":81407babybluebutterfly: you're in the [queue]({queue_link}) *!* \n-# **p.s using invites command is __required__ for sep to state invites. no reply to pings within 3 days or inv cmnd done will get you suspended from massing.**") + + await interaction.response.send_message(f"You have selected: {selected_time}", ephemeral=True) + if self.view: + self.view.stop() + + +class TimeSelectionView(discord.ui.View): + def __init__(self, cog: "MORS"): + super().__init__(timeout=180) + self.add_item(TimeSelect(cog)) + +class ReviewModal(discord.ui.Modal, title="MORS Review Submission"): + service_type = discord.ui.TextInput(label="What service type did you use?") + rating = discord.ui.TextInput(label="Rating (1-10)", max_length=2) + review_text = discord.ui.TextInput(label="Your Review", style=discord.TextStyle.paragraph, max_length=1500) + toxicity = discord.ui.TextInput(label="Toxicity Level (ntox/stox)") + + def __init__(self, cog: "MORS"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + await interaction.response.send_message("An error occurred.", ephemeral=True) + return + + review_channel_id = await self.cog.config.guild(guild).review_channel() + if not review_channel_id: + await interaction.response.send_message("The review channel has not been configured.", ephemeral=True) + return + + review_channel = guild.get_channel(review_channel_id) + if not isinstance(review_channel, discord.TextChannel): + await interaction.response.send_message("The configured review channel is invalid.", ephemeral=True) + return + + embed = discord.Embed( + title="New MORS Review", + color=discord.Color.green() + ) + embed.set_author(name=interaction.user.name, icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Service Type", value=self.service_type.value, inline=False) + embed.add_field(name="Rating", value=f"{self.rating.value}/10", inline=True) + embed.add_field(name="Toxicity", value=self.toxicity.value, inline=True) + embed.add_field(name="Review", value=self.review_text.value, inline=False) + + try: + await review_channel.send(embed=embed) + except discord.Forbidden: + # This would only happen if permissions changed mid-process + pass + + await interaction.response.send_message("Your review has been submitted! This ticket will close shortly.", ephemeral=True) + +class MORS(commands.Cog): + """ + Mass Outreach & Review System + """ + + def __init__(self, bot: "Red"): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567894, force_registration=True) + default_guild = { + "ad_channel": None, + "review_channel": None, + "done_channel": None, + "queue_channel": None, # Added for queue messages + "access_role": None, + "bypass_role": None, + "active_tickets": {} + } + self.config.register_guild(**default_guild) + + # --- User Commands --- + @app_commands.command() + @app_commands.guild_only() + async def game(self, interaction: discord.Interaction): + """Start the mass outreach process by submitting an ad.""" + guild = interaction.guild + if not guild: + return + + if not isinstance(interaction.user, discord.Member): + await interaction.response.send_message("This command can only be used by server members.", ephemeral=True) + return + + member = interaction.user + access_role_id = await self.config.guild(guild).access_role() + if access_role_id: + access_role = guild.get_role(access_role_id) + if access_role and access_role not in member.roles: + await interaction.response.send_message( + f"You need the {access_role.name} role to use this command.", + ephemeral=True + ) + return + + modal = AdSubmissionModal(cog=self) + await interaction.response.send_modal(modal) + + @app_commands.command() + @app_commands.guild_only() + async def over(self, interaction: discord.Interaction): + """Select your advertisement duration.""" + guild = interaction.guild + if not guild: + return + + active_tickets = await self.config.guild(guild).active_tickets() + if str(interaction.user.id) not in active_tickets: + await interaction.response.send_message( + "You need to start the process with `/game` before you can use this command.", + ephemeral=True + ) + return + + view = TimeSelectionView(cog=self) + await interaction.response.send_message("Please select your desired ad duration from the dropdown below:", view=view, ephemeral=True) + + @app_commands.command(name="pudding-head") + @app_commands.guild_only() + async def pudding_head(self, interaction: discord.Interaction): + """Submit your final review for the MORS process.""" + guild = interaction.guild + if not guild: + return + + active_tickets = await self.config.guild(guild).active_tickets() + if str(interaction.user.id) not in active_tickets: + await interaction.response.send_message( + "You do not have an active MORS ticket to review.", + ephemeral=True + ) + return + + modal = ReviewModal(cog=self) + await interaction.response.send_modal(modal) + + # --- Admin Commands --- + @app_commands.command() + @app_commands.guild_only() + @app_commands.describe(member="The user whose ad you want to post.") + @app_commands.default_permissions(manage_guild=True) + async def posted(self, interaction: discord.Interaction, member: discord.Member): + """Approves and posts a user's submitted ad.""" + guild = interaction.guild + if not guild: + return + + conf = self.config.guild(guild) + ad_channel_id = await conf.ad_channel() + if not ad_channel_id: + await interaction.response.send_message("Ad channel not configured.", ephemeral=True) + return + + ad_channel = guild.get_channel(ad_channel_id) + if not isinstance(ad_channel, discord.TextChannel): + await interaction.response.send_message("Configured ad channel is invalid.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(member.id) + if user_id_str not in active_tickets: + await interaction.response.send_message(f"{member.name} has no pending ad.", ephemeral=True) + return + + ticket_data = active_tickets[user_id_str] + ad_embed = discord.Embed( + title="New Server Advertisement", + description=ticket_data.get("ad_text", "No ad text provided."), + color=discord.Color.blue() + ) + ad_embed.add_field(name="Invite Link", value=ticket_data.get("ad_link", "No link provided.")) + ad_embed.set_author(name=member.name, icon_url=member.display_avatar.url) + + try: + ad_message = await ad_channel.send(embed=ad_embed) + await ad_message.pin() + active_tickets[user_id_str]["status"] = "posted" + except discord.Forbidden: + await interaction.response.send_message("I lack permissions to post or pin in the ad channel.", ephemeral=True) + return + + await interaction.response.send_message(f"Ad for {member.name} has been posted and pinned. Remember to set a reminder.", ephemeral=True) + + + @app_commands.command() + @app_commands.guild_only() + @app_commands.describe(member="The user whose ticket you want to finalize.") + @app_commands.default_permissions(manage_guild=True) + async def done(self, interaction: discord.Interaction, member: discord.Member): + """Admin command to finalize and close a MORS ticket.""" + guild = interaction.guild + if not guild: + return + + conf = self.config.guild(guild) + done_channel_id = await conf.done_channel() + if not done_channel_id: + await interaction.response.send_message("The 'done' channel is not configured.", ephemeral=True) + return + + done_channel = guild.get_channel(done_channel_id) + if not isinstance(done_channel, discord.TextChannel): + await interaction.response.send_message("The configured 'done' channel is invalid.", ephemeral=True) + return + + async with conf.active_tickets() as active_tickets: + user_id_str = str(member.id) + if user_id_str not in active_tickets: + await interaction.response.send_message(f"{member.name} does not have an active MORS ticket.", ephemeral=True) + return + + ticket_data = active_tickets.pop(user_id_str) # Remove ticket from active list + thread_id = ticket_data.get("thread_id") + ad_link = ticket_data.get("ad_link", "Not available") + + thread = guild.get_thread(thread_id) if thread_id else None + + # Send embed to done channel + done_embed = discord.Embed( + description=f"Advertisement period is complete for {member.mention}.", + color=discord.Color.teal() + ) + done_embed.add_field(name="Server Ad Link", value=ad_link, inline=False) + # Client mentioned "image", will need clarification on what image to include. + + await done_channel.send(embed=done_embed) + + if thread: + await thread.send(f"{member.mention}, your MORS process is complete! Please submit your review with `/pudding-head`.") + await thread.send("This ticket will be archived in 60 seconds.") + await interaction.response.send_message(f"Finalizing ticket for {member.name}. The thread will be archived in 60 seconds.", ephemeral=True) + + await asyncio.sleep(60) + try: + await thread.edit(archived=True, locked=True) + except discord.Forbidden: + await thread.send("I don't have permission to archive this thread.") + else: + await interaction.response.send_message(f"Finalizing ticket for {member.name}. Could not find original thread to archive.", ephemeral=True) + + + # --- Settings Commands --- + @commands.group() # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def morsset(self, ctx: commands.Context): + """Configure MORS settings.""" + pass + + @morsset.command(name="adchannel") + async def set_ad_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where ads are posted.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).ad_channel.set(channel.id) + await ctx.send(f"Ad channel has been set to {channel.mention}") + + @morsset.command(name="reviewchannel") + async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where final reviews are sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).review_channel.set(channel.id) + await ctx.send(f"Review channel has been set to {channel.mention}") + + @morsset.command(name="donechannel") + async def set_done_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for the /done command embeds.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).done_channel.set(channel.id) + await ctx.send(f"Done channel has been set to {channel.mention}") + + @morsset.command(name="queuechannel") + async def set_queue_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel for queue confirmation messages.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).queue_channel.set(channel.id) + await ctx.send(f"Queue channel has been set to {channel.mention}") + + @morsset.command(name="accessrole") + async def set_access_role(self, ctx: commands.Context, role: discord.Role): + """Set the role required to use the /game command.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).access_role.set(role.id) + await ctx.send(f"Access role has been set to {role.name}") + + @morsset.command(name="bypassrole") + async def set_bypass_role(self, ctx: commands.Context, role: discord.Role): + """Set the bypass role for MORS cooldowns.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).bypass_role.set(role.id) + await ctx.send(f"Bypass role has been set to {role.name}") + diff --git a/servicereview/__init__.py b/servicereview/__init__.py index e69de29..6546677 100644 --- a/servicereview/__init__.py +++ b/servicereview/__init__.py @@ -0,0 +1,4 @@ +from .servicereview import ServiceReview + +async def setup(bot): + await bot.add_cog(ServiceReview(bot)) \ No newline at end of file diff --git a/servicereview/iservice.py b/servicereview/iservice.py deleted file mode 100644 index e69de29..0000000 diff --git a/servicereview/servicereview.py b/servicereview/servicereview.py new file mode 100644 index 0000000..6abb5fe --- /dev/null +++ b/servicereview/servicereview.py @@ -0,0 +1,116 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Optional +import datetime + +# --- Modal for the Review Form --- + +class ReviewModal(discord.ui.Modal, title="Service Review"): + service_type = discord.ui.TextInput( + label="Service Type", + placeholder="e.g., HPM, Staff, PM, Commission, etc." + ) + rating = discord.ui.TextInput( + label="Rating (1-10)", + placeholder="Please enter a number from 1 to 10.", + max_length=2 + ) + review_text = discord.ui.TextInput( + label="Your Review", + style=discord.TextStyle.paragraph, + placeholder="Please provide details about your experience.", + max_length=1500 + ) + toxicity = discord.ui.TextInput( + label="Toxicity Level (ntox/stox)", + placeholder="ntox (non-toxic) or stox (semi-toxic)" + ) + + def __init__(self, cog: "ServiceReview"): + super().__init__() + self.cog = cog + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + # This should not happen due to guild_only decorator + return + + channel_id = await self.cog.config.guild(guild).review_channel() + if not channel_id: + await interaction.response.send_message( + "The review channel has not been configured by an administrator.", + ephemeral=True + ) + return + + channel = guild.get_channel(channel_id) + if not isinstance(channel, discord.TextChannel): + await interaction.response.send_message( + "The configured review channel is invalid. Please contact an administrator.", + ephemeral=True + ) + return + + embed = discord.Embed(title="New Service Review", color=discord.Color.purple()) + embed.set_author(name=f"{interaction.user.name} ({interaction.user.id})", icon_url=interaction.user.display_avatar.url) + embed.add_field(name="Service Type", value=self.service_type.value, inline=False) + embed.add_field(name="Rating", value=f"{self.rating.value}/10", inline=True) + embed.add_field(name="Toxicity", value=self.toxicity.value, inline=True) + embed.add_field(name="Review", value=self.review_text.value, inline=False) + embed.set_footer(text=f"User ID: {interaction.user.id}") + + try: + review_message = await channel.send(embed=embed) + # **FIX:** Log the timestamp for the DataManager + async with self.cog.config.guild(guild).submitted_reviews() as reviews: + reviews[str(review_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + + await interaction.response.send_message("Thank you! Your review has been submitted successfully.", ephemeral=True) + except discord.Forbidden: + await interaction.response.send_message( + "I don't have permission to send messages in the review channel. Please contact an administrator.", + ephemeral=True + ) + except discord.HTTPException as e: + await interaction.response.send_message(f"An error occurred while trying to send your review: {e}", ephemeral=True) + +# --- Main Cog Class --- + +class ServiceReview(commands.Cog): + """A cog for users to leave service reviews for staff.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567891, force_registration=True) + default_guild = { + "review_channel": None, + "submitted_reviews": {} # **FIX:** Added for DataManager logging + } + self.config.register_guild(**default_guild) + + @app_commands.command(name="srev") + @app_commands.guild_only() + async def service_review(self, interaction: discord.Interaction): + """Leave a review for a staff member or service.""" + modal = ReviewModal(self) + await interaction.response.send_modal(modal) + + @commands.group(aliases=["srset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def srevset(self, ctx: commands.Context): + """Configure ServiceReview settings.""" + pass + + @srevset.command(name="channel") + async def set_review_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where service reviews will be sent.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).review_channel.set(channel.id) + await ctx.send(f"Service review channel has been set to {channel.mention}") + +async def setup(bot): + await bot.add_cog(ServiceReview(bot)) + diff --git a/staffmsg/__init__.py b/staffmsg/__init__.py new file mode 100644 index 0000000..0b78d00 --- /dev/null +++ b/staffmsg/__init__.py @@ -0,0 +1,4 @@ +from .staffmsg import StaffMsg + +async def setup(bot): + await bot.add_cog(StaffMsg(bot)) \ No newline at end of file diff --git a/staffmsg/info.json b/staffmsg/info.json new file mode 100644 index 0000000..b42366f --- /dev/null +++ b/staffmsg/info.json @@ -0,0 +1,17 @@ +{ + "author": [ + "unstableCogs" + ], + "install_msg": "Thank you for installing the Staff Messaging cog!", + "name": "StaffMsg", + "short": "A command for staff to send official DMs.", + "description": "Provides a permission-controlled command for staff to send direct messages to server members, with logging for accountability.", + "tags": [ + "dm", + "messaging", + "staff", + "utility" + ], + "requirements": [], + "end_user_data_statement": "This cog may store message content and user IDs in a log file or channel for moderation purposes." +} \ No newline at end of file diff --git a/staffmsg/staffmsg.py b/staffmsg/staffmsg.py new file mode 100644 index 0000000..9149cf0 --- /dev/null +++ b/staffmsg/staffmsg.py @@ -0,0 +1,188 @@ +import discord +from redbot.core import commands, Config, app_commands +from typing import Optional +import datetime + +# --- Modal for the Message Form --- + +class MessageModal(discord.ui.Modal, title="Staff Message"): + message_content = discord.ui.TextInput( + label="Message to Send", + style=discord.TextStyle.paragraph, + placeholder="Type the official message you want to send to the user here.", + max_length=1800 + ) + + def __init__(self, cog: "StaffMsg", target_user: discord.Member): + super().__init__() + self.cog = cog + self.target_user = target_user + + async def on_submit(self, interaction: discord.Interaction): + guild = interaction.guild + if not guild: + return + + # --- Send DM to User --- + embed_to_user = discord.Embed( + title=f"A Message from the Staff of {guild.name}", + description=self.message_content.value, + color=await self.cog.bot.get_embed_color(interaction.channel) if interaction.channel else discord.Color.blurple() + ) + embed_to_user.set_footer(text="This is an official communication. Please do not reply to the bot.") + + try: + await self.target_user.send(embed=embed_to_user) + except discord.Forbidden: + await interaction.response.send_message( + f"I could not send a DM to {self.target_user.mention}. They may have DMs disabled.", + ephemeral=True + ) + return + except discord.HTTPException as e: + await interaction.response.send_message(f"An error occurred while sending the DM: {e}", ephemeral=True) + return + + # --- Log the Message --- + log_channel_id = await self.cog.config.guild(guild).log_channel() + if log_channel_id: + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + log_embed = discord.Embed( + title="Staff DM Sent", + description=self.message_content.value, + color=discord.Color.blue() + ) + log_embed.set_author(name=f"From: {interaction.user.name} ({interaction.user.id})", icon_url=interaction.user.display_avatar.url) + log_embed.add_field(name="To", value=f"{self.target_user.mention} ({self.target_user.id})") + + try: + log_message = await log_channel.send(embed=log_embed) + async with self.cog.config.guild(guild).sent_dms() as dms: + dms[str(log_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + except discord.Forbidden: + pass + + await interaction.response.send_message(f"Your message has been successfully sent to {self.target_user.mention}.", ephemeral=True) + + +# --- Main Cog Class --- + +class StaffMsg(commands.Cog): + """A cog for staff to send official DMs to users.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, identifier=1234567892, force_registration=True) + default_guild = { + "log_channel": None, + "authorized_role": None, + "sent_dms": {} # For DataManager + } + self.config.register_guild(**default_guild) + + async def cog_check(self, ctx: commands.Context) -> bool: + if not ctx.guild: + await ctx.send("This command can only be used in a server.", ephemeral=True) + return False + + auth_role_id = await self.config.guild(ctx.guild).authorized_role() + if not auth_role_id: + await ctx.send("The authorized role for this command has not been set.", ephemeral=True) + return False + + author = ctx.author + if not isinstance(author, discord.Member): + return False + + if author.guild_permissions.administrator: + return True + + auth_role = ctx.guild.get_role(auth_role_id) + if not auth_role or auth_role not in author.roles: + await ctx.send("You are not authorized to use this command.", ephemeral=True) + return False + + return True + + @commands.hybrid_command(name="smsg", aliases=["staff", "staffmsg"]) + @app_commands.describe(user="The user to send a DM to.", message="(Optional) The message to send. Opens a form if left blank.") + @app_commands.guild_only() + async def staff_message(self, ctx: commands.Context, user: discord.Member, *, message: Optional[str] = None): + """Send an official DM to a user. Opens a form if no message is provided.""" + + # This is a clever way to handle both slash and prefix commands. + # If the 'message' is None, it means it was likely a slash command + # or a prefix command with no text, so we open the modal. + if message is None: + if not ctx.interaction: + await ctx.send("Please provide a message to send.", ephemeral=True) + return + modal = MessageModal(self, user) + await ctx.interaction.response.send_modal(modal) + return + + guild = ctx.guild + if not guild: # Should be caught by cog_check but for type safety + return + + embed_to_user = discord.Embed( + title=f"A Message from the Staff of {guild.name}", + description=message, + color=await ctx.embed_color() + ) + embed_to_user.set_footer(text="This is an official communication. Please do not reply to the bot.") + + try: + await user.send(embed=embed_to_user) + except discord.Forbidden: + await ctx.send(f"I could not send a DM to {user.mention}. They may have DMs disabled.", ephemeral=True) + return + + log_channel_id = await self.config.guild(guild).log_channel() + if log_channel_id: + log_channel = guild.get_channel(log_channel_id) + if isinstance(log_channel, discord.TextChannel): + log_embed = discord.Embed( + title="Staff DM Sent", + description=message, + color=discord.Color.blue() + ) + log_embed.set_author(name=f"From: {ctx.author.name} ({ctx.author.id})", icon_url=ctx.author.display_avatar.url) + log_embed.add_field(name="To", value=f"{user.mention} ({user.id})") + try: + log_message = await log_channel.send(embed=log_embed) + async with self.config.guild(guild).sent_dms() as dms: + dms[str(log_message.id)] = datetime.datetime.now(datetime.timezone.utc).isoformat() + except discord.Forbidden: + pass + + await ctx.send(f"Your message has been sent to {user.mention}.", ephemeral=True) + + + @commands.group(aliases=["smset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def staffmsgset(self, ctx: commands.Context): + """Configure StaffMsg settings.""" + pass + + @staffmsgset.command(name="role") + async def set_auth_role(self, ctx: commands.Context, role: discord.Role): + """Set the role authorized to use the staff message commands.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).authorized_role.set(role.id) + await ctx.send(f"Authorized role has been set to {role.mention}") + + @staffmsgset.command(name="logchannel") + async def set_log_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where sent DMs will be logged.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).log_channel.set(channel.id) + await ctx.send(f"Log channel has been set to {channel.mention}") + +async def setup(bot): + await bot.add_cog(StaffMsg(bot)) +