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.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
from .modmail import setup
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user