From 41bc3479595c1d0142405f00a1fd9602dad612fc Mon Sep 17 00:00:00 2001 From: Matthew Cohen Date: Sat, 13 Jul 2024 17:06:05 -0400 Subject: [PATCH] Use forums instead of channels now --- cogs/modmail.py | 86 +++++++++++++++++++++-------------- cogs/utils.py | 111 +++++++++++++++++++++++++++------------------- config.example.py | 11 ++++- exceptions.py | 7 ++- 4 files changed, 134 insertions(+), 81 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 345319f..e2f2385 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -44,27 +44,40 @@ def __init__(self, bot): @app_commands.describe(delay='The delay for the modmail to close, in 1w2d3h4m5s format') @app_commands.guilds(discord.Object(id=config.guild)) @app_commands.default_permissions(view_audit_log=True) - async def _close(self, interaction: discord.Interaction, delay: typing.Optional[str]): - message, ephemeral = await self._close_generic(interaction.user, interaction.guild, interaction.channel, delay) + async def _close(self, interaction: discord.Interaction, delay: typing.Optional[str], auto: bool = False): + if not delay: + await interaction.response.send_message(f'This thread has been closed by {interaction.user}. Use `/open` to send any followup messages to this user.') - if message: - await interaction.response.send_message(message, ephemeral=ephemeral) + else: + await interaction.response.defer() + + try: + scheduledTime = await self._close_generic(interaction.user, interaction.guild, interaction.channel, delay) + + except exceptions.InvalidDuration: + await interaction.followup.send('Invalid duration passed', ephemeral=True) + + except exceptions.NotAModmail: + await interaction.followup.send('This is not a modmail channel!', ephemeral=True) + + except exceptions.InvalidType: + await interaction.followup.send(':x: Ban appeals cannot be closed with the `/close` command. Use `/appeal accept` or `/appeal deny` instead', ephemeral=True) + + if delay: + await interaction.followup.send(f'Thread has been scheduled to be closed {scheduledTime} by {interaction.user}. Once closed, use `/open` to send any followup messages to this user.') async def _close_generic(self, user, guild, channel, delay): db = mclient.modmail.logs doc = db.find_one({'channel_id': str(channel.id), 'open': True}) if not doc: - return 'This is not a modmail channel!', True + raise exceptions.NotAModmail if doc['_id'] in self.closeQueue: self.closeQueue[doc['_id']].cancel() if doc['ban_appeal']: - return ( - ':x: Ban appeals cannot be closed with the `/close` command. Use `/appeal accept` or `/appeal deny` instead', - False, - ) + raise exceptions.InvalidType if delay: try: @@ -72,7 +85,7 @@ async def _close_generic(self, user, guild, channel, delay): delayTime = delayDate.timestamp() - datetime.now(tz=timezone.utc).timestamp() except KeyError: - return 'Invalid duration', False + raise exceptions.InvalidDuration event_loop = self.bot.loop close_action = event_loop.call_later( @@ -81,10 +94,10 @@ async def _close_generic(self, user, guild, channel, delay): utils._close_thread(self.bot, user, guild, channel, self.bot.get_channel(config.modLog)), ) self.closeQueue[doc['_id']] = close_action - return f'Thread scheduled to be closed ', False - - await utils._close_thread(self.bot, user, guild, channel, self.bot.get_channel(config.modLog)) - return None, False + return f'' + + else: + await utils._close_thread(self.bot, user, guild, channel, self.bot.get_channel(config.modLog)) @app_commands.command(name='reply', description='Replys to a modmail, with your username') @app_commands.describe(content='The message to send to the user') @@ -234,6 +247,7 @@ async def _open_context(self, interaction: discord.Interaction, member: discord. Open a modmail thread with a user """ + await interaction.response.defer() await self._open_thread(interaction, member) async def _open_thread(self, interaction: discord.Interaction, member: discord.Member): @@ -265,7 +279,7 @@ async def _message_report(self, interaction: discord.Interaction, message: disco return await interaction.followup.send(':x: You cannot report messages sent by bots', ephemeral=True) try: - dmOpened = await self._user_create_thread(message, interaction) + dmOpened = await self._user_create_thread(message, interaction, menu_interacted=True) except exceptions.ModmailBlacklisted: return await interaction.followup.send( @@ -307,6 +321,8 @@ async def _appeal_accept(self, interaction: discord.Interaction, reason: app_com if not doc: return await interaction.response.send_message(':x: This is not a ban appeal channel!', ephemeral=True) + await interaction.response.defer() + user = await self.bot.fetch_user(int(doc['recipient']['id'])) punsDB.update_one({'user': user.id, 'type': 'ban', 'active': True}, {'$set': {'active': False}}) punsDB.update_one({'user': user.id, 'type': 'appealdeny', 'active': True}, {'$set': {'active': False}}) @@ -343,13 +359,13 @@ async def _appeal_accept(self, interaction: discord.Interaction, reason: app_com ) except: - await self.bot.get_channel(config.adminChannel).send( - f':warning: The ban appeal for {user} has been accepted by {interaction.user}, but I was unable to DM them the decision' + await interaction.followup.send( + f':warning: This ban appeal for {user} has been accepted by {interaction.user}, but I was unable to DM them the decision.' ) else: - await self.bot.get_channel(config.adminChannel).send( - f':white_check_mark: The ban appeal for {user} has been accepted by {interaction.user}' + await interaction.followup.send( + f':white_check_mark: This ban appeal for {user} has been accepted by {interaction.user}.' ) finally: @@ -385,6 +401,8 @@ async def _appeal_deny( if not doc: return await interaction.response.send_message(':x: This is not a ban appeal channel!', ephemeral=True) + await interaction.response.defer() + user = await self.bot.fetch_user(int(doc['recipient']['id'])) try: delayDate = utils.resolve_duration(next_attempt) @@ -444,13 +462,13 @@ async def _appeal_deny( ) except: - await self.bot.get_channel(config.adminChannel).send( - f':warning: The ban appeal for {user} has been denied by {interaction.user} {humanizedTimestamp}, but I was unable to DM them the decision' + await interaction.followup.send( + f':warning: This ban appeal for {user} has been denied by {interaction.user} {humanizedTimestamp}, but I was unable to DM them the decision.' ) else: - await self.bot.get_channel(config.adminChannel).send( - f':white_check_mark: The ban appeal for {user} has been denied by {interaction.user} {humanizedTimestamp}' + await interaction.followup.send( + f':white_check_mark: This ban appeal for {user} has been denied by {interaction.user} {humanizedTimestamp}.' ) finally: @@ -505,8 +523,8 @@ async def on_member_ban(self, guild, member): db = mclient.modmail.logs thread = db.find_one({'recipient.id': str(member.id), 'open': True}) if thread: - channel = self.bot.get_guild(int(thread['guild_id'])).get_channel(int(thread['channel_id'])) - await channel.send(f'**{member}** has been banned from the server') + channel = self.bot.get_channel(int(thread['channel_id'])) + await channel.send(f'**{member}** has been banned from the server and this thread is now closed.') if not thread['ban_appeal']: await self._close_generic(member, member.guild, channel, None) @@ -568,10 +586,8 @@ async def on_member_remove(self, member): channel = self.bot.get_guild(int(thread['guild_id'])).get_channel(int(thread['channel_id'])) if member.guild.id == config.guild: - msg, _ = await self._close_generic(member, member.guild, channel, '4h') - - if msg: - await channel.send(f'**{member}** has left the server. {msg}') + scheduledTime = await self._close_generic(member, member.guild, channel, '4h') + await channel.send(f'**{member}** has left the server. Thread scheduled to be closed {scheduledTime}') elif ( thread['ban_appeal'] and member.guild.id == config.appealGuild @@ -642,7 +658,7 @@ def _format_message_embed( return content, embed - async def _user_create_thread(self, message: discord.Message, interaction: discord.Interaction = None): + async def _user_create_thread(self, message: discord.Message, interaction: discord.Interaction = None, menu_interacted: bool = False): successfulDM = False attachments = [x.url for x in message.attachments] ctx = await self.bot.get_context(message) @@ -666,7 +682,7 @@ async def _user_create_thread(self, message: discord.Message, interaction: disco ) content, embed = self._format_message_embed(message, attachments, interaction=interaction) - await self.bot.get_guild(int(thread['guild_id'])).get_channel(int(thread['channel_id'])).send( + await self.bot.get_channel(int(thread['channel_id'])).send( embed=embed ) db.update_one( @@ -698,12 +714,16 @@ async def _user_create_thread(self, message: discord.Message, interaction: disco self.bot, message.author if not interaction else interaction.user, message, - 'user', + 'message_report' if menu_interacted else 'user', interaction=interaction, ) content, embed = self._format_message_embed(message, attachments, interaction=interaction) - await thread.send(embed=embed) + msgContent = f'<@&{config.modRole}>' + if not successfulDM: + msgContent += '\nPlease note, this user\'s DMs are closed. As such, they have been notified when they reported this message that they may not receive a moderator response.' + + await thread.send(content=msgContent, embed=embed, silent=True) if not interaction: await message.add_reaction('✅') diff --git a/cogs/utils.py b/cogs/utils.py index 15e1b75..765b318 100644 --- a/cogs/utils.py +++ b/cogs/utils.py @@ -27,6 +27,12 @@ 'note': 'User note', 'appealdeny': 'Denied ban appeal', } +tagIDS = { + 'user': config.userThreadTag, + 'moderator': config.modThreadTag, + 'ban_appeal': config.banAppealTag, + 'message_report': config.messageReportTag +} def resolve_duration(data): @@ -246,7 +252,7 @@ async def _close_thread( try: channel = bot.get_channel(thread_channel.id) - await channel.delete(reason=f'Modmail closed by {mod_user}') + await channel.edit(locked=True, archived=True, reason=f'Modmail closed by {mod_user}') except discord.NotFound: pass @@ -280,11 +286,10 @@ async def _close_thread( async def _trigger_create_user_thread( - bot, member, message, open_type, is_mention=False, moderator=None, content=None, anonymous=True, interaction=None + bot, member, message, open_type, is_mention=False, moderator=None, content=None, anonymous=True, interaction=None, ): db = mclient.modmail.logs punsDB = mclient.bowser.puns - banAppeal = False successfulDM = False guild = bot.get_guild(config.guild) @@ -294,7 +299,7 @@ async def _trigger_create_user_thread( except discord.NotFound: # If the user is not in the primary guild. Failsafe check in-case on_member_join didn't catch them - banAppeal = True + open_type = 'ban_appeal' try: await guild.fetch_ban(member) @@ -312,17 +317,14 @@ async def _trigger_create_user_thread( raise RuntimeError('User cannot appeal') # Deny thread creation if modmail restricted - if open_type == 'user' and not banAppeal: + if open_type == 'user': if not mclient.bowser.users.find_one({'_id': member.id})['modmail']: raise exceptions.ModmailBlacklisted - category = guild.get_channel(config.category) - channelName = member.name if member.discriminator == '0' else f'{member.name}-{member.discriminator}' - if banAppeal: - channelName = '🔨-' + channelName - channel = await category.create_text_channel(channelName, reason='New modmail opened') - - if banAppeal: + forum = guild.get_channel(config.forumChannel) + postName = member.name + ' - ' + if open_type == 'ban_appeal': + postName += 'Ban Appeal' embed = discord.Embed(title='New ban appeal submitted', color=0xEE5F5F) else: @@ -334,32 +336,25 @@ async def _trigger_create_user_thread( ) threadCount = db.count_documents({'recipient.id': str(member.id)}) - docID = await _create_thread( - bot, - channel, - member if not moderator else moderator, - member, - is_mention, - content=content, - is_mod=True if moderator else False, - ban_appeal=banAppeal, - message=message, - report=interaction, - ) punsDB = mclient.bowser.puns puns = punsDB.find({'user': member.id, 'active': True}) punsCnt = punsDB.count_documents({'user': member.id, 'active': True}) - if banAppeal: - description = f'A new ban appeal has been submitted by {member} ({member.mention}) and needs to be reviewed' + if open_type == 'ban_appeal': + description = f'A new ban appeal has been submitted by {member} ({member.mention}) and needs to be reviewed.' elif open_type == 'moderator': - description = f'A modmail thread has been opened with {member} ({member.mention}) by {moderator} ({moderator.mention}). There are {threadCount} previous threads involving this user' + postName += 'Mod Opened' + description = f'A modmail thread has been opened with {member} ({member.mention}) by {moderator} ({moderator.mention}). There are {threadCount} previous threads involving this user.' + + elif open_type == 'message_report': + postName += 'Message Reported' + description = f"A new message report needs to be reviewed from {member} ({member.mention}). There are {threadCount} previous threads involving this user." else: - description = f"A new modmail needs to be reviewed from {member} ({member.mention}). There are {threadCount} previous threads involving this user" + postName += 'Modmail' + description = f"A new modmail needs to be reviewed from {member} ({member.mention}). There are {threadCount} previous threads involving this user." - description += f'. Archive link: {config.logUrl}{docID}' if punsCnt: description += '\n\n__User has active punishments:__\n' for pun in puns: @@ -373,13 +368,33 @@ async def _trigger_create_user_thread( ) embed.description = description - mailMsg = await channel.send(embed=embed) - await _info(await bot.get_context(mailMsg), bot, member.id if banAppeal else await guild.fetch_member(member.id)) + tag = forum.get_tag(tagIDS[open_type]) + thread, threadMessage = await forum.create_thread( + name=postName, + auto_archive_duration=10080, + embed=embed, + applied_tags=[tag], + reason='New modmail opened' + ) + await _create_thread( + bot, + thread, + member if not moderator else moderator, + member, + is_mention, + content=content, + is_mod=True if moderator else False, + ban_appeal=open_type == 'ban_appeal', + message=message, + report=interaction, + ) + await _info(await bot.get_context(threadMessage), bot, member.id if open_type == 'ban_appeal' else await guild.fetch_member(member.id)) - if banAppeal: + if open_type == 'ban_appeal': await member.send( f'Hi there!\nYou have submitted a ban appeal to the chat moderators who oversee the **{guild.name}** Discord.\n\nI will send you a message when a moderator responds to this thread. Every message you send to me while your thread is open will also be sent to the moderation team -- so you can message me anytime to add information or to reply to a moderator\'s message. You\'ll know your message has been sent when I react to your message with a ✅.\n\nPlease be patient for a response; the moderation team will have active discussions about the appeal and may take some time to reply. We ask that you be civil and respectful during this process so constructive conversation can be had in both directions. At the end of this process, moderators will either lift or uphold your ban -- you will receive an official message stating the final decision.' ) + successfulDM = True else: try: @@ -391,7 +406,7 @@ async def _trigger_create_user_thread( except discord.Forbidden: pass - return channel, successfulDM + return thread, successfulDM async def _trigger_create_mod_thread(bot, guild, member, moderator): @@ -406,9 +421,9 @@ async def _trigger_create_mod_thread(bot, guild, member, moderator): except discord.NotFound: raise RuntimeError('Invalid user') # TODO: We need custom exceptions - category = guild.get_channel(config.category) - channelName = member.name if member.discriminator == '0' else f'{member.name}-{member.discriminator}' - channel = await category.create_text_channel(channelName, reason='New modmail opened') + forum = guild.get_channel(config.forumChannel) + postName = member.name + ' - Mod Opened' + tag = forum.get_tag(tagIDS['moderator']) embed = discord.Embed(title='New modmail opened', color=0xE3CF59) @@ -418,16 +433,12 @@ async def _trigger_create_mod_thread(bot, guild, member, moderator): ) threadCount = db.count_documents({'recipient.id': str(member.id)}) - docID = await _create_thread( - bot, channel, moderator, member, created_at=datetime.now(tz=timezone.utc).isoformat(sep=' ') - ) # Since we don't have a reference with slash commands, pull current iso datetime in UTC punsDB = mclient.bowser.puns puns = punsDB.find({'user': member.id, 'active': True}) punsCnt = punsDB.count_documents({'user': member.id, 'active': True}) - description = f'A modmail thread has been opened with {member} ({member.mention}) by {moderator} ({moderator.mention}). There are {threadCount} previous threads involving this user' - description += f'. Archive link: {config.logUrl}{docID}' + description = f'A modmail thread has been opened with {member} ({member.mention}) by {moderator} ({moderator.mention}). There are {threadCount} previous threads involving this user.' if punsCnt: description += '\n\n__User has active punishments:__\n' @@ -442,8 +453,18 @@ async def _trigger_create_mod_thread(bot, guild, member, moderator): ) embed.description = description - mailMsg = await channel.send(moderator.mention, embed=embed) - await _info(await bot.get_context(mailMsg), bot, await guild.fetch_member(member.id)) + thread, threadMessage = await forum.create_thread( + name=postName, + auto_archive_duration=10080, + content=moderator.mention, + embed=embed, + applied_tags=[tag], + reason='New modmail opened' + ) + docID = await _create_thread( + bot, thread, moderator, member, created_at=datetime.now(tz=timezone.utc).isoformat(sep=' ') + ) # Since we don't have a reference with slash commands, pull current iso datetime in UTC + await _info(await bot.get_context(threadMessage), bot, await guild.fetch_member(member.id)) try: await member.send( f'Hi there!\nThe chat moderators who oversee the **{guild.name}** Discord have opened a modmail with you!\n\nI will send you a message when a moderator responds to this thread. Every message you send to me while your thread is open will also be sent to the moderation team -- so you can message me anytime to add information or to reply to a moderator\'s message. You\'ll know your message has been sent when I react to your message with a ✅.' @@ -452,7 +473,7 @@ async def _trigger_create_mod_thread(bot, guild, member, moderator): except discord.Forbidden: # Cleanup if there really was an issue messaging the user, i.e. bot blocked db.delete_one({'_id': docID}) - await channel.delete() + await thread.delete() raise embed = discord.Embed( @@ -460,7 +481,7 @@ async def _trigger_create_mod_thread(bot, guild, member, moderator): description='This thread is now open to moderator and user replies. Start the conversation by using `/reply` or `/areply`', color=0x58B9FF, ) - await channel.send(embed=embed) + await thread.send(content=f'<@&{config.modRole}>', embed=embed, silent=True) async def _info(ctx, bot, user: typing.Union[discord.Member, int]): diff --git a/config.example.py b/config.example.py index 0a64393..096610d 100644 --- a/config.example.py +++ b/config.example.py @@ -9,6 +9,7 @@ # Channel IDs modLog: int = mod_log_channel_id adminChannel: int = admin_channel_id +forumChannel: int = modmail_forum_id # Category ID category: int = modmail_category_id @@ -17,15 +18,21 @@ guild: int = modmail_guild_id appealGuild: int = ban_appeal_guild_id -# Role ID +# Role IDs leadModRole: int = lead_moderator_role_id modRole: int = moderator_role_id trialModRole: int = trial_moderator_role_id -# Emoji ID +# Emoji IDs addTick = '<:addTickL:951241243604713492>' removeTick = '<:removeTickL:951249921862926367>' +# Tag IDs +userThreadTag: int = user_created_tag_id +modThreadTag: int = moderator_created_tag_id +banAppealTag: int = user_ban_appeal_tag_id +messageReportTag: int = message_reported_tag_id + # URLs logUrl = 'https://example.com/logs/' appealInvite = 'https://discord.gg/invite' diff --git a/exceptions.py b/exceptions.py index 54e07c4..f8744eb 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1,6 +1,11 @@ -class InvalidType(Exception): +class InvalidDuration(Exception): pass +class InvalidType(Exception): + pass class ModmailBlacklisted(Exception): pass + +class NotAModmail(Exception): + pass \ No newline at end of file