From 2d199d92475147fd489e94655680ca3e615ce661 Mon Sep 17 00:00:00 2001 From: Unstable Kitsune Date: Sat, 20 Sep 2025 20:45:15 -0400 Subject: [PATCH] feat: add configurable forum-based ModMail system Introduces a new cog that handles user DMs by creating threads in a designated forum channel. Enables staff to reply in threads to send messages back to users anonymously. Includes commands to configure settings, enable/disable the system, and close threads. Improves moderation by streamlining ticket management in Discord forums. --- modmail/__init__.py | 1 + modmail/modmail.py | 218 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/modmail/__init__.py b/modmail/__init__.py index e69de29..8b42c3a 100644 --- a/modmail/__init__.py +++ b/modmail/__init__.py @@ -0,0 +1 @@ +from .modmail import setup \ No newline at end of file diff --git a/modmail/modmail.py b/modmail/modmail.py index e69de29..8c84793 100644 --- a/modmail/modmail.py +++ b/modmail/modmail.py @@ -0,0 +1,218 @@ +import discord +from redbot.core import commands, Config +from redbot.core.bot import Red +from typing import Optional + +class ModMail(commands.Cog): + """ + A configurable, forum-based ModMail system. + """ + + def __init__(self, bot: Red): + self.bot = bot + # Initialize Red's Config system for storing settings per-server. + self.config = Config.get_conf(self, identifier=9876543210, force_registration=True) + + # Define the default settings for each server. + default_guild = { + "modmail_forum": None, # The ID of the forum channel for tickets + "enabled": False, # Whether the system is on or off + "active_threads": {} # A dictionary to track {user_id: thread_id} + } + + # Register the default settings. + self.config.register_guild(**default_guild) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + """ + This is the core event listener. It handles both incoming DMs from users + and outgoing replies from staff. + """ + # Ignore messages from bots to prevent loops. + if message.author.bot: + return + + # --- Part 1: Handling DMs from Users --- + if isinstance(message.channel, discord.DMChannel): + await self.handle_dm(message) + + # --- Part 2: Handling Replies from Staff --- + elif isinstance(message.channel, discord.Thread): + await self.handle_staff_reply(message) + + async def handle_dm(self, message: discord.Message): + """Handles messages sent directly to the bot.""" + # Find a mutual server with the user. + guild = next((g for g in self.bot.guilds if g.get_member(message.author.id)), None) + if not guild: + return + + settings = await self.config.guild(guild).all() + if not settings["enabled"] or not settings["modmail_forum"]: + return + + forum_channel = guild.get_channel(settings["modmail_forum"]) + if not isinstance(forum_channel, discord.ForumChannel): + return + + active_threads = settings["active_threads"] + user_id_str = str(message.author.id) + + # Check if the user already has an active thread. + if user_id_str in active_threads: + thread_id = active_threads[user_id_str] + thread = guild.get_thread(thread_id) + if thread: + # Relay the message to the existing thread. + await thread.send(f"**{message.author.display_name}:** {message.content}") + await message.add_reaction("✅") + return + else: + # The thread was deleted, so we clean up our records. + async with self.config.guild(guild).active_threads() as threads: + del threads[user_id_str] + + # Create a new thread for the user. + try: + thread_name = f"ModMail | {message.author.name}" + embed = discord.Embed( + title=f"New ModMail Thread", + description=f"**User:** {message.author.mention} (`{message.author.id}`)", + color=0xadd8e6 # Light grey pastel blue + ) + embed.add_field(name="Initial Message", value=message.content, inline=False) + embed.set_footer(text="Staff can reply in this thread to send a message.") + + thread_with_message = await forum_channel.create_thread(name=thread_name, embed=embed) + thread = thread_with_message.thread + + async with self.config.guild(guild).active_threads() as threads: + threads[user_id_str] = thread.id + + await message.channel.send("Your message has been received, and a ModMail ticket has been opened. Staff will be with you shortly.") + await message.add_reaction("✅") + except discord.Forbidden: + print(f"ModMail: I don't have permission to create threads in {forum_channel.name}.") + except Exception as e: + print(f"ModMail: An unexpected error occurred: {e}") + + async def handle_staff_reply(self, message: discord.Message): + """Handles messages sent by staff inside a ModMail thread.""" + guild = message.guild + if not guild: + return + + active_threads = await self.config.guild(guild).active_threads() + + # Find which user this thread belongs to by checking our records. + thread_id_str = str(message.channel.id) + user_id = None + for uid, tid in active_threads.items(): + if str(tid) == thread_id_str: + user_id = int(uid) + break + + if not user_id: + # This is a regular thread, not a ModMail thread we're tracking. + return + + user = guild.get_member(user_id) + if not user: + # User might have left the server. + await message.channel.send("⚠️ **Error:** Could not find the user. They may have left the server.") + return + + # Send the staff's message to the user's DMs. + try: + embed = discord.Embed( + description=message.content, + color=0xadd8e6 # Light grey pastel blue + ) + embed.set_author(name="Staff Response") # Anonymize the staff member + + await user.send(embed=embed) + await message.add_reaction("📨") # Add a mail icon to show it was sent + except discord.Forbidden: + await message.channel.send("⚠️ **Error:** I could not send a DM to this user. They may have DMs disabled.") + except Exception as e: + await message.channel.send(f"⚠️ **Error:** An unexpected error occurred: {e}") + + + # --- Settings and Management Commands --- + @commands.group(aliases=["mmset"]) # type: ignore + @commands.guild_only() + @commands.admin_or_permissions(manage_guild=True) + async def modmailset(self, ctx: commands.Context): + """ + Configure the ModMail settings for this server. + """ + pass + + @modmailset.command(name="forum") + async def modmailset_forum(self, ctx: commands.Context, channel: discord.ForumChannel): + """Set the forum channel where ModMail tickets will be created.""" + if not ctx.guild: + return + await self.config.guild(ctx.guild).modmail_forum.set(channel.id) + await ctx.send(f"The ModMail forum has been set to {channel.mention}.") + + @modmailset.command(name="toggle") + async def modmailset_toggle(self, ctx: commands.Context): + """Enable or disable the ModMail system on this server.""" + if not ctx.guild: + return + current_status = await self.config.guild(ctx.guild).enabled() + new_status = not current_status + await self.config.guild(ctx.guild).enabled.set(new_status) + status_text = "enabled" if new_status else "disabled" + await ctx.send(f"The ModMail system has been {status_text}.") + + @modmailset.command(name="close") + async def modmailset_close(self, ctx: commands.Context, *, reason: Optional[str] = "No reason provided."): + """ + Close the current ModMail thread. + + You must run this command inside the thread you wish to close. + """ + if not ctx.guild or not isinstance(ctx.channel, discord.Thread): + await ctx.send("This command can only be run inside a ModMail thread.") + return + + active_threads = await self.config.guild(ctx.guild).active_threads() + thread_id_str = str(ctx.channel.id) + user_id = None + for uid, tid in active_threads.items(): + if str(tid) == thread_id_str: + user_id = int(uid) + break + + if not user_id: + await ctx.send("This does not appear to be an active ModMail thread.") + return + + # Clean up our records. + async with self.config.guild(ctx.guild).active_threads() as threads: + del threads[str(user_id)] + + # Notify the user. + user = self.bot.get_user(user_id) + if user: + try: + embed = discord.Embed( + title="ModMail Ticket Closed", + description=f"Your ticket has been closed by staff.\n\n**Reason:** {reason}", + color=0xadd8e6 + ) + await user.send(embed=embed) + except discord.Forbidden: + pass # Can't notify user if DMs are closed + + # Archive the thread. + await ctx.send(f"Ticket closed by {ctx.author.mention}. Archiving thread...") + await ctx.channel.edit(archived=True, locked=True) + +# This required function allows Red to load the cog. +async def setup(bot: Red): + await bot.add_cog(ModMail(bot)) + -- 2.43.0