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))