From 6352c2c11d90cf158a459e6eb288871bfdbe5325 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 7 Jan 2024 21:05:56 +0100 Subject: [PATCH 01/32] WIP --- techsupport_bot/bot.py | 5 ++++- techsupport_bot/main.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index f1dc80995..32bd62f69 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -22,6 +22,9 @@ from discord import app_commands from discord.ext import commands +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + class TechSupportBot(commands.Bot): """The main bot object.""" @@ -623,7 +626,7 @@ async def load_extensions(self, graceful: bool = True) -> None: self.logger.console.error( f"Failed to load extension {extension_name}: {exception}" ) - if not graceful: + if not graceful or extension_name == "modmail": raise exception self.logger.console.debug("Retrieving functions") diff --git a/techsupport_bot/main.py b/techsupport_bot/main.py index c5de17665..549dcfdf3 100644 --- a/techsupport_bot/main.py +++ b/techsupport_bot/main.py @@ -34,4 +34,9 @@ intents=intents, allowed_mentions=discord.AllowedMentions(everyone=False, roles=False), ) -asyncio.run(bot_.start()) +# Starts a custom event loop, because modmail requires it to be run separately +# TODO: actually explain if it works + + +bot.loop.create_task(bot_.start()) +bot.loop.run_forever() From 3d20e150d23fae34f0c4ab634e78e56fbb467a0f Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 7 Jan 2024 21:09:26 +0100 Subject: [PATCH 02/32] WIP: Added file --- techsupport_bot/commands/modmail.py | 317 ++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 techsupport_bot/commands/modmail.py diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py new file mode 100644 index 000000000..23b09f0b9 --- /dev/null +++ b/techsupport_bot/commands/modmail.py @@ -0,0 +1,317 @@ +""" +Modmail stuff +""" + + +from datetime import datetime + +import discord +from core import auxiliary, cogs, extensionconfig +from discord.ext import commands + + +# This is not run within the setup hooks because the user *only used for one guild* +class Modmail_bot(discord.Client): + """The bot used to send and receive DM messages""" + + async def on_message(self, message: discord.Message) -> None: + """Listens to DMs, forwards them to handle_dm for proper handling + + Args: + message (discord.Message): Any sent message, gets filtered to only dms + """ + if isinstance(message.channel, discord.DMChannel) and not message.author.bot: + await message.add_reaction("✅") + await handle_dm(message) + + +# Both of these get assigned in the __init__ +Ts_client = None +MODMAIL_CHANNEL_ID = None + +intents = discord.Intents.default() +intents.members = True +Modmail_client = Modmail_bot(intents=intents) + + +async def create_thread( + channel: discord.TextChannel, user: discord.User, content: str = None +): + """Creates a thread from a DM message + + Args: + channel (discord.TextChannel): The forum channel to create the thread in + message (discord.Message): The original message + """ + + # --> WELCOME MESSAGE <-- + + # Formatting the description of the initial message + description = ( + f"{user.mention} was created {discord.utils.format_dt(user.created_at, 'R')}" + ) + + # Gets past threadss + past_thread_count = 0 + for thread in channel.threads: + if int(thread.name.split("|")[-1].strip()) == user.id: + past_thread_count += 1 + + if past_thread_count > 0: + description += f" and has {past_thread_count} past threads" + else: + description += " and has **no** past threads" + + embed = discord.Embed(color=discord.Color.blue(), description=description) + + # If user is a member, do member specific things + member = channel.guild.get_member(user.id) + if member: + description += f", joined at {discord.utils.format_dt(member.joined_at, 'R')}" + embed.add_field(name="Nickname", value=member.nick) + roles = [] + + for role in sorted(member.roles, key=lambda x: x.position, reverse=True): + if role.is_default(): + continue + roles.append(role.mention) + + embed.add_field(name="Roles", value=", ".join(roles)) + else: + description += ", is not in this server" + + embed.set_author(name=user, icon_url=user.avatar.url) + embed.set_footer(text=f"User ID: {user.id}") + embed.timestamp = datetime.utcnow() + + thread = await channel.create_thread( + name=f"[OPEN] | {user} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {user.id}", + embed=embed, + ) + + # --> ACTUAL MESSAGE <-- + if content: + embed = discord.Embed(color=discord.Color.yellow(), description=content) + embed.set_author(name=user, icon_url=user.avatar.url) + embed.timestamp = datetime.utcnow() + + await thread[0].send(embed=embed) + + +async def handle_dm(message: discord.Message) -> None: + """Sends a registered dm to the appropriate thread + + Args: + message (discord.Message): The incoming message + """ + # Finds the channel from TS-es side, so it can create the thread + modmail_channel = Ts_client.get_channel(MODMAIL_CHANNEL_ID) + + # Tries to find existing threads to send the message to + try: + for thread in modmail_channel.threads: + if ( + thread.name.startswith("[OPEN]") + and thread.name.split("|")[-1].strip() == message.author.id + ): + embed = discord.Embed( + color=discord.Color.blue(), description=message.content + ) + embed.set_author( + name=message.author, icon_url=message.author.avatar.url + ) + embed.set_footer(text=f"Message ID: {message.id}") + embed.timestamp = datetime.utcnow() + + await thread.send(embed=embed) + return + except AttributeError: + # The channel doesn't have any threads, no need to search + pass + await create_thread(modmail_channel, message.author, content=message.content) + + +async def reply_to_thread( + content: str, + author: discord.user, + thread: discord.Thread, + anonymous: bool, +): + """Replies to a modmail message on both the dm side and the ts side + + Args: + raw_content (str): The message to send + author (discord.user): The author of the message + thread (discord.Thread): The thread to reply to + anonymous (bool): Whether to reply anonymously + """ + # Removes the command call + + target_member = discord.utils.get( + thread.guild.members, id=int(thread.name.split("|")[-1].strip()) + ) + # Refetches the user from modmails client so it can reply to it instead of TS + user = Modmail_client.get_user(target_member.id) + + # - Modmail thread side - + embed = discord.Embed(color=discord.Color.green(), description=content) + embed.timestamp = datetime.utcnow() + embed.set_author(name=author, icon_url=author.avatar.url) + embed.set_footer(text="Response") + + if anonymous: + embed.set_footer(text="[Anonymous] Response") + + await thread.send(embed=embed) + + # - User side - + embed.set_footer(text="Response") + + if anonymous: + embed.set_author(name="rTechSupport Moderator", icon_url=thread.guild.icon.url) + + await user.send(embed=embed) + + +# ------------------------------------------------------------------------------------------------- + + +async def setup(bot): + """Sets the modmail extension up""" + config = extensionconfig.ExtensionConfig() + + config.add( + key="aliases", + datatype="dict", + title="Aliases for modmail messages", + description="Custom commands to send message slices", + default={}, + ) + + await bot.add_cog(Modmail(bot=bot)) + bot.add_extension_config("modmail", config) + + +class Modmail(cogs.BaseCog): + """The modmail cog class""" + + def __init__(self, bot): + """Init is used to make variables global so they can be used on the modmail side""" + + # Makes the TS client available to create threads and populate them with info + # pylint: disable=W0603 + global Ts_client + Ts_client = bot + Ts_client.loop.create_task( + Modmail_client.start(bot.file_config.modmail_config.modmail_auth_token) + ) + + # Sets the modmail channel from config, has to be here otherwise it'd be hardcoded + # pylint: disable=W0603 + global MODMAIL_CHANNEL_ID + MODMAIL_CHANNEL_ID = int(bot.file_config.modmail_config.modmail_forum_channel) + + self.global_timeouts = {} + self.prefix = bot.file_config.modmail_config.modmail_prefix + self.bot = bot + + @commands.Cog.listener() + async def on_ready(self): + """Fetches the modmail channel only once ready""" + await self.bot.wait_until_ready() + self.modmail_forum = self.bot.get_channel(MODMAIL_CHANNEL_ID) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + """Processes sent messages matching the prefix sent in modmail threads + + Args: + message (discord.Message): The sent message + """ + if ( + not message.content.startswith(self.prefix) + or not isinstance(message.channel, discord.Thread) + or message.channel.parent_id != self.modmail_forum.id + or message.channel.name.startswith("[CLOSED]") + ): + return + + # Gets the content without the prefix + content = message.content.partition(self.prefix)[2] + + # Checks if the message was a command + match content.split()[0]: + case "close": + await message.channel.send( + embed=auxiliary.generate_basic_embed( + color=discord.Color.red(), + title="Thread closed.", + description="", + ) + ) + await message.channel.edit( + name=f"[CLOSED] {message.channel.name[6:]}", + archived=True, + locked=True, + ) + return + + case "reply": + await message.delete() + await reply_to_thread( + " ".join(content.split()[1:]), + message.author, + message.channel, + anonymous=False, + ) + return + + case "areply": + await message.delete() + await reply_to_thread( + " ".join(content.split()[1:]), + message.author, + message.channel, + anonymous=True, + ) + return + + # Checks if it is an alias instead + config = self.bot.guild_configs[str(self.modmail_forum.guild.id)] + aliases = config.extensions.modmail.aliases.value + + for alias in aliases: + if alias != content.split()[0]: + continue + + await message.delete() + await reply_to_thread(aliases[alias], message.author, message.channel, True) + return + + @auxiliary.with_typing + @commands.command(name="contact", brief="") + async def contact(self, ctx: commands.Context, user: discord.User): + """Opens a modmail thread with a person of your choice + + Args: + ctx (commands.Context): _description_ + user (discord.User): _description_ + """ + modmail_forum = self.bot.get_channel(MODMAIL_CHANNEL_ID) + + for thread in modmail_forum.threads: + if ( + thread.name.startswith("[OPEN]") + and int(thread.name.split("|")[-1].strip()) == user.id + ): + await auxiliary.send_deny_embed( + message=f"User already has an open thread! <#{thread.id}>", + channel=ctx.channel, + ) + return + + await create_thread(modmail_forum, user=user) + + await auxiliary.send_confirm_embed( + message="Thread succesfully created!", channel=ctx.channel + ) From 6239413a14208d74a529fb7a6f75e0f6b296bdfb Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Mon, 12 Feb 2024 22:59:46 +0100 Subject: [PATCH 03/32] wip push, for debug online --- techsupport_bot/commands/factoids.py | 8 + techsupport_bot/commands/modmail.py | 650 +++++++++++++++++++++++---- 2 files changed, 568 insertions(+), 90 deletions(-) diff --git a/techsupport_bot/commands/factoids.py b/techsupport_bot/commands/factoids.py index 2207d2a0a..e69db6f3d 100644 --- a/techsupport_bot/commands/factoids.py +++ b/techsupport_bot/commands/factoids.py @@ -44,6 +44,14 @@ async def setup(bot): description="The roles required to manage factoids", default=["Factoids"], ) + config.add( + key="test_value", + datatype="list", + title="Manage factoids roles", + description="The roles required to manage factoids", + default=["Factoids"], + ) + config.add( key="prefix", datatype="str", diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 23b09f0b9..cf889bbe9 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -3,47 +3,166 @@ """ +import asyncio from datetime import datetime import discord -from core import auxiliary, cogs, extensionconfig +from botlogging import LogContext, LogLevel +import expiringdict +import ui +import re +from core import auxiliary, cogs, extensionconfig, custom_errors from discord.ext import commands -# This is not run within the setup hooks because the user *only used for one guild* +async def has_modmail_management_role(ctx: commands.Context): + """-COMMAND CHECK- + Checks if the invoker has a modmail management role + + Args: + ctx (commands.Context): Context used for getting the config file + + Raises: + commands.CommandError: No modmail management roles were assigned in the config + commands.MissingAnyRole: Invoker doesn't have a modmail role + + Returns: + bool: Whether the invoker has a modmail management role + """ + + config = ctx.bot.guild_configs[str(ctx.guild.id)] + modmail_roles = [] + + # Gets permitted roles + for role_id in config.extensions.modmail.modmail_roles.value: + role = discord.utils.get(ctx.guild.roles, id=role_id) + if not role: + continue + modmail_roles.append(role) + + if not modmail_roles: + raise commands.CommandError("No modmail roles were assigned in the config file") + # Checking against the user to see if they have amy of the roles specified in the config + if not any( + modmail_role in getattr(ctx.author, "roles", []) + for modmail_role in modmail_roles + ): + raise commands.MissingAnyRole(modmail_roles) + + return True + + class Modmail_bot(discord.Client): """The bot used to send and receive DM messages""" async def on_message(self, message: discord.Message) -> None: - """Listens to DMs, forwards them to handle_dm for proper handling + + """Listen to DMs, forward them to handle_dm for proper handling when applicable Args: message (discord.Message): Any sent message, gets filtered to only dms """ if isinstance(message.channel, discord.DMChannel) and not message.author.bot: + # User is banned from creating modmail threads + if await Ts_client.models.ModmailBan.query.where( + Ts_client.models.ModmailBan.user_id == str(message.author.id) + ).gino.first(): + await message.add_reaction("❌") + return + + # Spam protection + if message.author.id in delayed_people: + await message.add_reaction("🕒") + await auxiliary.send_deny_embed( + message="To restrict spam, you can not open a new thread within 24 hours of a thread being closed. Please try again later.", + channel=message.channel, + ) + + return + + # Finally, normal handling await message.add_reaction("✅") await handle_dm(message) + + async def on_typing(self, channel: discord.DMChannel, user: discord.User, _: datetime): + """When someone starts typing in modmails dms, starts typing in the corresponding thread + + Args: + channel (discord.Channel): _description_ + user (discord.User): _description_ + """ + if isinstance(channel, discord.DMChannel) and user.id in active_threads: + await self.get_channel(active_threads[user.id]).typing() -# Both of these get assigned in the __init__ +# These get assigned in the __init__, are needed for inter-bot comm +# It is a goofy solution but given that this extension is only used in ONE guild, it's good enough Ts_client = None -MODMAIL_CHANNEL_ID = None +MODMAIL_FORUM_ID = None +MODMAIL_LOG_CHANNEL_ID = None +ROLES_TO_PING = None +AUTOMATIC_RESPONSES = None + +active_threads = {} +closure_jobs = {} +delayed_people = expiringdict.ExpiringDict( + max_age_seconds=93600, max_len=1000 # max_len has to be set for some reason +) + +# Prepares the Modmail client with the Members intent for the lookup +# It is actually started in __init__ of the modmail command, the client is defined here +# since it is used elsewhere intents = discord.Intents.default() intents.members = True Modmail_client = Modmail_bot(intents=intents) +async def handle_dm(message: discord.Message) -> None: + """Sends a received dm to the appropriate thread + + Args: + message (discord.Message): The incoming message + """ + # the bot is not ready to handle dms yet + if not Ts_client or not MODMAIL_FORUM_ID: + await message.channel.send( + discord.Embed( + color=discord.Color.light_gray(), + description="Bot is still starting, please wait...", + ) + ) + return + + # Gets the modmail channel from TS-es side so it can create the thread + modmail_channel = Ts_client.get_channel(MODMAIL_FORUM_ID) + + if message.author.id in active_threads: + embed = discord.Embed(color=discord.Color.blue(), description=message.content) + embed.set_author(name=message.author, icon_url=message.author.avatar.url) + embed.set_footer(text=f"Message ID: {message.id}") + embed.timestamp = datetime.utcnow() + + thread = Ts_client.get_channel(active_threads[message.author.id]) + await thread.send(embed=embed) + return + + # No thread was found, creates one manually + await create_thread( + channel=modmail_channel, user=message.author, content=message.content + ) + + async def create_thread( - channel: discord.TextChannel, user: discord.User, content: str = None -): + channel: discord.TextChannel, user: discord.User, content: str = "" +) -> None: """Creates a thread from a DM message Args: channel (discord.TextChannel): The forum channel to create the thread in - message (discord.Message): The original message + user (discord.User): The user who sent the DM + contents (str, optional): The DM contents, defaults to an empty string """ - # --> WELCOME MESSAGE <-- # Formatting the description of the initial message @@ -51,20 +170,19 @@ async def create_thread( f"{user.mention} was created {discord.utils.format_dt(user.created_at, 'R')}" ) - # Gets past threadss past_thread_count = 0 for thread in channel.threads: if int(thread.name.split("|")[-1].strip()) == user.id: past_thread_count += 1 - if past_thread_count > 0: - description += f" and has {past_thread_count} past threads" - else: + if past_thread_count == 0: description += " and has **no** past threads" + else: + description += f" and has {past_thread_count} past threads" embed = discord.Embed(color=discord.Color.blue(), description=description) - # If user is a member, do member specific things + # If the user is a member, do member specific things member = channel.guild.get_member(user.id) if member: description += f", joined at {discord.utils.format_dt(member.joined_at, 'R')}" @@ -80,72 +198,78 @@ async def create_thread( else: description += ", is not in this server" - embed.set_author(name=user, icon_url=user.avatar.url) + if user.avatar: + url = user.avatar.url + else: + url = user.default_avatar.url + + embed.set_author(name=user, icon_url=url) embed.set_footer(text=f"User ID: {user.id}") embed.timestamp = datetime.utcnow() + # --> Handling for autoping roles <-- + role_string = "" + if ROLES_TO_PING: + for role_id in ROLES_TO_PING: + role_string += f"<@&{role_id}>" + + # --> THREAD CREATION <-- + + # The thread CAN NOT be renamed while open, this is because of how the bot gets metadata + # about the user + # [STATUS] | Username | Date of creation | User id thread = await channel.create_thread( name=f"[OPEN] | {user} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {user.id}", embed=embed, + content=role_string, ) + active_threads[user.id] = thread[0].id - # --> ACTUAL MESSAGE <-- + # --> MESSAGE SENDING <-- if content: embed = discord.Embed(color=discord.Color.yellow(), description=content) - embed.set_author(name=user, icon_url=user.avatar.url) + embed.set_author(name=user, icon_url=url) embed.timestamp = datetime.utcnow() await thread[0].send(embed=embed) - - -async def handle_dm(message: discord.Message) -> None: - """Sends a registered dm to the appropriate thread - - Args: - message (discord.Message): The incoming message - """ - # Finds the channel from TS-es side, so it can create the thread - modmail_channel = Ts_client.get_channel(MODMAIL_CHANNEL_ID) - - # Tries to find existing threads to send the message to - try: - for thread in modmail_channel.threads: - if ( - thread.name.startswith("[OPEN]") - and thread.name.split("|")[-1].strip() == message.author.id - ): - embed = discord.Embed( - color=discord.Color.blue(), description=message.content - ) - embed.set_author( - name=message.author, icon_url=message.author.avatar.url - ) - embed.set_footer(text=f"Message ID: {message.id}") - embed.timestamp = datetime.utcnow() - - await thread.send(embed=embed) - return - except AttributeError: - # The channel doesn't have any threads, no need to search - pass - await create_thread(modmail_channel, message.author, content=message.content) + + # --> AUTORESPONSE HANDLING <-- + for regex in AUTOMATIC_RESPONSES: + if re.match(regex, content): + await reply_to_thread( + content=AUTOMATIC_RESPONSES[regex], + author=Ts_client.user, # Set for the sake of consistency, is not actually used + thread=thread, + anonymous=True + ) + return async def reply_to_thread( content: str, - author: discord.user, + author: discord.User, thread: discord.Thread, anonymous: bool, -): - """Replies to a modmail message on both the dm side and the ts side +) -> None: + """Replies to a modmail thread on both the dm side and the ts side Args: - raw_content (str): The message to send - author (discord.user): The author of the message + raw_content (str): The content to send + author (discord.user): The author of the outgoing message thread (discord.Thread): The thread to reply to anonymous (bool): Whether to reply anonymously """ - # Removes the command call + # If thread is going to be closed, cancels it + if thread.id in closure_jobs: + closure_jobs[thread.id].cancel() + del closure_jobs[thread.id] + + await thread.send( + embed=discord.Embed( + color=discord.Color.red(), + description="Scheduled close has been cancelled.", + ) + ) target_member = discord.utils.get( thread.guild.members, id=int(thread.name.split("|")[-1].strip()) @@ -173,7 +297,82 @@ async def reply_to_thread( await user.send(embed=embed) -# ------------------------------------------------------------------------------------------------- +async def close_thread( + thread: discord.Thread, + silent: bool, + timed: bool, + log_channel: discord.TextChannel, + user: discord.User, + closed_by: discord.User, +): + # Waits 5 minutes before closing, only happens when func is run as an asyncio job + if timed: + embed = discord.Embed( + color=discord.Color.red(), + description="This thread will close in 5 minutes.", + ) + embed.set_author(name="Scheduled close") + embed.set_footer( + text="Closing will be cancelled if a message is sent, or if you rerun the command." + ) + embed.timestamp = datetime.utcnow() + + await thread.send(embed=embed) + + await asyncio.sleep(300) + + del active_threads[user.id] + # Removes closure job from queue + if timed: + del closure_jobs[thread.id] + + # Closes the thread + await thread.send( + embed=auxiliary.generate_basic_embed( + color=discord.Color.red(), + title="Thread closed.", + description="", + ) + ) + await thread.edit( + name=f"[CLOSED] {thread.name[6:]}", + archived=True, + locked=True, + ) + # It just has to exist in the dict to trigger + delayed_people[user.id] = "" + + await log_closure(thread, user, log_channel, closed_by) + + if silent: + return + + # Sends the close message + embed = discord.Embed( + color=discord.Color.light_gray(), + description="Please wait 24 hours before creating a new one.", + ) + embed.set_author(name="Thread closed") + embed.timestamp = datetime.utcnow() + + await user.send(embed=embed) + + + + +async def log_closure(thread, user, log_channel, closed_by: discord.User): + embed = discord.Embed( + color=discord.Color.red(), + description=f"<#{thread.id}>", + title=f"{user.name} `{user.id}`", + ) + embed.set_footer( + icon_url=closed_by.avatar.url, + text=f"Thread closed by {closed_by.name}", + ) + embed.timestamp = datetime.utcnow() + + await log_channel.send(embed=embed) async def setup(bot): @@ -188,6 +387,31 @@ async def setup(bot): default={}, ) + config.add( + key="automatic_responses", + datatype="dict", + title="Modmail autoresponses", + description="If someone sends a message containing a key, sends its value", + default={}, + ) + + + config.add( + key="modmail_roles", + datatype="list", + title="Roles that can access modmail and its commands", + description="Roles that can access modmail and its commands", + default=[], + ) + + config.add( + key="roles_to_ping", + datatype="list", + title="Roles to ping on thread creation", + description="Roles to ping on thread creation", + default=[], + ) + await bot.add_cog(Modmail(bot=bot)) bot.add_extension_config("modmail", config) @@ -198,7 +422,7 @@ class Modmail(cogs.BaseCog): def __init__(self, bot): """Init is used to make variables global so they can be used on the modmail side""" - # Makes the TS client available to create threads and populate them with info + # Makes the TS client available globally for creating threads and populating them with info # pylint: disable=W0603 global Ts_client Ts_client = bot @@ -206,12 +430,30 @@ def __init__(self, bot): Modmail_client.start(bot.file_config.modmail_config.modmail_auth_token) ) - # Sets the modmail channel from config, has to be here otherwise it'd be hardcoded + # Sets the modmail channel from config, has to be here otherwise it'd have to be hardcoded + # pylint: disable=W0603 + global MODMAIL_FORUM_ID + MODMAIL_FORUM_ID = int(bot.file_config.modmail_config.modmail_forum_channel) + + # Same for the log channel + # pylint: disable=W0603 + global MODMAIL_LOG_CHANNEL_ID + MODMAIL_LOG_CHANNEL_ID = int(bot.file_config.modmail_config.modmail_log_channel) + + + config = bot.guild_configs[str(bot.file_config.modmail_config.modmail_guild)] + + # Makes all role IDs to ping when thread is being created global + # pylint: disable=W0603 + global ROLES_TO_PING + ROLES_TO_PING = config.extensions.modmail.roles_to_ping.value + + # Lastly, makes the automatic rseponses global so they can be accessed from modmails side # pylint: disable=W0603 - global MODMAIL_CHANNEL_ID - MODMAIL_CHANNEL_ID = int(bot.file_config.modmail_config.modmail_forum_channel) + global AUTOMATIC_RESPONSES + AUTOMATIC_RESPONSES = config.extensions.modmail.automatic_responses.value + - self.global_timeouts = {} self.prefix = bot.file_config.modmail_config.modmail_prefix self.bot = bot @@ -219,11 +461,22 @@ def __init__(self, bot): async def on_ready(self): """Fetches the modmail channel only once ready""" await self.bot.wait_until_ready() - self.modmail_forum = self.bot.get_channel(MODMAIL_CHANNEL_ID) + # Has to be done in here, putting into preconfig breaks stuff for some reason + self.modmail_forum = self.bot.get_channel(MODMAIL_FORUM_ID) + + # Populates the currently active threads + + for thread in self.modmail_forum.threads: + if thread.name.startswith("[OPEN]"): + # Funky stuff time + # [username, date, id] + active_threads[int(thread.name.split(" | ")[3])] = thread.id + + return @commands.Cog.listener() async def on_message(self, message: discord.Message): - """Processes sent messages matching the prefix sent in modmail threads + """Processes messages sent in a modmail thread, basically a manual command handler Args: message (discord.Message): The sent message @@ -239,29 +492,100 @@ async def on_message(self, message: discord.Message): # Gets the content without the prefix content = message.content.partition(self.prefix)[2] - # Checks if the message was a command + # Checks if the message had a command match content.split()[0]: + # - Normal closes - case "close": - await message.channel.send( - embed=auxiliary.generate_basic_embed( - color=discord.Color.red(), - title="Thread closed.", - description="", + await close_thread( + thread=message.channel, + silent=False, + timed=False, + user=self.bot.get_user( + int(message.channel.name.split("|")[-1].strip()) + ), + log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), + closed_by=message.author, + ) + + return + + case "tclose": + # If close was ran already, cancel it + if message.channel.id in closure_jobs: + closure_jobs[message.channel.id].cancel() + del closure_jobs[message.channel.id] + + await message.channel.send( + embed=discord.Embed( + color=discord.Color.red(), + description="Scheduled close has been cancelled.", + ) + ) + return + + # I LOVE INDENTATIONS THEY ARE SO COOL + closure_jobs[message.channel.id] = asyncio.create_task( + close_thread( + thread=message.channel, + silent=False, + timed=True, + user=self.bot.get_user( + int(message.channel.name.split("|")[-1].strip()) + ), + log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), + closed_by=message.author, ) ) - await message.channel.edit( - name=f"[CLOSED] {message.channel.name[6:]}", - archived=True, - locked=True, + + # - Silent closes - + case "sclose": + await close_thread( + thread=message.channel, + silent=True, + timed=False, + user=self.bot.get_user( + int(message.channel.name.split("|")[-1].strip()) + ), + log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), + closed_by=message.author, ) + return + case "tsclose": + # If close was ran already, cancel it + if message.channel.id in closure_jobs: + closure_jobs[message.channel.id].cancel() + del closure_jobs[message.channel.id] + + await message.channel.send( + embed=discord.Embed( + color=discord.Color.red(), + description="Scheduled close has been cancelled.", + ) + ) + return + + closure_jobs[message.channel.id] = asyncio.create_task( + close_thread( + thread=message.channel, + silent=True, + timed=True, + user=self.bot.get_user( + int(message.channel.name.split("|")[-1].strip()) + ), + log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), + closed_by=message.author, + ) + ) + + # - Replies - case "reply": await message.delete() await reply_to_thread( - " ".join(content.split()[1:]), - message.author, - message.channel, + content=" ".join(content.split()[1:]), + author=message.author, + thread=message.channel, anonymous=False, ) return @@ -269,13 +593,38 @@ async def on_message(self, message: discord.Message): case "areply": await message.delete() await reply_to_thread( - " ".join(content.split()[1:]), - message.author, - message.channel, + content=" ".join(content.split()[1:]), + author=message.author, + thread=message.channel, anonymous=True, ) return + + # Sends a factoid + case "send": + # Replaces \n with spaces so factoid can be called even with newlines + query = message.content.replace("\n", " ").split(" ")[1].lower() + factoid = ( + await self.bot.models.Factoid.query.where( + self.bot.models.Factoid.name == query.lower() + ) + .where(self.bot.models.Factoid.guild == str(message.guild.id)) + .gino.first() + ) + + if not factoid: + await auxiliary.send_deny_embed(message=f"Couldn't find the factoid `{query}`", channel=message.channel) + return + + await reply_to_thread( + content=factoid.message, + author=message.author, + thread=message.channel, + anonymous=True + ) + + # Checks if it is an alias instead config = self.bot.guild_configs[str(self.modmail_forum.guild.id)] aliases = config.extensions.modmail.aliases.value @@ -294,24 +643,145 @@ async def contact(self, ctx: commands.Context, user: discord.User): """Opens a modmail thread with a person of your choice Args: - ctx (commands.Context): _description_ - user (discord.User): _description_ + ctx (commands.Context): Context of the command execution + user (discord.User): The user to start a thread with """ - modmail_forum = self.bot.get_channel(MODMAIL_CHANNEL_ID) + modmail_forum = self.bot.get_channel(MODMAIL_FORUM_ID) for thread in modmail_forum.threads: - if ( - thread.name.startswith("[OPEN]") - and int(thread.name.split("|")[-1].strip()) == user.id - ): + if user.id in active_threads: await auxiliary.send_deny_embed( message=f"User already has an open thread! <#{thread.id}>", channel=ctx.channel, ) return - await create_thread(modmail_forum, user=user) + view = ui.Confirm() + await view.send( + message=(f"Create new modmail thread to {user.mention}?"), + channel=ctx.channel, + author=ctx.author, + ) + + await view.wait() + + match view.value: + case ui.ConfirmResponse.TIMEOUT: + pass + + case ui.ConfirmResponse.DENIED: + await auxiliary.send_deny_embed( + message="The thread was not created.", + channel=ctx.channel, + ) + + case ui.ConfirmResponse.CONFIRMED: + await create_thread(modmail_forum, user=user) + + await auxiliary.send_confirm_embed( + message="Thread succesfully created!", channel=ctx.channel + ) + + @commands.group(name="modmail") + async def modmail(self, ctx): + """Method for the modmail command group.""" + + # Executed if there are no/invalid args supplied + await auxiliary.extension_help(self, ctx, self.__module__[9:]) + + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @modmail.command( + name="ban", brief="Bans a user from creating future modmail threads" + ) + async def modmail_ban(self, ctx: commands.Context, user: discord.User): + """Opens a modmail thread with a person of your choice + + Args: + ctx (commands.Context): Context of the command execution + user (discord.User): The user to ban + """ + if await self.bot.models.ModmailBan.query.where( + self.bot.models.ModmailBan.user_id == str(user.id) + ).gino.first(): + return await auxiliary.send_deny_embed( + message=f"{user.mention} is already banned!", channel=ctx.channel + ) + + config = ctx.bot.guild_configs[str(ctx.guild.id)] + modmail_roles = [] + # Gets permitted roles + for role_id in config.extensions.modmail.modmail_roles.value: + modmail_role = discord.utils.get(ctx.guild.roles, id=role_id) + if not modmail_role: + continue + modmail_roles.append(modmail_role) + + # Checking against the user to see if they have the roles specified in the config + if any( + modmail_role in getattr(user, "roles", []) for modmail_role in modmail_roles + ): + return await auxiliary.send_deny_embed( + message="You cannot ban someone with a modmail role!", + channel=ctx.channel, + ) + + view = ui.Confirm() + await view.send( + message=( + f"Are you sure you want to ban {user.mention} from creating modmail threads?" + ), + channel=ctx.channel, + author=ctx.author, + ) + + await view.wait() + + match view.value: + case ui.ConfirmResponse.TIMEOUT: + pass + + case ui.ConfirmResponse.DENIED: + return await auxiliary.send_deny_embed( + message=f"{user.mention} was not banned from creating modmail threads.", + channel=ctx.channel, + ) + + case ui.ConfirmResponse.CONFIRMED: + await self.bot.models.ModmailBan( + user_id=str(user.id), ban_date=datetime.utcnow() + ).create() + + return await auxiliary.send_confirm_embed( + message=f"{user.mention} was succesfully banned from creating future modmail threads!", + channel=ctx.channel, + ) + + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @modmail.command( + name="unban", brief="Unans a user from creating future modmail threads" + ) + async def modmail_unban(self, ctx: commands.Context, user: discord.User): + """Opens a modmail thread with a person of your choice + + Args: + ctx (commands.Context): Context of the command execution + user (discord.User): The user to ban + """ + ban_entry = await self.bot.models.ModmailBan.query.where( + self.bot.models.ModmailBan.user_id == str(user.id) + ).gino.first() + + if not ban_entry: + return await auxiliary.send_deny_embed( + message=f"{user.mention} is not banned from making modmail threads!", + channel=ctx.channel, + ) + + await ban_entry.delete() - await auxiliary.send_confirm_embed( - message="Thread succesfully created!", channel=ctx.channel + return await auxiliary.send_confirm_embed( + message=f"{user.mention} succesfully unbanned from creating modmail threads!", + channel=ctx.channel, ) From df4b46046b044bf915b65235bc6f076d82b27010 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:27:20 +0100 Subject: [PATCH 04/32] Added config options --- config.default.yml | 6 ++++ techsupport_bot/commands/modmail.py | 53 ++++++++++++++--------------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/config.default.yml b/config.default.yml index 52bc426f1..3e305451e 100644 --- a/config.default.yml +++ b/config.default.yml @@ -6,6 +6,12 @@ bot_config: disabled_extensions: ["kanye"] default_prefix: "." global_alerts_channel: "" +modmail_config: + modmail_auth_token: + modmail_prefix: + modmail_guild: + modmail_forum_channel: + modmail_log_channel: database: postgres: user: diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index cf889bbe9..09e580663 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -4,14 +4,14 @@ import asyncio +import re from datetime import datetime import discord -from botlogging import LogContext, LogLevel import expiringdict import ui -import re -from core import auxiliary, cogs, extensionconfig, custom_errors +from botlogging import LogContext, LogLevel +from core import auxiliary, cogs, custom_errors, extensionconfig from discord.ext import commands @@ -56,7 +56,6 @@ class Modmail_bot(discord.Client): """The bot used to send and receive DM messages""" async def on_message(self, message: discord.Message) -> None: - """Listen to DMs, forward them to handle_dm for proper handling when applicable Args: @@ -83,8 +82,10 @@ async def on_message(self, message: discord.Message) -> None: # Finally, normal handling await message.add_reaction("✅") await handle_dm(message) - - async def on_typing(self, channel: discord.DMChannel, user: discord.User, _: datetime): + + async def on_typing( + self, channel: discord.DMChannel, user: discord.User, _: datetime + ): """When someone starts typing in modmails dms, starts typing in the corresponding thread Args: @@ -232,15 +233,15 @@ async def create_thread( embed.timestamp = datetime.utcnow() await thread[0].send(embed=embed) - + # --> AUTORESPONSE HANDLING <-- for regex in AUTOMATIC_RESPONSES: if re.match(regex, content): await reply_to_thread( content=AUTOMATIC_RESPONSES[regex], - author=Ts_client.user, # Set for the sake of consistency, is not actually used + author=Ts_client.user, # Set for the sake of consistency, is not actually used thread=thread, - anonymous=True + anonymous=True, ) return @@ -358,8 +359,6 @@ async def close_thread( await user.send(embed=embed) - - async def log_closure(thread, user, log_channel, closed_by: discord.User): embed = discord.Embed( color=discord.Color.red(), @@ -395,7 +394,6 @@ async def setup(bot): default={}, ) - config.add( key="modmail_roles", datatype="list", @@ -440,7 +438,6 @@ def __init__(self, bot): global MODMAIL_LOG_CHANNEL_ID MODMAIL_LOG_CHANNEL_ID = int(bot.file_config.modmail_config.modmail_log_channel) - config = bot.guild_configs[str(bot.file_config.modmail_config.modmail_guild)] # Makes all role IDs to ping when thread is being created global @@ -453,7 +450,6 @@ def __init__(self, bot): global AUTOMATIC_RESPONSES AUTOMATIC_RESPONSES = config.extensions.modmail.automatic_responses.value - self.prefix = bot.file_config.modmail_config.modmail_prefix self.bot = bot @@ -567,16 +563,16 @@ async def on_message(self, message: discord.Message): return closure_jobs[message.channel.id] = asyncio.create_task( - close_thread( - thread=message.channel, - silent=True, - timed=True, - user=self.bot.get_user( - int(message.channel.name.split("|")[-1].strip()) - ), - log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), - closed_by=message.author, - ) + close_thread( + thread=message.channel, + silent=True, + timed=True, + user=self.bot.get_user( + int(message.channel.name.split("|")[-1].strip()) + ), + log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), + closed_by=message.author, + ) ) # - Replies - @@ -600,7 +596,6 @@ async def on_message(self, message: discord.Message): ) return - # Sends a factoid case "send": # Replaces \n with spaces so factoid can be called even with newlines @@ -614,16 +609,18 @@ async def on_message(self, message: discord.Message): ) if not factoid: - await auxiliary.send_deny_embed(message=f"Couldn't find the factoid `{query}`", channel=message.channel) + await auxiliary.send_deny_embed( + message=f"Couldn't find the factoid `{query}`", + channel=message.channel, + ) return await reply_to_thread( content=factoid.message, author=message.author, thread=message.channel, - anonymous=True + anonymous=True, ) - # Checks if it is an alias instead config = self.bot.guild_configs[str(self.modmail_forum.guild.id)] From 0ec62f7bf7171952763c3381b8b91be683414871 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 22:43:50 +0100 Subject: [PATCH 05/32] Finishing up --- config.default.yml | 1 + techsupport_bot/bot.py | 2 +- techsupport_bot/commands/factoids.py | 7 - techsupport_bot/commands/modmail.py | 387 ++++++++++++++++++--------- techsupport_bot/core/databases.py | 8 + 5 files changed, 276 insertions(+), 129 deletions(-) diff --git a/config.default.yml b/config.default.yml index 3e305451e..c70e132c4 100644 --- a/config.default.yml +++ b/config.default.yml @@ -7,6 +7,7 @@ bot_config: default_prefix: "." global_alerts_channel: "" modmail_config: + disable_thread_creation: False modmail_auth_token: modmail_prefix: modmail_guild: diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index 32bd62f69..67dc2eee9 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -626,7 +626,7 @@ async def load_extensions(self, graceful: bool = True) -> None: self.logger.console.error( f"Failed to load extension {extension_name}: {exception}" ) - if not graceful or extension_name == "modmail": + if not graceful: raise exception self.logger.console.debug("Retrieving functions") diff --git a/techsupport_bot/commands/factoids.py b/techsupport_bot/commands/factoids.py index e69db6f3d..845eefc0b 100644 --- a/techsupport_bot/commands/factoids.py +++ b/techsupport_bot/commands/factoids.py @@ -44,13 +44,6 @@ async def setup(bot): description="The roles required to manage factoids", default=["Factoids"], ) - config.add( - key="test_value", - datatype="list", - title="Manage factoids roles", - description="The roles required to manage factoids", - default=["Factoids"], - ) config.add( key="prefix", diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 09e580663..8fa4faee9 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -1,8 +1,16 @@ """ -Modmail stuff +Runs a bot that can be messaged to create modmail threads +Unit tests: False +Config: + File: disable_thread_creation, modmail_auth_token, modmail_prefix, modmail_guild, + modmail_forum_channel, modmail_log_channel + Command: aliases, automatic_responses, modmail_roles, roles_to_ping +API: None +Postgresql: True +Models: ModmailBan +Commands: contact, modmail ban, modmail unban """ - import asyncio import re from datetime import datetime @@ -10,12 +18,11 @@ import discord import expiringdict import ui -from botlogging import LogContext, LogLevel -from core import auxiliary, cogs, custom_errors, extensionconfig +from core import auxiliary, cogs, extensionconfig from discord.ext import commands -async def has_modmail_management_role(ctx: commands.Context): +async def has_modmail_management_role(ctx: commands.Context) -> bool: """-COMMAND CHECK- Checks if the invoker has a modmail management role @@ -42,6 +49,7 @@ async def has_modmail_management_role(ctx: commands.Context): if not modmail_roles: raise commands.CommandError("No modmail roles were assigned in the config file") + # Checking against the user to see if they have amy of the roles specified in the config if not any( modmail_role in getattr(ctx.author, "roles", []) @@ -56,10 +64,10 @@ class Modmail_bot(discord.Client): """The bot used to send and receive DM messages""" async def on_message(self, message: discord.Message) -> None: - """Listen to DMs, forward them to handle_dm for proper handling when applicable + """Listen to DMs, send them to handle_dm for proper handling when applicable Args: - message (discord.Message): Any sent message, gets filtered to only dms + message (discord.Message): Every sent message, gets filtered to only dms """ if isinstance(message.channel, discord.DMChannel) and not message.author.bot: # User is banned from creating modmail threads @@ -73,14 +81,22 @@ async def on_message(self, message: discord.Message) -> None: if message.author.id in delayed_people: await message.add_reaction("🕒") await auxiliary.send_deny_embed( - message="To restrict spam, you can not open a new thread within 24 hours of a thread being closed. Please try again later.", + message="To restrict spam, you can not open a new thread within 24 hours of" + + "a thread being closed. Please try again later.", channel=message.channel, ) + return + if DISABLE_THREAD_CREATION: + await message.add_reaction("❌") + await auxiliary.send_deny_embed( + message="Modmail isn't accepting messages right now. " + + "Please try again later.", + channel=message.channel, + ) return - # Finally, normal handling - await message.add_reaction("✅") + # Everything looks good - handle dm properly await handle_dm(message) async def on_typing( @@ -89,8 +105,9 @@ async def on_typing( """When someone starts typing in modmails dms, starts typing in the corresponding thread Args: - channel (discord.Channel): _description_ - user (discord.User): _description_ + channel (discord.Channel): The channel where osmeone started typing + user (discord.User): The user who started typing + _ (datetime.datetime): The timestamp of when typing started, unused """ if isinstance(channel, discord.DMChannel) and user.id in active_threads: await self.get_channel(active_threads[user.id]).typing() @@ -99,19 +116,20 @@ async def on_typing( # These get assigned in the __init__, are needed for inter-bot comm # It is a goofy solution but given that this extension is only used in ONE guild, it's good enough Ts_client = None +DISABLE_THREAD_CREATION = None MODMAIL_FORUM_ID = None MODMAIL_LOG_CHANNEL_ID = None ROLES_TO_PING = None AUTOMATIC_RESPONSES = None active_threads = {} -closure_jobs = {} +closure_jobs = {} # Used in timed closes +# Is a dict because expiringDict only has expiring dictionaries... go figure delayed_people = expiringdict.ExpiringDict( max_age_seconds=93600, max_len=1000 # max_len has to be set for some reason ) - -# Prepares the Modmail client with the Members intent for the lookup +# Prepares the Modmail client with the Members intent used for lookups # It is actually started in __init__ of the modmail command, the client is defined here # since it is used elsewhere intents = discord.Intents.default() @@ -119,13 +137,46 @@ async def on_typing( Modmail_client = Modmail_bot(intents=intents) +async def build_attachments( + thread: discord.Thread, message: discord.Message +) -> list[discord.File]: + """Returns a list of as many files from a message as the bot can send to the given guild + + Args: + + thread (discord.Thread): The thread the attachments are going to be sent to + (To get the maximum file size) + message (discord.Message): The message to get the attachments from + + Returns: + list[discord.File]: The list of file objects ready to be sent + """ + attachments: list[discord.File] = [] + + total_attachment_size = 0 + for attachment in message.attachments: + # Add attachments until the max file size is reached + if ( + total_attachment_size := total_attachment_size + attachment.size + ) <= thread.guild.filesize_limit: + attachments.append(await attachment.to_file()) + + # The attachments were too big + if (failed_amount := len(message.attachments) - len(attachments)) != 0: + await thread.send( + f"{failed_amount} additional attachments were detected, but were too big to send!" + ) + + return attachments + + async def handle_dm(message: discord.Message) -> None: - """Sends a received dm to the appropriate thread + """Sends a message to the corresponding thread, creates one if needed Args: message (discord.Message): The incoming message """ - # the bot is not ready to handle dms yet + # The bot is not ready to handle dms yet, this should only take a few seconds after startup if not Ts_client or not MODMAIL_FORUM_ID: await message.channel.send( discord.Embed( @@ -138,7 +189,10 @@ async def handle_dm(message: discord.Message) -> None: # Gets the modmail channel from TS-es side so it can create the thread modmail_channel = Ts_client.get_channel(MODMAIL_FORUM_ID) + # The user already has an open thread if message.author.id in active_threads: + await message.add_reaction("📨") + embed = discord.Embed(color=discord.Color.blue(), description=message.content) embed.set_author(name=message.author, icon_url=message.author.avatar.url) embed.set_footer(text=f"Message ID: {message.id}") @@ -146,25 +200,64 @@ async def handle_dm(message: discord.Message) -> None: thread = Ts_client.get_channel(active_threads[message.author.id]) await thread.send(embed=embed) + + # Handling for attachments + if message.attachments: + attachments = await build_attachments(thread=thread, message=message) + # If the attachment that was sent is bigger than the bot can send, this will be empty + if attachments: + await thread.send(files=attachments) + return - # No thread was found, creates one manually - await create_thread( - channel=modmail_channel, user=message.author, content=message.content + # No thread was found, create one + confirmation = ui.Confirm() + await confirmation.send( + message=(f"Create a Modmail thread?"), + channel=message.channel, + author=message.author, ) + await confirmation.wait() + + if ( + confirmation.value == ui.ConfirmResponse.DENIED + or confirmation.value == ui.ConfirmResponse.TIMEOUT + ): + await auxiliary.send_deny_embed( + message=f"Thread creation cancelled.", + channel=message.channel, + ) + return + + embed = discord.Embed( + color=discord.Color.green(), + description="The staff will get back to you as soon as possible.", + ) + embed.set_author(name="Thread Created") + embed.set_footer(text="Your message has been sent.") + embed.timestamp = datetime.utcnow() + + await message.author.send(embed=embed) + + await create_thread(channel=modmail_channel, user=message.author, message=message) + async def create_thread( - channel: discord.TextChannel, user: discord.User, content: str = "" + channel: discord.TextChannel, + user: discord.User, + message: discord.Message = None, ) -> None: """Creates a thread from a DM message + The message is left blank when invoked by the contact command Args: channel (discord.TextChannel): The forum channel to create the thread in - user (discord.User): The user who sent the DM - contents (str, optional): The DM contents, defaults to an empty string + user (discord.User): The user who sent the DM or is being contacted + message (discord.Message, optional): The incoming message """ # --> WELCOME MESSAGE <-- + embed = discord.Embed(color=discord.Color.blue()) # Formatting the description of the initial message description = ( @@ -173,21 +266,25 @@ async def create_thread( past_thread_count = 0 for thread in channel.threads: - if int(thread.name.split("|")[-1].strip()) == user.id: + if not thread.name.startswith("[OPEN]") and thread.name.split("|")[ + -1 + ].strip() == str(user.id): past_thread_count += 1 if past_thread_count == 0: - description += " and has **no** past threads" + description += ", has **no** past threads" else: - description += f" and has {past_thread_count} past threads" - - embed = discord.Embed(color=discord.Color.blue(), description=description) + description += f", has {past_thread_count} past threads" # If the user is a member, do member specific things member = channel.guild.get_member(user.id) + if member: - description += f", joined at {discord.utils.format_dt(member.joined_at, 'R')}" + description += f", joined {discord.utils.format_dt(member.joined_at, 'R')}" + embed.add_field(name="Nickname", value=member.nick) + + role_string = "None" roles = [] for role in sorted(member.roles, key=lambda x: x.position, reverse=True): @@ -195,55 +292,68 @@ async def create_thread( continue roles.append(role.mention) - embed.add_field(name="Roles", value=", ".join(roles)) + if roles: + role_string = ", ".join(roles) + + embed.add_field(name="Roles", value=role_string) + + # This shouldn't be possible because to dm the bot you need to share a server + # Is still here for safety else: description += ", is not in this server" + # Only adds the avatar if the user has one if user.avatar: url = user.avatar.url else: url = user.default_avatar.url - embed.set_author(name=user, icon_url=url) - embed.set_footer(text=f"User ID: {user.id}") + # has to be done like this because of member handling + embed.description = description embed.timestamp = datetime.utcnow() + embed.set_footer(text=f"User ID: {user.id}") - # --> Handling for autoping roles <-- + # Handling for roles to ping, not performed if the func was invoked by the contact command role_string = "" - if ROLES_TO_PING: + if message and ROLES_TO_PING: for role_id in ROLES_TO_PING: - role_string += f"<@&{role_id}>" + role_string += f"<@&{role_id}> " # --> THREAD CREATION <-- - # The thread CAN NOT be renamed while open, this is because of how the bot gets metadata - # about the user + # All threads in the modmail forum channel HAVE to follow this scheme as long as they start + # with [CLOSED] or [OPEN]: # [STATUS] | Username | Date of creation | User id thread = await channel.create_thread( name=f"[OPEN] | {user} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {user.id}", embed=embed, - content=role_string, + content=role_string.rstrip(), ) active_threads[user.id] = thread[0].id - # --> MESSAGE SENDING <-- - if content: - embed = discord.Embed(color=discord.Color.yellow(), description=content) + # The thread creation was invoked from an incoming message + if message: + embed = discord.Embed(color=discord.Color.blue(), description=message.content) embed.set_author(name=user, icon_url=url) + embed.set_footer(text=f"Message ID: {message.id}") embed.timestamp = datetime.utcnow() await thread[0].send(embed=embed) - # --> AUTORESPONSE HANDLING <-- - for regex in AUTOMATIC_RESPONSES: - if re.match(regex, content): - await reply_to_thread( - content=AUTOMATIC_RESPONSES[regex], - author=Ts_client.user, # Set for the sake of consistency, is not actually used - thread=thread, - anonymous=True, - ) - return + if message.attachments: + attachments = await build_attachments(thread=thread[0], message=message) + if attachments: + await thread[0].send(files=attachments) + + for regex in AUTOMATIC_RESPONSES: + if re.match(regex, message.content): + await reply_to_thread( + content=AUTOMATIC_RESPONSES[regex], + author=Ts_client.user, + thread=thread[0], + anonymous=True, + ) + return async def reply_to_thread( @@ -252,7 +362,7 @@ async def reply_to_thread( thread: discord.Thread, anonymous: bool, ) -> None: - """Replies to a modmail thread on both the dm side and the ts side + """Replies to a modmail thread on both the dm side and the modmail thread side Args: raw_content (str): The content to send @@ -260,7 +370,7 @@ async def reply_to_thread( thread (discord.Thread): The thread to reply to anonymous (bool): Whether to reply anonymously """ - # If thread is going to be closed, cancels it + # If thread was going to be closed, cancel the task if thread.id in closure_jobs: closure_jobs[thread.id].cancel() del closure_jobs[thread.id] @@ -284,7 +394,9 @@ async def reply_to_thread( embed.set_author(name=author, icon_url=author.avatar.url) embed.set_footer(text="Response") - if anonymous: + if author == Ts_client.user: + embed.set_footer(text="[Automatic] Response") + elif anonymous: embed.set_footer(text="[Anonymous] Response") await thread.send(embed=embed) @@ -303,18 +415,29 @@ async def close_thread( silent: bool, timed: bool, log_channel: discord.TextChannel, - user: discord.User, closed_by: discord.User, -): - # Waits 5 minutes before closing, only happens when func is run as an asyncio job +) -> None: + """Closes a thread instantly or on a delay + + Args: + thread (discord.Thread): The thread to close + silent (bool): Whether to send a closure message to the user + timed (bool): Whether to wait 5 minutes before closing + log_channel (discord.TextChannel): The channel to send the closure message to + closed_by (discord.User): The person who closed the thread + """ + user = Modmail_client.get_user(int(thread.name.split("|")[-1].strip())) + + # Waits 5 minutes before closing, below is only executed when func was run as an asyncio job if timed: embed = discord.Embed( color=discord.Color.red(), description="This thread will close in 5 minutes.", ) + embed.set_author(name="Scheduled close") embed.set_footer( - text="Closing will be cancelled if a message is sent, or if you rerun the command." + text="Closing will be cancelled if a message is sent, or if the command is run again." ) embed.timestamp = datetime.utcnow() @@ -322,12 +445,13 @@ async def close_thread( await asyncio.sleep(300) + # - Actually starts closing the thread - del active_threads[user.id] # Removes closure job from queue if timed: del closure_jobs[thread.id] - # Closes the thread + # Archives and locks the thread await thread.send( embed=auxiliary.generate_basic_embed( color=discord.Color.red(), @@ -340,7 +464,8 @@ async def close_thread( archived=True, locked=True, ) - # It just has to exist in the dict to trigger + + # No value needed, just has to exist delayed_people[user.id] = "" await log_closure(thread, user, log_channel, closed_by) @@ -348,7 +473,7 @@ async def close_thread( if silent: return - # Sends the close message + # Sends the closure message to the user embed = discord.Embed( color=discord.Color.light_gray(), description="Please wait 24 hours before creating a new one.", @@ -359,7 +484,21 @@ async def close_thread( await user.send(embed=embed) -async def log_closure(thread, user, log_channel, closed_by: discord.User): +async def log_closure( + thread: discord.Thread, + user: discord.User, + log_channel: discord.TextChannel, + closed_by: discord.User, +) -> None: + """Sends a closure message to the log channel + + Args: + thread (discord.Thread): The thread that got closed + user (discord.User): The person who created the thread + log_channel (discord.TextChannel): The log channel to send the closure message to + closed_by (discord.User): The person who closed the thread + """ + embed = discord.Embed( color=discord.Color.red(), description=f"<#{thread.id}>", @@ -382,7 +521,7 @@ async def setup(bot): key="aliases", datatype="dict", title="Aliases for modmail messages", - description="Custom commands to send message slices", + description="Custom modmail commands to send message slices", default={}, ) @@ -428,34 +567,40 @@ def __init__(self, bot): Modmail_client.start(bot.file_config.modmail_config.modmail_auth_token) ) - # Sets the modmail channel from config, has to be here otherwise it'd have to be hardcoded + # -> This makes the configs available from the whole file, this can only be done here + # -> thanks to modmail only being available in one guild. It is NEEDED for inter-bot comms + # -> Pylint disables because it bitches about using globals + + # pylint: disable=W0603 + global DISABLE_THREAD_CREATION + DISABLE_THREAD_CREATION = bot.file_config.modmail_config.disable_thread_creation + # pylint: disable=W0603 global MODMAIL_FORUM_ID MODMAIL_FORUM_ID = int(bot.file_config.modmail_config.modmail_forum_channel) - # Same for the log channel # pylint: disable=W0603 global MODMAIL_LOG_CHANNEL_ID MODMAIL_LOG_CHANNEL_ID = int(bot.file_config.modmail_config.modmail_log_channel) config = bot.guild_configs[str(bot.file_config.modmail_config.modmail_guild)] - # Makes all role IDs to ping when thread is being created global # pylint: disable=W0603 global ROLES_TO_PING ROLES_TO_PING = config.extensions.modmail.roles_to_ping.value - # Lastly, makes the automatic rseponses global so they can be accessed from modmails side # pylint: disable=W0603 global AUTOMATIC_RESPONSES AUTOMATIC_RESPONSES = config.extensions.modmail.automatic_responses.value + # Finally, makes the TS client available from within the Modmail class once again self.prefix = bot.file_config.modmail_config.modmail_prefix self.bot = bot @commands.Cog.listener() async def on_ready(self): - """Fetches the modmail channel only once ready""" + """Fetches the modmail channel only once ready + Not done in preconfig because that breaks stuff for some reason?""" await self.bot.wait_until_ready() # Has to be done in here, putting into preconfig breaks stuff for some reason self.modmail_forum = self.bot.get_channel(MODMAIL_FORUM_ID) @@ -464,11 +609,20 @@ async def on_ready(self): for thread in self.modmail_forum.threads: if thread.name.startswith("[OPEN]"): - # Funky stuff time # [username, date, id] active_threads[int(thread.name.split(" | ")[3])] = thread.id - return + guild_id = str(self.bot.file_config.modmail_config.modmail_guild) + config = self.bot.guild_configs[guild_id] + + self.modmail_roles = [] + # Gets permitted roles + for role_id in config.extensions.modmail.modmail_roles.value: + modmail_role = discord.utils.get(guild_id, id=role_id) + if not modmail_role: + continue + + self.modmail_roles.append(modmail_role) @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -496,9 +650,6 @@ async def on_message(self, message: discord.Message): thread=message.channel, silent=False, timed=False, - user=self.bot.get_user( - int(message.channel.name.split("|")[-1].strip()) - ), log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), closed_by=message.author, ) @@ -525,9 +676,6 @@ async def on_message(self, message: discord.Message): thread=message.channel, silent=False, timed=True, - user=self.bot.get_user( - int(message.channel.name.split("|")[-1].strip()) - ), log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), closed_by=message.author, ) @@ -539,9 +687,6 @@ async def on_message(self, message: discord.Message): thread=message.channel, silent=True, timed=False, - user=self.bot.get_user( - int(message.channel.name.split("|")[-1].strip()) - ), log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), closed_by=message.author, ) @@ -567,9 +712,6 @@ async def on_message(self, message: discord.Message): thread=message.channel, silent=True, timed=True, - user=self.bot.get_user( - int(message.channel.name.split("|")[-1].strip()) - ), log_channel=self.bot.get_channel(MODMAIL_LOG_CHANNEL_ID), closed_by=message.author, ) @@ -579,7 +721,7 @@ async def on_message(self, message: discord.Message): case "reply": await message.delete() await reply_to_thread( - content=" ".join(content.split()[1:]), + content=content.partition(" ")[2], author=message.author, thread=message.channel, anonymous=False, @@ -589,7 +731,7 @@ async def on_message(self, message: discord.Message): case "areply": await message.delete() await reply_to_thread( - content=" ".join(content.split()[1:]), + content=content.partition(" ")[2], author=message.author, thread=message.channel, anonymous=True, @@ -622,7 +764,7 @@ async def on_message(self, message: discord.Message): anonymous=True, ) - # Checks if it is an alias instead + # Checks if the command was an alias config = self.bot.guild_configs[str(self.modmail_forum.guild.id)] aliases = config.extensions.modmail.aliases.value @@ -635,7 +777,11 @@ async def on_message(self, message: discord.Message): return @auxiliary.with_typing - @commands.command(name="contact", brief="") + @commands.command( + name="contact", + brief="Creates a modmail thread with a user", + usage="[user-to-contact]", + ) async def contact(self, ctx: commands.Context, user: discord.User): """Opens a modmail thread with a person of your choice @@ -643,26 +789,30 @@ async def contact(self, ctx: commands.Context, user: discord.User): ctx (commands.Context): Context of the command execution user (discord.User): The user to start a thread with """ - modmail_forum = self.bot.get_channel(MODMAIL_FORUM_ID) + if user.bot: + await auxiliary.send_deny_embed( + message="I can only talk to other bots using 1s and 0s!", + channel=ctx.channel, + ) + return - for thread in modmail_forum.threads: - if user.id in active_threads: - await auxiliary.send_deny_embed( - message=f"User already has an open thread! <#{thread.id}>", - channel=ctx.channel, - ) - return + if user.id in active_threads: + await auxiliary.send_deny_embed( + message=f"User already has an open thread! <#{active_threads[user.id]}>", + channel=ctx.channel, + ) + return - view = ui.Confirm() - await view.send( - message=(f"Create new modmail thread to {user.mention}?"), + confirmation = ui.Confirm() + await confirmation.send( + message=(f"Create a new modmail thread with {user.mention}?"), channel=ctx.channel, author=ctx.author, ) - await view.wait() + await confirmation.wait() - match view.value: + match confirmation.value: case ui.ConfirmResponse.TIMEOUT: pass @@ -673,7 +823,7 @@ async def contact(self, ctx: commands.Context, user: discord.User): ) case ui.ConfirmResponse.CONFIRMED: - await create_thread(modmail_forum, user=user) + await create_thread(self.bot.get_channel(MODMAIL_FORUM_ID), user=user) await auxiliary.send_confirm_embed( message="Thread succesfully created!", channel=ctx.channel @@ -689,10 +839,12 @@ async def modmail(self, ctx): @auxiliary.with_typing @commands.check(has_modmail_management_role) @modmail.command( - name="ban", brief="Bans a user from creating future modmail threads" + name="ban", + brief="Bans a user from creating future modmail threads", + usage="[user-to-ban]", ) async def modmail_ban(self, ctx: commands.Context, user: discord.User): - """Opens a modmail thread with a person of your choice + """Bans a user from creating future modmail threads Args: ctx (commands.Context): Context of the command execution @@ -701,22 +853,15 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): if await self.bot.models.ModmailBan.query.where( self.bot.models.ModmailBan.user_id == str(user.id) ).gino.first(): - return await auxiliary.send_deny_embed( + await auxiliary.send_deny_embed( message=f"{user.mention} is already banned!", channel=ctx.channel ) - - config = ctx.bot.guild_configs[str(ctx.guild.id)] - modmail_roles = [] - # Gets permitted roles - for role_id in config.extensions.modmail.modmail_roles.value: - modmail_role = discord.utils.get(ctx.guild.roles, id=role_id) - if not modmail_role: - continue - modmail_roles.append(modmail_role) + return # Checking against the user to see if they have the roles specified in the config if any( - modmail_role in getattr(user, "roles", []) for modmail_role in modmail_roles + modmail_role in getattr(user, "roles", []) + for modmail_role in self.modmail_roles ): return await auxiliary.send_deny_embed( message="You cannot ban someone with a modmail role!", @@ -725,9 +870,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): view = ui.Confirm() await view.send( - message=( - f"Are you sure you want to ban {user.mention} from creating modmail threads?" - ), + message=(f"Ban {user.mention} from creating modmail threads?"), channel=ctx.channel, author=ctx.author, ) @@ -740,7 +883,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): case ui.ConfirmResponse.DENIED: return await auxiliary.send_deny_embed( - message=f"{user.mention} was not banned from creating modmail threads.", + message=f"{user.mention} was NOT banned from creating modmail threads.", channel=ctx.channel, ) @@ -750,14 +893,16 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): ).create() return await auxiliary.send_confirm_embed( - message=f"{user.mention} was succesfully banned from creating future modmail threads!", + message=f"{user.mention} was succesfully banned from creating future modmail threads.", channel=ctx.channel, ) @auxiliary.with_typing @commands.check(has_modmail_management_role) @modmail.command( - name="unban", brief="Unans a user from creating future modmail threads" + name="unban", + brief="Unans a user from creating future modmail threads", + usage="[user-to-unban]", ) async def modmail_unban(self, ctx: commands.Context, user: discord.User): """Opens a modmail thread with a person of your choice @@ -772,13 +917,13 @@ async def modmail_unban(self, ctx: commands.Context, user: discord.User): if not ban_entry: return await auxiliary.send_deny_embed( - message=f"{user.mention} is not banned from making modmail threads!", + message=f"{user.mention} is not currently banned from making modmail threads!", channel=ctx.channel, ) await ban_entry.delete() return await auxiliary.send_confirm_embed( - message=f"{user.mention} succesfully unbanned from creating modmail threads!", + message=f"{user.mention} was succesfully unbanned from creating modmail threads!", channel=ctx.channel, ) diff --git a/techsupport_bot/core/databases.py b/techsupport_bot/core/databases.py index 0b4202208..3b3a3afac 100644 --- a/techsupport_bot/core/databases.py +++ b/techsupport_bot/core/databases.py @@ -112,6 +112,13 @@ class IRCChannelMapping(bot.db.Model): discord_channel_id = bot.db.Column(bot.db.String, default=None) irc_channel_id = bot.db.Column(bot.db.String, default=None) + class ModmailBan(bot.db.Model): + """The postgres table for modmail bans + Currently used in modmail.py""" + + __tablename__ = "modmail_bans" + user_id = bot.db.Column(bot.db.String, default=None, primary_key=True) + class UserNote(bot.db.Model): """The postgres table for notes Currently used in who.py""" @@ -171,6 +178,7 @@ class Rule(bot.db.Model): bot.models.FactoidJob = FactoidJob bot.models.Grab = Grab bot.models.IRCChannelMapping = IRCChannelMapping + bot.models.ModmailBan = ModmailBan bot.models.UserNote = UserNote bot.models.Warning = Warning bot.models.Config = Config From 615b3518931f3f5c0284a18818d25d7f8e5172ba Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:10:53 +0100 Subject: [PATCH 06/32] Ironed permission issues out, fixed bot restarting --- techsupport_bot/commands/factoids.py | 1 - techsupport_bot/commands/modmail.py | 74 +++++++++++++++++++++------- techsupport_bot/commands/restart.py | 7 +++ 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/techsupport_bot/commands/factoids.py b/techsupport_bot/commands/factoids.py index 845eefc0b..2207d2a0a 100644 --- a/techsupport_bot/commands/factoids.py +++ b/techsupport_bot/commands/factoids.py @@ -44,7 +44,6 @@ async def setup(bot): description="The roles required to manage factoids", default=["Factoids"], ) - config.add( key="prefix", datatype="str", diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 8fa4faee9..801c7a3b2 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -179,7 +179,7 @@ async def handle_dm(message: discord.Message) -> None: # The bot is not ready to handle dms yet, this should only take a few seconds after startup if not Ts_client or not MODMAIL_FORUM_ID: await message.channel.send( - discord.Embed( + embed=auxiliary.generate_basic_embed( color=discord.Color.light_gray(), description="Bot is still starting, please wait...", ) @@ -194,9 +194,14 @@ async def handle_dm(message: discord.Message) -> None: await message.add_reaction("📨") embed = discord.Embed(color=discord.Color.blue(), description=message.content) - embed.set_author(name=message.author, icon_url=message.author.avatar.url) embed.set_footer(text=f"Message ID: {message.id}") embed.timestamp = datetime.utcnow() + if message.author.avatar: + embed.set_author(name=message.author, icon_url=message.author.avatar.url) + else: + embed.set_author( + name=message.author, icon_url=message.author.default_avatar.url + ) thread = Ts_client.get_channel(active_threads[message.author.id]) await thread.send(embed=embed) @@ -391,8 +396,11 @@ async def reply_to_thread( # - Modmail thread side - embed = discord.Embed(color=discord.Color.green(), description=content) embed.timestamp = datetime.utcnow() - embed.set_author(name=author, icon_url=author.avatar.url) embed.set_footer(text="Response") + if author.avatar: + embed.set_author(name=author, icon_url=author.avatar.url) + else: + embed.set_author(name=author, icon_url=author.default_avatar.url) if author == Ts_client.user: embed.set_footer(text="[Automatic] Response") @@ -597,6 +605,11 @@ def __init__(self, bot): self.prefix = bot.file_config.modmail_config.modmail_prefix self.bot = bot + async def handle_reboot(self): + """Ram when the bot is restarted""" + + await Modmail_client.close() + @commands.Cog.listener() async def on_ready(self): """Fetches the modmail channel only once ready @@ -612,24 +625,15 @@ async def on_ready(self): # [username, date, id] active_threads[int(thread.name.split(" | ")[3])] = thread.id - guild_id = str(self.bot.file_config.modmail_config.modmail_guild) - config = self.bot.guild_configs[guild_id] - - self.modmail_roles = [] - # Gets permitted roles - for role_id in config.extensions.modmail.modmail_roles.value: - modmail_role = discord.utils.get(guild_id, id=role_id) - if not modmail_role: - continue - - self.modmail_roles.append(modmail_role) - @commands.Cog.listener() async def on_message(self, message: discord.Message): """Processes messages sent in a modmail thread, basically a manual command handler Args: message (discord.Message): The sent message + + Raises: + commands.MissingAnyRole: When the invoker doesn't have a modmail role """ if ( not message.content.startswith(self.prefix) @@ -638,7 +642,28 @@ async def on_message(self, message: discord.Message): or message.channel.name.startswith("[CLOSED]") ): return + # Makes sure the person is actually allowed to run modmail commands + + config = self.bot.guild_configs[str(message.guild.id)] + modmail_roles = [] + + # Gets permitted roles + for role_id in config.extensions.modmail.modmail_roles.value: + modmail_role = discord.utils.get(message.guild.roles, id=role_id) + if not modmail_role: + continue + + modmail_roles.append(modmail_role) + if not any( + modmail_role in getattr(message.author, "roles", []) + for modmail_role in modmail_roles + ): + await auxiliary.send_deny_embed( + channel=message.channel, + message="You don't have permission to use that command!", + ) + return # Gets the content without the prefix content = message.content.partition(self.prefix)[2] @@ -765,7 +790,6 @@ async def on_message(self, message: discord.Message): ) # Checks if the command was an alias - config = self.bot.guild_configs[str(self.modmail_forum.guild.id)] aliases = config.extensions.modmail.aliases.value for alias in aliases: @@ -777,6 +801,7 @@ async def on_message(self, message: discord.Message): return @auxiliary.with_typing + @commands.check(has_modmail_management_role) @commands.command( name="contact", brief="Creates a modmail thread with a user", @@ -859,14 +884,25 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): return # Checking against the user to see if they have the roles specified in the config + config = self.bot.guild_configs[str(ctx.guild.id)] + modmail_roles = [] + + # Gets permitted roles + for role_id in config.extensions.modmail.modmail_roles.value: + modmail_role = discord.utils.get(ctx.guild.roles, id=role_id) + if not modmail_role: + continue + + modmail_roles.append(modmail_role) + if any( - modmail_role in getattr(user, "roles", []) - for modmail_role in self.modmail_roles + modmail_role in getattr(user, "roles", []) for modmail_role in modmail_roles ): - return await auxiliary.send_deny_embed( + await auxiliary.send_deny_embed( message="You cannot ban someone with a modmail role!", channel=ctx.channel, ) + return view = ui.Confirm() await view.send( diff --git a/techsupport_bot/commands/restart.py b/techsupport_bot/commands/restart.py index 9629a35c7..da5f891f7 100644 --- a/techsupport_bot/commands/restart.py +++ b/techsupport_bot/commands/restart.py @@ -44,5 +44,12 @@ async def restart(self, ctx: commands.Context) -> None: if irc_config.enable_irc: self.bot.irc.exit_irc() + # Exit modmail if it's enabled + modmail_cog = ctx.bot.get_cog("Modmail") + await modmail_cog.handle_reboot() + + # Ending the event loop + self.bot.loop.stop() + # Close the bot and let the docker container restart await self.bot.close() From e4a434b53974f79a90e42a13816b5b5f510b1f68 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:14:27 +0100 Subject: [PATCH 07/32] Got rid of the TODO. Oops --- techsupport_bot/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/techsupport_bot/main.py b/techsupport_bot/main.py index 549dcfdf3..eae89f760 100644 --- a/techsupport_bot/main.py +++ b/techsupport_bot/main.py @@ -34,9 +34,8 @@ intents=intents, allowed_mentions=discord.AllowedMentions(everyone=False, roles=False), ) -# Starts a custom event loop, because modmail requires it to be run separately -# TODO: actually explain if it works - +# Starts a custom event loop for the bot, because Modmail runs its own one as well, +# you can not run nested asyncio loops bot.loop.create_task(bot_.start()) bot.loop.run_forever() From 2bd9a0671dfb068617d0ee5385ab9173860e5702 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:18:23 +0100 Subject: [PATCH 08/32] Codefactor fixes --- techsupport_bot/bot.py | 4 ++-- techsupport_bot/main.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index 67dc2eee9..5d1745e81 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -959,10 +959,10 @@ async def start_irc(self): bool: True if the connection was successful, False if it was not """ irc_config = getattr(self.file_config.api, "irc") - loop = asyncio.get_running_loop() + main_loop = asyncio.get_running_loop() irc_bot = ircrelay.IRCBot( - loop=loop, + loop=main_loop, server=irc_config.server, port=irc_config.port, channels=irc_config.channels, diff --git a/techsupport_bot/main.py b/techsupport_bot/main.py index eae89f760..a3c236def 100644 --- a/techsupport_bot/main.py +++ b/techsupport_bot/main.py @@ -1,7 +1,6 @@ """TechSupport Bot main thread. """ -import asyncio import logging import os From 30777f15a3d0f7990dd0c26898cead14365b627f Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:21:54 +0100 Subject: [PATCH 09/32] Fixed pylint issues --- techsupport_bot/commands/modmail.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 801c7a3b2..c406f7ba1 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -218,19 +218,16 @@ async def handle_dm(message: discord.Message) -> None: # No thread was found, create one confirmation = ui.Confirm() await confirmation.send( - message=(f"Create a Modmail thread?"), + message=("Create a Modmail thread?"), channel=message.channel, author=message.author, ) await confirmation.wait() - if ( - confirmation.value == ui.ConfirmResponse.DENIED - or confirmation.value == ui.ConfirmResponse.TIMEOUT - ): + if confirmation.value in (ui.ConfirmResponse.DENIED, ui.ConfirmResponse.TIMEOUT): await auxiliary.send_deny_embed( - message=f"Thread creation cancelled.", + message="Thread creation cancelled.", channel=message.channel, ) return @@ -929,7 +926,8 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): ).create() return await auxiliary.send_confirm_embed( - message=f"{user.mention} was succesfully banned from creating future modmail threads.", + message=f"{user.mention} was succesfully banned from creating future modmail" + + "threads.", channel=ctx.channel, ) From 4195194747fcc8f87584b54d70d7103ed72e7adf Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 17 Feb 2024 23:30:50 +0100 Subject: [PATCH 10/32] Fixed grammar in last comment --- techsupport_bot/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/main.py b/techsupport_bot/main.py index 1497bb7a6..1a332272b 100644 --- a/techsupport_bot/main.py +++ b/techsupport_bot/main.py @@ -32,7 +32,7 @@ intents=intents, allowed_mentions=discord.AllowedMentions(everyone=False, roles=False), ) -# Starts a custom event loop for the bot, because Modmail runs its own one as well, +# Creates & starts a custom event loop for the bot, because Modmail runs its own one as well and # you can not run nested asyncio loops bot.loop.create_task(bot_.start()) From 8a7588693a27e86ffc585230ccff1e9208270d47 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 18 Feb 2024 00:26:16 +0100 Subject: [PATCH 11/32] Fixed review stuff, added type and super call for init, made consistent embed capitalization --- techsupport_bot/bot.py | 10 +++++----- techsupport_bot/commands/modmail.py | 28 +++++++++++++++++----------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index 4792e5f67..5d1745e81 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -62,11 +62,11 @@ def __init__( self.guild_configs: dict[str, munch.Munch] = {} self.extension_configs = munch.DefaultMunch(None) self.extension_states = munch.DefaultMunch(None) - self.command_rate_limit_bans: expiringdict.ExpiringDict[str, bool] = ( - expiringdict.ExpiringDict( - max_len=5000, - max_age_seconds=600, - ) + self.command_rate_limit_bans: expiringdict.ExpiringDict[ + str, bool + ] = expiringdict.ExpiringDict( + max_len=5000, + max_age_seconds=600, ) self.command_execute_history: dict[str, dict[int, bool]] = {} diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index c406f7ba1..2b966951f 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -10,10 +10,12 @@ Models: ModmailBan Commands: contact, modmail ban, modmail unban """ +from __future__ import annotations import asyncio import re from datetime import datetime +from typing import TYPE_CHECKING import discord import expiringdict @@ -21,6 +23,9 @@ from core import auxiliary, cogs, extensionconfig from discord.ext import commands +if TYPE_CHECKING: + import bot + async def has_modmail_management_role(ctx: commands.Context) -> bool: """-COMMAND CHECK- @@ -105,7 +110,7 @@ async def on_typing( """When someone starts typing in modmails dms, starts typing in the corresponding thread Args: - channel (discord.Channel): The channel where osmeone started typing + channel (discord.Channel): The channel where someone started typing user (discord.User): The user who started typing _ (datetime.datetime): The timestamp of when typing started, unused """ @@ -218,7 +223,7 @@ async def handle_dm(message: discord.Message) -> None: # No thread was found, create one confirmation = ui.Confirm() await confirmation.send( - message=("Create a Modmail thread?"), + message="Create a Modmail thread?", channel=message.channel, author=message.author, ) @@ -367,7 +372,7 @@ async def reply_to_thread( """Replies to a modmail thread on both the dm side and the modmail thread side Args: - raw_content (str): The content to send + content (str): The content to send author (discord.user): The author of the outgoing message thread (discord.Thread): The thread to reply to anonymous (bool): Whether to reply anonymously @@ -460,7 +465,7 @@ async def close_thread( await thread.send( embed=auxiliary.generate_basic_embed( color=discord.Color.red(), - title="Thread closed.", + title="Thread Closed.", description="", ) ) @@ -483,7 +488,7 @@ async def close_thread( color=discord.Color.light_gray(), description="Please wait 24 hours before creating a new one.", ) - embed.set_author(name="Thread closed") + embed.set_author(name="Thread Closed") embed.timestamp = datetime.utcnow() await user.send(embed=embed) @@ -561,8 +566,9 @@ async def setup(bot): class Modmail(cogs.BaseCog): """The modmail cog class""" - def __init__(self, bot): + def __init__(self, bot: bot.TechSupportBot): """Init is used to make variables global so they can be used on the modmail side""" + super().__init__(bot=bot) # Makes the TS client available globally for creating threads and populating them with info # pylint: disable=W0603 @@ -848,7 +854,7 @@ async def contact(self, ctx: commands.Context, user: discord.User): await create_thread(self.bot.get_channel(MODMAIL_FORUM_ID), user=user) await auxiliary.send_confirm_embed( - message="Thread succesfully created!", channel=ctx.channel + message="Thread successfully created!", channel=ctx.channel ) @commands.group(name="modmail") @@ -903,7 +909,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): view = ui.Confirm() await view.send( - message=(f"Ban {user.mention} from creating modmail threads?"), + message=f"Ban {user.mention} from creating modmail threads?", channel=ctx.channel, author=ctx.author, ) @@ -926,7 +932,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): ).create() return await auxiliary.send_confirm_embed( - message=f"{user.mention} was succesfully banned from creating future modmail" + message=f"{user.mention} was successfully banned from creating future modmail" + "threads.", channel=ctx.channel, ) @@ -935,7 +941,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): @commands.check(has_modmail_management_role) @modmail.command( name="unban", - brief="Unans a user from creating future modmail threads", + brief="Unbans a user from creating future modmail threads", usage="[user-to-unban]", ) async def modmail_unban(self, ctx: commands.Context, user: discord.User): @@ -958,6 +964,6 @@ async def modmail_unban(self, ctx: commands.Context, user: discord.User): await ban_entry.delete() return await auxiliary.send_confirm_embed( - message=f"{user.mention} was succesfully unbanned from creating modmail threads!", + message=f"{user.mention} was successfully unbanned from creating modmail threads!", channel=ctx.channel, ) From 8afe97828c79c8ab98c910a88ba6692c3d437f44 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 18 Feb 2024 00:37:01 +0100 Subject: [PATCH 12/32] updated black --- techsupport_bot/bot.py | 10 +++++----- techsupport_bot/commands/modmail.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/techsupport_bot/bot.py b/techsupport_bot/bot.py index 5d1745e81..4792e5f67 100644 --- a/techsupport_bot/bot.py +++ b/techsupport_bot/bot.py @@ -62,11 +62,11 @@ def __init__( self.guild_configs: dict[str, munch.Munch] = {} self.extension_configs = munch.DefaultMunch(None) self.extension_states = munch.DefaultMunch(None) - self.command_rate_limit_bans: expiringdict.ExpiringDict[ - str, bool - ] = expiringdict.ExpiringDict( - max_len=5000, - max_age_seconds=600, + self.command_rate_limit_bans: expiringdict.ExpiringDict[str, bool] = ( + expiringdict.ExpiringDict( + max_len=5000, + max_age_seconds=600, + ) ) self.command_execute_history: dict[str, dict[int, bool]] = {} diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 2b966951f..c438fa328 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -10,6 +10,7 @@ Models: ModmailBan Commands: contact, modmail ban, modmail unban """ + from __future__ import annotations import asyncio From ed453394854de50947e95072825b1cb706985f10 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 7 Apr 2024 15:48:07 +0200 Subject: [PATCH 13/32] review stuff --- techsupport_bot/commands/modmail.py | 437 ++++++++++++++++++++-------- 1 file changed, 318 insertions(+), 119 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index c438fa328..fcc6c4e72 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -4,11 +4,11 @@ Config: File: disable_thread_creation, modmail_auth_token, modmail_prefix, modmail_guild, modmail_forum_channel, modmail_log_channel - Command: aliases, automatic_responses, modmail_roles, roles_to_ping + Command: aliases, automatic_responses, modmail_roles, roles_to_ping, thread_creation_message API: None Postgresql: True Models: ModmailBan -Commands: contact, modmail ban, modmail unban +Commands: contact, modmail commands, modmail ban, modmail unban """ from __future__ import annotations @@ -28,12 +28,13 @@ import bot -async def has_modmail_management_role(ctx: commands.Context) -> bool: +async def has_modmail_management_role(ctx: commands.Context, config=None) -> bool: """-COMMAND CHECK- Checks if the invoker has a modmail management role Args: ctx (commands.Context): Context used for getting the config file + config (): Can be defined manually to run this without providing actual ctx Raises: commands.CommandError: No modmail management roles were assigned in the config @@ -42,25 +43,25 @@ async def has_modmail_management_role(ctx: commands.Context) -> bool: Returns: bool: Whether the invoker has a modmail management role """ - - config = ctx.bot.guild_configs[str(ctx.guild.id)] + if not config: + config = ctx.bot.guild_configs[str(ctx.guild.id)] + user_roles = getattr(ctx.author, "roles", []) modmail_roles = [] - # Gets permitted roles + if not config.extensions.modmail.modmail_roles.value: + raise commands.CommandError("No modmail roles were assigned in the config file") + + # Two for loops are needed, because an array containing all modmail roles is needed for + # the error thrown when the user doesn't have any relevant roles. for role_id in config.extensions.modmail.modmail_roles.value: - role = discord.utils.get(ctx.guild.roles, id=role_id) + role = discord.utils.get(ctx.guild.roles, id=int(role_id)) + if not role: continue - modmail_roles.append(role) - if not modmail_roles: - raise commands.CommandError("No modmail roles were assigned in the config file") + modmail_roles.append(role) - # Checking against the user to see if they have amy of the roles specified in the config - if not any( - modmail_role in getattr(ctx.author, "roles", []) - for modmail_role in modmail_roles - ): + if not any(role in user_roles for role in modmail_roles): raise commands.MissingAnyRole(modmail_roles) return True @@ -69,6 +70,7 @@ async def has_modmail_management_role(ctx: commands.Context) -> bool: class Modmail_bot(discord.Client): """The bot used to send and receive DM messages""" + @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: """Listen to DMs, send them to handle_dm for proper handling when applicable @@ -87,8 +89,9 @@ async def on_message(self, message: discord.Message) -> None: if message.author.id in delayed_people: await message.add_reaction("🕒") await auxiliary.send_deny_embed( - message="To restrict spam, you can not open a new thread within 24 hours of" - + "a thread being closed. Please try again later.", + message="To restrict spam, you are timed out from creating new threads. " + + "You are welcome to create a new thread after 24 hours since your previous" + + "thread's closing.", channel=message.channel, ) return @@ -105,10 +108,11 @@ async def on_message(self, message: discord.Message) -> None: # Everything looks good - handle dm properly await handle_dm(message) + @commands.Cog.listener() async def on_typing( self, channel: discord.DMChannel, user: discord.User, _: datetime - ): - """When someone starts typing in modmails dms, starts typing in the corresponding thread + ) -> None: + """When someone starts typing in modmails dms, start typing in the corresponding thread Args: channel (discord.Channel): The channel where someone started typing @@ -118,6 +122,58 @@ async def on_typing( if isinstance(channel, discord.DMChannel) and user.id in active_threads: await self.get_channel(active_threads[user.id]).typing() + @commands.Cog.listener() + async def on_message_edit( + self, before: discord.Message, after: discord.Message + ) -> None: + """When someone edits a message, send the event to the appropriate thread + + Args: + before (discord.Message): The message prior to editing + after (discord.Message): The message after editing + """ + if ( + isinstance(before.channel, discord.DMChannel) + and before.author.id in active_threads + ): + thread = self.get_channel(active_threads[before.author.id]) + embed = discord.Embed( + color=discord.Color.blue(), + title="Message edit", + description=f"Message ID: {before.id}", + ) + embed.timestamp = datetime.utcnow() + + # This is here to save space if this listener is triggered by something other than + # a content modification, i.e. a message being pinned + if before.content == after.content: + embed.add_field(name="Before", value=before.content).add_field( + name="After", value="" + ) + + else: + embed.add_field(name="Before", value=before.content).add_field( + name="After", value=after.content + ) + + await thread.send(embed=embed) + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member) -> None: + """Sends a message into a thread if the addressee left + + Args: + member (discord.Member): The member who left + """ + if member.id in active_threads: + thread = self.get_channel(active_threads[member.id]) + embed = discord.Embed( + color=discord.Color.red(), + title="Member left", + description=f"{member.mention} has left the guild.", + ) + await thread.send(embed=embed) + # These get assigned in the __init__, are needed for inter-bot comm # It is a goofy solution but given that this extension is only used in ONE guild, it's good enough @@ -125,8 +181,9 @@ async def on_typing( DISABLE_THREAD_CREATION = None MODMAIL_FORUM_ID = None MODMAIL_LOG_CHANNEL_ID = None -ROLES_TO_PING = None AUTOMATIC_RESPONSES = None +ROLES_TO_PING = None +THREAD_CREATION_MESSAGE = None active_threads = {} closure_jobs = {} # Used in timed closes @@ -136,7 +193,7 @@ async def on_typing( ) # Prepares the Modmail client with the Members intent used for lookups -# It is actually started in __init__ of the modmail command, the client is defined here +# Is started in __init__ of the modmail exntension, the client is defined here # since it is used elsewhere intents = discord.Intents.default() intents.members = True @@ -146,7 +203,7 @@ async def on_typing( async def build_attachments( thread: discord.Thread, message: discord.Message ) -> list[discord.File]: - """Returns a list of as many files from a message as the bot can send to the given guild + """Returns a list of as many files from a message as the bot can send to the given channel Args: @@ -192,11 +249,22 @@ async def handle_dm(message: discord.Message) -> None: ) return - # Gets the modmail channel from TS-es side so it can create the thread - modmail_channel = Ts_client.get_channel(MODMAIL_FORUM_ID) - # The user already has an open thread if message.author.id in active_threads: + thread = Ts_client.get_channel(active_threads[message.author.id]) + + # If thread was going to be closed, cancel the task + if thread.id in closure_jobs: + closure_jobs[thread.id].cancel() + del closure_jobs[thread.id] + + await thread.send( + embed=discord.Embed( + color=discord.Color.red(), + description="Scheduled close has been cancelled.", + ) + ) + await message.add_reaction("📨") embed = discord.Embed(color=discord.Color.blue(), description=message.content) @@ -209,35 +277,47 @@ async def handle_dm(message: discord.Message) -> None: name=message.author, icon_url=message.author.default_avatar.url ) - thread = Ts_client.get_channel(active_threads[message.author.id]) - await thread.send(embed=embed) - - # Handling for attachments + attachments = None if message.attachments: attachments = await build_attachments(thread=thread, message=message) - # If the attachment that was sent is bigger than the bot can send, this will be empty - if attachments: - await thread.send(files=attachments) + + await thread.send(embed=embed, files=attachments) return # No thread was found, create one confirmation = ui.Confirm() await confirmation.send( - message="Create a Modmail thread?", + message=THREAD_CREATION_MESSAGE, channel=message.channel, author=message.author, ) await confirmation.wait() - if confirmation.value in (ui.ConfirmResponse.DENIED, ui.ConfirmResponse.TIMEOUT): + if confirmation.value == ui.ConfirmResponse.DENIED: await auxiliary.send_deny_embed( message="Thread creation cancelled.", channel=message.channel, ) return + elif confirmation.value == ui.ConfirmResponse.TIMEOUT: + await auxiliary.send_deny_embed( + message="Thread confirmation prompt timed out, please hit `Confirm` or `Deny` when " + + "creating a new thread. You are welcome to send another message.", + channel=message.channel, + ) + return + + # Is here in case the user sent multiple messages without hitting confirm, then hit them all + # at once. + if message.author.id in active_threads: + await auxiliary.send_deny_embed( + message="You already opened a thread!", channel=message.channel + ) + return + embed = discord.Embed( color=discord.Color.green(), description="The staff will get back to you as soon as possible.", @@ -248,7 +328,11 @@ async def handle_dm(message: discord.Message) -> None: await message.author.send(embed=embed) - await create_thread(channel=modmail_channel, user=message.author, message=message) + await create_thread( + channel=Ts_client.get_channel(MODMAIL_FORUM_ID), + user=message.author, + message=message, + ) async def create_thread( @@ -256,7 +340,7 @@ async def create_thread( user: discord.User, message: discord.Message = None, ) -> None: - """Creates a thread from a DM message + """Creates a thread from a DM message. The message is left blank when invoked by the contact command Args: @@ -273,16 +357,17 @@ async def create_thread( ) past_thread_count = 0 - for thread in channel.threads: + async for thread in channel.archived_threads(): if not thread.name.startswith("[OPEN]") and thread.name.split("|")[ -1 ].strip() == str(user.id): + past_thread_count += 1 if past_thread_count == 0: description += ", has **no** past threads" else: - description += f", has {past_thread_count} past threads" + description += f", has **{past_thread_count}** past threads" # If the user is a member, do member specific things member = channel.guild.get_member(user.id) @@ -318,6 +403,7 @@ async def create_thread( # has to be done like this because of member handling embed.description = description + embed.set_author(name=user, icon_url=url) embed.timestamp = datetime.utcnow() embed.set_footer(text=f"User ID: {user.id}") @@ -346,18 +432,20 @@ async def create_thread( embed.set_footer(text=f"Message ID: {message.id}") embed.timestamp = datetime.utcnow() - await thread[0].send(embed=embed) - + attachments = None if message.attachments: + if not message.content: + embed.description = "**" + attachments = await build_attachments(thread=thread[0], message=message) - if attachments: - await thread[0].send(files=attachments) + + await thread[0].send(embed=embed, files=attachments) for regex in AUTOMATIC_RESPONSES: if re.match(regex, message.content): await reply_to_thread( - content=AUTOMATIC_RESPONSES[regex], - author=Ts_client.user, + raw_contents=AUTOMATIC_RESPONSES[regex], + message=message, thread=thread[0], anonymous=True, ) @@ -365,16 +453,16 @@ async def create_thread( async def reply_to_thread( - content: str, - author: discord.User, + raw_contents: str, + message: discord.Message, thread: discord.Thread, anonymous: bool, ) -> None: """Replies to a modmail thread on both the dm side and the modmail thread side Args: - content (str): The content to send - author (discord.user): The author of the outgoing message + raw_contents (str): The raw content string + message (discord.Message): The outgoing message, used solely for attachments thread (discord.Thread): The thread to reply to anonymous (bool): Whether to reply anonymously """ @@ -390,27 +478,68 @@ async def reply_to_thread( ) ) + # Gets the user from the guild instead of just looking for them by the id, because modmail + # can't contact users it doesn't share a guild with. Acts as protection for people who left. target_member = discord.utils.get( thread.guild.members, id=int(thread.name.split("|")[-1].strip()) ) - # Refetches the user from modmails client so it can reply to it instead of TS - user = Modmail_client.get_user(target_member.id) + + if not target_member: + await auxiliary.send_deny_embed( + message="Couldn't fetch the user! Are they in the guild?", channel=thread + ) + return # - Modmail thread side - - embed = discord.Embed(color=discord.Color.green(), description=content) + embed = discord.Embed(color=discord.Color.green()) + # if there are any attachments sent, this will be changed to a list of files + attachments = None + # The attachments that will be sent to the user, has to be remade since they become invlaid + # after being sent to the thread + user_attachments = None + + if raw_contents: + embed.description = raw_contents + + # Makes sure an empty message won't be sent + elif not message.attachments: + await auxiliary.send_deny_embed( + message="You need to include message contents!", channel=thread + ) + return + + # Properly handles any attachments + if message.attachments: + if not raw_contents: + embed.description = "**" + + attachments = await build_attachments(thread=thread, message=message) + + if not attachments: + await auxiliary.send_deny_embed( + message="Failed to build any attachments!", channel=thread + ) + + # No need to reconfirm + user_attachments = await build_attachments(thread=thread, message=message) + embed.timestamp = datetime.utcnow() embed.set_footer(text="Response") - if author.avatar: - embed.set_author(name=author, icon_url=author.avatar.url) + + if message.author.avatar: + embed.set_author(name=message.author, icon_url=message.author.avatar.url) else: - embed.set_author(name=author, icon_url=author.default_avatar.url) + embed.set_author( + name=message.author, icon_url=message.author.default_avatar.url + ) - if author == Ts_client.user: + if message.author == Ts_client.user: embed.set_footer(text="[Automatic] Response") elif anonymous: embed.set_footer(text="[Anonymous] Response") - await thread.send(embed=embed) + # Attachments is either None or a list of files, discord can handle either + await thread.send(embed=embed, files=attachments) # - User side - embed.set_footer(text="Response") @@ -418,7 +547,11 @@ async def reply_to_thread( if anonymous: embed.set_author(name="rTechSupport Moderator", icon_url=thread.guild.icon.url) - await user.send(embed=embed) + # Refetches the user from modmails client so it can reply to it instead of TS + user = Modmail_client.get_user(target_member.id) + + # Attachments is either None or a list of files, discord can handle either + await user.send(embed=embed, files=user_attachments) async def close_thread( @@ -428,7 +561,7 @@ async def close_thread( log_channel: discord.TextChannel, closed_by: discord.User, ) -> None: - """Closes a thread instantly or on a delay + """Closes a thread instantly or with a delay Args: thread (discord.Thread): The thread to close @@ -437,7 +570,8 @@ async def close_thread( log_channel (discord.TextChannel): The channel to send the closure message to closed_by (discord.User): The person who closed the thread """ - user = Modmail_client.get_user(int(thread.name.split("|")[-1].strip())) + user_id = int(thread.name.split("|")[-1].strip()) + user = Modmail_client.get_user(user_id) # Waits 5 minutes before closing, below is only executed when func was run as an asyncio job if timed: @@ -457,29 +591,41 @@ async def close_thread( await asyncio.sleep(300) # - Actually starts closing the thread - - del active_threads[user.id] + # Removes closure job from queue if timed: del closure_jobs[thread.id] # Archives and locks the thread - await thread.send( - embed=auxiliary.generate_basic_embed( - color=discord.Color.red(), - title="Thread Closed.", - description="", + if silent: + await thread.send( + embed=auxiliary.generate_basic_embed( + color=discord.Color.red(), + title="Thread Silently Closed.", + description="", + ) ) - ) + else: + await thread.send( + embed=auxiliary.generate_basic_embed( + color=discord.Color.red(), + title="Thread Closed.", + description="", + ) + ) + await thread.edit( name=f"[CLOSED] {thread.name[6:]}", archived=True, locked=True, ) - # No value needed, just has to exist - delayed_people[user.id] = "" + await log_closure(thread, user_id, log_channel, closed_by) - await log_closure(thread, user, log_channel, closed_by) + # User has left the guild + if not user: + delayed_people[int(thread.name.split("|")[-1].strip())] = "" + return if silent: return @@ -494,10 +640,13 @@ async def close_thread( await user.send(embed=embed) + # No value needed, just has to exist + del active_threads[user.id] + async def log_closure( thread: discord.Thread, - user: discord.User, + user_id: int, log_channel: discord.TextChannel, closed_by: discord.User, ) -> None: @@ -505,16 +654,25 @@ async def log_closure( Args: thread (discord.Thread): The thread that got closed - user (discord.User): The person who created the thread + user (int): The id of the person who created the thread, not an user object to + be able to include the ID even if the user leaves the guild log_channel (discord.TextChannel): The log channel to send the closure message to closed_by (discord.User): The person who closed the thread """ + user = Modmail_client.get_user(user_id) - embed = discord.Embed( - color=discord.Color.red(), - description=f"<#{thread.id}>", - title=f"{user.name} `{user.id}`", - ) + if not user: + embed = discord.Embed( + color=discord.Color.red(), + description=f"<#{thread.id}>", + title=f"<<@!{user_id}> has left the guild> `{user_id}`", + ) + else: + embed = discord.Embed( + color=discord.Color.red(), + description=f"<#{thread.id}>", + title=f"{user.name} `{user.id}`", + ) embed.set_footer( icon_url=closed_by.avatar.url, text=f"Thread closed by {closed_by.name}", @@ -560,6 +718,13 @@ async def setup(bot): default=[], ) + config.add( + key="thread_creation_message", + datatype="str", + title="Thread creation message", + description="The message sent to the user when confirming a thread creation.", + default="Create modmail thread?", + ) await bot.add_cog(Modmail(bot=bot)) bot.add_extension_config("modmail", config) @@ -581,7 +746,7 @@ def __init__(self, bot: bot.TechSupportBot): # -> This makes the configs available from the whole file, this can only be done here # -> thanks to modmail only being available in one guild. It is NEEDED for inter-bot comms - # -> Pylint disables because it bitches about using globals + # -> Pylint disables present because it bitches about using globals # pylint: disable=W0603 global DISABLE_THREAD_CREATION @@ -597,36 +762,41 @@ def __init__(self, bot: bot.TechSupportBot): config = bot.guild_configs[str(bot.file_config.modmail_config.modmail_guild)] + # pylint: disable=W0603 + global AUTOMATIC_RESPONSES + AUTOMATIC_RESPONSES = config.extensions.modmail.automatic_responses.value + # pylint: disable=W0603 global ROLES_TO_PING ROLES_TO_PING = config.extensions.modmail.roles_to_ping.value # pylint: disable=W0603 - global AUTOMATIC_RESPONSES - AUTOMATIC_RESPONSES = config.extensions.modmail.automatic_responses.value + global THREAD_CREATION_MESSAGE + THREAD_CREATION_MESSAGE = ( + config.extensions.modmail.thread_creation_message.value + ) - # Finally, makes the TS client available from within the Modmail class once again + # Finally, makes the TS client available from within the Modmail extension class once again self.prefix = bot.file_config.modmail_config.modmail_prefix self.bot = bot async def handle_reboot(self): - """Ram when the bot is restarted""" + """Ran when the bot is restarted""" await Modmail_client.close() @commands.Cog.listener() async def on_ready(self): """Fetches the modmail channel only once ready - Not done in preconfig because that breaks stuff for some reason?""" + Not done in preconfig because that breaks stuff for some reason""" await self.bot.wait_until_ready() # Has to be done in here, putting into preconfig breaks stuff for some reason self.modmail_forum = self.bot.get_channel(MODMAIL_FORUM_ID) # Populates the currently active threads - for thread in self.modmail_forum.threads: if thread.name.startswith("[OPEN]"): - # [username, date, id] + # [status, username, date, id] active_threads[int(thread.name.split(" | ")[3])] = thread.id @commands.Cog.listener() @@ -646,28 +816,15 @@ async def on_message(self, message: discord.Message): or message.channel.name.startswith("[CLOSED]") ): return - # Makes sure the person is actually allowed to run modmail commands + # Makes sure the person is actually allowed to run modmail commands config = self.bot.guild_configs[str(message.guild.id)] - modmail_roles = [] - - # Gets permitted roles - for role_id in config.extensions.modmail.modmail_roles.value: - modmail_role = discord.utils.get(message.guild.roles, id=role_id) - if not modmail_role: - continue - - modmail_roles.append(modmail_role) - - if not any( - modmail_role in getattr(message.author, "roles", []) - for modmail_role in modmail_roles - ): - await auxiliary.send_deny_embed( - channel=message.channel, - message="You don't have permission to use that command!", - ) + try: + await has_modmail_management_role(message, config) + except commands.MissingAnyRole as e: + await auxiliary.send_deny_embed(message=f"{e}", channel=message.channel) return + # Gets the content without the prefix content = message.content.partition(self.prefix)[2] @@ -686,7 +843,7 @@ async def on_message(self, message: discord.Message): return case "tclose": - # If close was ran already, cancel it + # If close was scheduled, cancel it if message.channel.id in closure_jobs: closure_jobs[message.channel.id].cancel() del closure_jobs[message.channel.id] @@ -723,7 +880,7 @@ async def on_message(self, message: discord.Message): return case "tsclose": - # If close was ran already, cancel it + # If close was scheduled, cancel it if message.channel.id in closure_jobs: closure_jobs[message.channel.id].cancel() del closure_jobs[message.channel.id] @@ -750,8 +907,8 @@ async def on_message(self, message: discord.Message): case "reply": await message.delete() await reply_to_thread( - content=content.partition(" ")[2], - author=message.author, + raw_contents=content.partition(" ")[2], + message=message, thread=message.channel, anonymous=False, ) @@ -760,8 +917,8 @@ async def on_message(self, message: discord.Message): case "areply": await message.delete() await reply_to_thread( - content=content.partition(" ")[2], - author=message.author, + raw_contents=content.partition(" ")[2], + message=message, thread=message.channel, anonymous=True, ) @@ -787,8 +944,8 @@ async def on_message(self, message: discord.Message): return await reply_to_thread( - content=factoid.message, - author=message.author, + raw_contents=factoid.message, + message=message, thread=message.channel, anonymous=True, ) @@ -820,7 +977,7 @@ async def contact(self, ctx: commands.Context, user: discord.User): """ if user.bot: await auxiliary.send_deny_embed( - message="I can only talk to other bots using 1s and 0s!", + message="I only talk to other bots using 1s and 0s!", channel=ctx.channel, ) return @@ -852,6 +1009,11 @@ async def contact(self, ctx: commands.Context, user: discord.User): ) case ui.ConfirmResponse.CONFIRMED: + + # Makes sure the user can reply if they were timed out from creating threads + if user.id in delayed_people: + del delayed_people[user.id] + await create_thread(self.bot.get_channel(MODMAIL_FORUM_ID), user=user) await auxiliary.send_confirm_embed( @@ -865,6 +1027,46 @@ async def modmail(self, ctx): # Executed if there are no/invalid args supplied await auxiliary.extension_help(self, ctx, self.__module__[9:]) + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @modmail.command( + name="commands", + brief="Lists all commands you can use in modmail threads", + usage="[user-to-ban]", + ) + async def modmail_commands(self, ctx: commands.Context): + + prefix = self.bot.file_config.modmail_config.modmail_prefix + embed = discord.Embed( + color=discord.Color.green(), + description=f"*You can use these by typing `{prefix}` in a modmail thread*", + title="Modmail commands", + ) + embed.timestamp = datetime.utcnow() + + # I hate this + embed.add_field(name="reply", value="Sends a message").add_field( + name="areply", value="Sends a message anonymously" + ).add_field(name="send", value="Sends the user a factoid").add_field( + # ZWSP used to separate the replies from closes, makes the fields a bit prettier + name="​", + value="​", + inline=False, + ).add_field( + name="close", value="Closes the thread, sends the user a closure message" + ).add_field( + name="tclose", + value="Closes a thread in 5 minutes unless rerun or a message " + "is sent", + ).add_field( + name="sclose", value="Closes a thread without sending the user anything" + ).add_field( + name="tsclose", + value="Closes a thread in 5 minutes unlress rerun or a message" + + "is sent, closes without sending the user anything", + ) + + await ctx.send(embed=embed) + @auxiliary.with_typing @commands.check(has_modmail_management_role) @modmail.command( @@ -889,19 +1091,18 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): # Checking against the user to see if they have the roles specified in the config config = self.bot.guild_configs[str(ctx.guild.id)] + user_roles = getattr(user, "roles", []) modmail_roles = [] # Gets permitted roles for role_id in config.extensions.modmail.modmail_roles.value: - modmail_role = discord.utils.get(ctx.guild.roles, id=role_id) + modmail_role = discord.utils.get(ctx.guild.roles, id=int(role_id)) if not modmail_role: continue modmail_roles.append(modmail_role) - if any( - modmail_role in getattr(user, "roles", []) for modmail_role in modmail_roles - ): + if any(role in user_roles for role in modmail_roles): await auxiliary.send_deny_embed( message="You cannot ban someone with a modmail role!", channel=ctx.channel, @@ -928,9 +1129,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): ) case ui.ConfirmResponse.CONFIRMED: - await self.bot.models.ModmailBan( - user_id=str(user.id), ban_date=datetime.utcnow() - ).create() + await self.bot.models.ModmailBan(user_id=str(user.id)).create() return await auxiliary.send_confirm_embed( message=f"{user.mention} was successfully banned from creating future modmail" From affa813c8afc94a4810ade0fc4429acb2b8a8923 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sun, 7 Apr 2024 15:51:06 +0200 Subject: [PATCH 14/32] Pylint --- techsupport_bot/commands/modmail.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index fcc6c4e72..ee87c31e5 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -302,7 +302,7 @@ async def handle_dm(message: discord.Message) -> None: ) return - elif confirmation.value == ui.ConfirmResponse.TIMEOUT: + if confirmation.value == ui.ConfirmResponse.TIMEOUT: await auxiliary.send_deny_embed( message="Thread confirmation prompt timed out, please hit `Confirm` or `Deny` when " + "creating a new thread. You are welcome to send another message.", @@ -1035,7 +1035,11 @@ async def modmail(self, ctx): usage="[user-to-ban]", ) async def modmail_commands(self, ctx: commands.Context): + """Lists all commands usable in modmail threads + Args: + ctx (commands.Context): Context of the command execution + """ prefix = self.bot.file_config.modmail_config.modmail_prefix embed = discord.Embed( color=discord.Color.green(), @@ -1049,8 +1053,8 @@ async def modmail_commands(self, ctx: commands.Context): name="areply", value="Sends a message anonymously" ).add_field(name="send", value="Sends the user a factoid").add_field( # ZWSP used to separate the replies from closes, makes the fields a bit prettier - name="​", - value="​", + name="\u200B", + value="\u200B", inline=False, ).add_field( name="close", value="Closes the thread, sends the user a closure message" From 0de7e4ae6ee5e72f5cdd42bcb2c45ee5cbef45df Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:51:14 +0200 Subject: [PATCH 15/32] More review stuff --- config.default.yml | 1 + techsupport_bot/commands/modmail.py | 156 ++++++++++++++++++++-------- techsupport_bot/commands/restart.py | 3 +- 3 files changed, 115 insertions(+), 45 deletions(-) diff --git a/config.default.yml b/config.default.yml index c70e132c4..4aa13c42d 100644 --- a/config.default.yml +++ b/config.default.yml @@ -7,6 +7,7 @@ bot_config: default_prefix: "." global_alerts_channel: "" modmail_config: + enable_modmail: False disable_thread_creation: False modmail_auth_token: modmail_prefix: diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index ee87c31e5..5818bdef0 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -2,8 +2,8 @@ Runs a bot that can be messaged to create modmail threads Unit tests: False Config: - File: disable_thread_creation, modmail_auth_token, modmail_prefix, modmail_guild, - modmail_forum_channel, modmail_log_channel + File: enable_modmail, disable_thread_creation, modmail_auth_token, modmail_prefix, + modmail_guild, modmail_forum_channel, modmail_log_channel Command: aliases, automatic_responses, modmail_roles, roles_to_ping, thread_creation_message API: None Postgresql: True @@ -21,7 +21,7 @@ import discord import expiringdict import ui -from core import auxiliary, cogs, extensionconfig +from core import auxiliary, cogs, custom_errors, extensionconfig from discord.ext import commands if TYPE_CHECKING: @@ -43,14 +43,20 @@ async def has_modmail_management_role(ctx: commands.Context, config=None) -> boo Returns: bool: Whether the invoker has a modmail management role """ + # Only running this line of code if config isn't manually defined allows the use of + # a discord.Message object in place of ctx if not config: config = ctx.bot.guild_configs[str(ctx.guild.id)] user_roles = getattr(ctx.author, "roles", []) + unparsed_roles = config.extensions.modmail.modmail_roles.value modmail_roles = [] - if not config.extensions.modmail.modmail_roles.value: + if not unparsed_roles: raise commands.CommandError("No modmail roles were assigned in the config file") + # Deduplicates the list + unparsed_roles = list(dict.fromkeys(unparsed_roles)) + # Two for loops are needed, because an array containing all modmail roles is needed for # the error thrown when the user doesn't have any relevant roles. for role_id in config.extensions.modmail.modmail_roles.value: @@ -91,16 +97,7 @@ async def on_message(self, message: discord.Message) -> None: await auxiliary.send_deny_embed( message="To restrict spam, you are timed out from creating new threads. " + "You are welcome to create a new thread after 24 hours since your previous" - + "thread's closing.", - channel=message.channel, - ) - return - - if DISABLE_THREAD_CREATION: - await message.add_reaction("❌") - await auxiliary.send_deny_embed( - message="Modmail isn't accepting messages right now. " - + "Please try again later.", + + " thread's closing.", channel=message.channel, ) return @@ -136,6 +133,12 @@ async def on_message_edit( isinstance(before.channel, discord.DMChannel) and before.author.id in active_threads ): + + if await Ts_client.models.ModmailBan.query.where( + Ts_client.models.ModmailBan.user_id == str(before.author.id) + ).gino.first(): + return + thread = self.get_channel(active_threads[before.author.id]) embed = discord.Embed( color=discord.Color.blue(), @@ -192,6 +195,10 @@ async def on_member_remove(self, member: discord.Member) -> None: max_age_seconds=93600, max_len=1000 # max_len has to be set for some reason ) +# This is needed to prevent being able to open more than one thread by sending several messages +# and then clicking the confirmations really quickly +awaiting_confirmation = [] + # Prepares the Modmail client with the Members intent used for lookups # Is started in __init__ of the modmail exntension, the client is defined here # since it is used elsewhere @@ -279,13 +286,35 @@ async def handle_dm(message: discord.Message) -> None: attachments = None if message.attachments: + if not message.content: + embed.description = "**" + attachments = await build_attachments(thread=thread, message=message) await thread.send(embed=embed, files=attachments) return - # No thread was found, create one + # - No thread was found, create one - + + if message.author.id in awaiting_confirmation: + await auxiliary.send_deny_embed( + message="Please respond to the existing prompt before trying to open a new modmail" + + " thread!", + channel=message.channel, + ) + return + + # Not run in the initial on_message to allow existing threads to continue + if DISABLE_THREAD_CREATION: + await message.add_reaction("❌") + await auxiliary.send_deny_embed( + message="Modmail isn't accepting messages right now. " + + "Please try again later.", + channel=message.channel, + ) + return + confirmation = ui.Confirm() await confirmation.send( message=THREAD_CREATION_MESSAGE, @@ -293,6 +322,7 @@ async def handle_dm(message: discord.Message) -> None: author=message.author, ) + awaiting_confirmation.append(message.author.id) await confirmation.wait() if confirmation.value == ui.ConfirmResponse.DENIED: @@ -304,19 +334,17 @@ async def handle_dm(message: discord.Message) -> None: if confirmation.value == ui.ConfirmResponse.TIMEOUT: await auxiliary.send_deny_embed( - message="Thread confirmation prompt timed out, please hit `Confirm` or `Deny` when " + message="Thread confirmation prompt timed out, please hit `Confirm` or `Cancel` when " + "creating a new thread. You are welcome to send another message.", channel=message.channel, ) return - # Is here in case the user sent multiple messages without hitting confirm, then hit them all - # at once. - if message.author.id in active_threads: - await auxiliary.send_deny_embed( - message="You already opened a thread!", channel=message.channel - ) - return + await create_thread( + channel=Ts_client.get_channel(MODMAIL_FORUM_ID), + user=message.author, + message=message, + ) embed = discord.Embed( color=discord.Color.green(), @@ -328,12 +356,6 @@ async def handle_dm(message: discord.Message) -> None: await message.author.send(embed=embed) - await create_thread( - channel=Ts_client.get_channel(MODMAIL_FORUM_ID), - user=message.author, - message=message, - ) - async def create_thread( channel: discord.TextChannel, @@ -421,7 +443,7 @@ async def create_thread( thread = await channel.create_thread( name=f"[OPEN] | {user} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {user.id}", embed=embed, - content=role_string.rstrip(), + content=role_string.rstrip()[:2000], ) active_threads[user.id] = thread[0].id @@ -435,7 +457,7 @@ async def create_thread( attachments = None if message.attachments: if not message.content: - embed.description = "**" + embed.description = "**" attachments = await build_attachments(thread=thread[0], message=message) @@ -511,7 +533,7 @@ async def reply_to_thread( # Properly handles any attachments if message.attachments: if not raw_contents: - embed.description = "**" + embed.description = "**" attachments = await build_attachments(thread=thread, message=message) @@ -620,7 +642,7 @@ async def close_thread( locked=True, ) - await log_closure(thread, user_id, log_channel, closed_by) + await log_closure(thread, user_id, log_channel, closed_by, silent) # User has left the guild if not user: @@ -641,6 +663,8 @@ async def close_thread( await user.send(embed=embed) # No value needed, just has to exist + delayed_people[user.id] = "" + del active_threads[user.id] @@ -649,6 +673,7 @@ async def log_closure( user_id: int, log_channel: discord.TextChannel, closed_by: discord.User, + silent: bool, ) -> None: """Sends a closure message to the log channel @@ -658,6 +683,7 @@ async def log_closure( be able to include the ID even if the user leaves the guild log_channel (discord.TextChannel): The log channel to send the closure message to closed_by (discord.User): The person who closed the thread + silent (bool): Whether the thread was closed silently """ user = Modmail_client.get_user(user_id) @@ -673,10 +699,17 @@ async def log_closure( description=f"<#{thread.id}>", title=f"{user.name} `{user.id}`", ) - embed.set_footer( - icon_url=closed_by.avatar.url, - text=f"Thread closed by {closed_by.name}", - ) + + if silent: + embed.set_footer( + icon_url=closed_by.avatar.url, + text=f"Thread silently closed by {closed_by.name}", + ) + else: + embed.set_footer( + icon_url=closed_by.avatar.url, + text=f"Thread closed by {closed_by.name}", + ) embed.timestamp = datetime.utcnow() await log_channel.send(embed=embed) @@ -736,6 +769,12 @@ def __init__(self, bot: bot.TechSupportBot): """Init is used to make variables global so they can be used on the modmail side""" super().__init__(bot=bot) + # Only runs if modmail is enabled + if not bot.file_config.modmail_config.enable_modmail: + # Raising an exception makes the extension loading mark as failed, this is surprisingly + # the most reliable way to ensure the modmail bot or code doesn't run + raise custom_errors.AppCommandExtensionDisabled + # Makes the TS client available globally for creating threads and populating them with info # pylint: disable=W0603 global Ts_client @@ -936,6 +975,16 @@ async def on_message(self, message: discord.Message): .gino.first() ) + # Handling if the call is an alias + if factoid and factoid.alias not in ["", None]: + factoid = ( + await self.bot.models.Factoid.query.where( + self.bot.models.Factoid.name == factoid.alias + ) + .where(self.bot.models.Factoid.guild == str(message.guild.id)) + .gino.first() + ) + if not factoid: await auxiliary.send_deny_embed( message=f"Couldn't find the factoid `{query}`", @@ -943,6 +992,16 @@ async def on_message(self, message: discord.Message): ) return + # Checks for restricted and disabled factoids + config = self.bot.guild_configs[str(message.guild.id)] + + if factoid.disabled or ( + factoid.restricted + and str(message.channel.id) + not in config.extensions.factoids.restricted_list.value + ): + return + await reply_to_thread( raw_contents=factoid.message, message=message, @@ -958,14 +1017,19 @@ async def on_message(self, message: discord.Message): continue await message.delete() - await reply_to_thread(aliases[alias], message.author, message.channel, True) + await reply_to_thread( + raw_contents=aliases[alias], + message=message, + thread=message.channel, + anonymous=True, + ) return @auxiliary.with_typing @commands.check(has_modmail_management_role) @commands.command( name="contact", - brief="Creates a modmail thread with a user", + description="Creates a modmail thread with a user", usage="[user-to-contact]", ) async def contact(self, ctx: commands.Context, user: discord.User): @@ -1031,7 +1095,7 @@ async def modmail(self, ctx): @commands.check(has_modmail_management_role) @modmail.command( name="commands", - brief="Lists all commands you can use in modmail threads", + description="Lists all commands you can use in modmail threads", usage="[user-to-ban]", ) async def modmail_commands(self, ctx: commands.Context): @@ -1075,7 +1139,7 @@ async def modmail_commands(self, ctx: commands.Context): @commands.check(has_modmail_management_role) @modmail.command( name="ban", - brief="Bans a user from creating future modmail threads", + description="Bans a user from creating future modmail threads", usage="[user-to-ban]", ) async def modmail_ban(self, ctx: commands.Context, user: discord.User): @@ -1096,7 +1160,11 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): # Checking against the user to see if they have the roles specified in the config config = self.bot.guild_configs[str(ctx.guild.id)] user_roles = getattr(user, "roles", []) - modmail_roles = [] + unparsed_roles = config.extensions.modmail.modmail_roles.value + modmail_roles = list(dict.fromkeys(unparsed_roles)) + + # No error has to be thrown if unparsed_roles is None, it's already checked in + # has_modmail_management_role # Gets permitted roles for role_id in config.extensions.modmail.modmail_roles.value: @@ -1137,7 +1205,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): return await auxiliary.send_confirm_embed( message=f"{user.mention} was successfully banned from creating future modmail" - + "threads.", + + " threads.", channel=ctx.channel, ) @@ -1145,7 +1213,7 @@ async def modmail_ban(self, ctx: commands.Context, user: discord.User): @commands.check(has_modmail_management_role) @modmail.command( name="unban", - brief="Unbans a user from creating future modmail threads", + description="Unbans a user from creating future modmail threads", usage="[user-to-unban]", ) async def modmail_unban(self, ctx: commands.Context, user: discord.User): diff --git a/techsupport_bot/commands/restart.py b/techsupport_bot/commands/restart.py index da5f891f7..745826d2c 100644 --- a/techsupport_bot/commands/restart.py +++ b/techsupport_bot/commands/restart.py @@ -46,7 +46,8 @@ async def restart(self, ctx: commands.Context) -> None: # Exit modmail if it's enabled modmail_cog = ctx.bot.get_cog("Modmail") - await modmail_cog.handle_reboot() + if modmail_cog: + await modmail_cog.handle_reboot() # Ending the event loop self.bot.loop.stop() From 246e2043631386442a3aca8aaf2cd507f5c95f1e Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:57:24 +0200 Subject: [PATCH 16/32] Made error more verbose --- techsupport_bot/commands/modmail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 5818bdef0..8dce28900 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -773,7 +773,7 @@ def __init__(self, bot: bot.TechSupportBot): if not bot.file_config.modmail_config.enable_modmail: # Raising an exception makes the extension loading mark as failed, this is surprisingly # the most reliable way to ensure the modmail bot or code doesn't run - raise custom_errors.AppCommandExtensionDisabled + raise AttributeError("Modmail was not loaded because it's disabled") # Makes the TS client available globally for creating threads and populating them with info # pylint: disable=W0603 From 65e8555acc5603a2b2a8cdc117e973d3efd3cddf Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:59:24 +0200 Subject: [PATCH 17/32] pylint --- techsupport_bot/commands/modmail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 8dce28900..ebaf58954 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -21,7 +21,7 @@ import discord import expiringdict import ui -from core import auxiliary, cogs, custom_errors, extensionconfig +from core import auxiliary, cogs, extensionconfig from discord.ext import commands if TYPE_CHECKING: From 8113fd20cb4f997fc268f70bda9a7bfec4735b63 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:06:57 +0200 Subject: [PATCH 18/32] Further review stuff --- techsupport_bot/commands/modmail.py | 118 ++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index ebaf58954..18c797be6 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -102,6 +102,16 @@ async def on_message(self, message: discord.Message) -> None: ) return + # Makes sure existing threads can still be responded to + if message.author.id not in active_threads and DISABLE_THREAD_CREATION: + await message.add_reaction("❌") + await auxiliary.send_deny_embed( + message="Modmail isn't accepting messages right now. " + + "Please try again later.", + channel=message.channel, + ) + return + # Everything looks good - handle dm properly await handle_dm(message) @@ -177,6 +187,22 @@ async def on_member_remove(self, member: discord.Member) -> None: ) await thread.send(embed=embed) + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + """Sends a message into a thread if the addressee joined the guild with an active thread + + Args: + member (discord.Member): The member who joined + """ + if member.id in active_threads: + thread = self.get_channel(active_threads[member.id]) + embed = discord.Embed( + color=discord.Color.blue(), + title="Member joined", + description=f"{member.mention} has rejoined the guild.", + ) + await thread.send(embed=embed) + # These get assigned in the __init__, are needed for inter-bot comm # It is a goofy solution but given that this extension is only used in ONE guild, it's good enough @@ -188,9 +214,9 @@ async def on_member_remove(self, member: discord.Member) -> None: ROLES_TO_PING = None THREAD_CREATION_MESSAGE = None -active_threads = {} +active_threads = {} # User id: Thread id closure_jobs = {} # Used in timed closes -# Is a dict because expiringDict only has expiring dictionaries... go figure +# Is a dict because expiringDict only has dictionaries... go figure delayed_people = expiringdict.ExpiringDict( max_age_seconds=93600, max_len=1000 # max_len has to be set for some reason ) @@ -272,8 +298,6 @@ async def handle_dm(message: discord.Message) -> None: ) ) - await message.add_reaction("📨") - embed = discord.Embed(color=discord.Color.blue(), description=message.content) embed.set_footer(text=f"Message ID: {message.id}") embed.timestamp = datetime.utcnow() @@ -293,6 +317,8 @@ async def handle_dm(message: discord.Message) -> None: await thread.send(embed=embed, files=attachments) + await message.add_reaction("📨") + return # - No thread was found, create one - @@ -305,16 +331,6 @@ async def handle_dm(message: discord.Message) -> None: ) return - # Not run in the initial on_message to allow existing threads to continue - if DISABLE_THREAD_CREATION: - await message.add_reaction("❌") - await auxiliary.send_deny_embed( - message="Modmail isn't accepting messages right now. " - + "Please try again later.", - channel=message.channel, - ) - return - confirmation = ui.Confirm() await confirmation.send( message=THREAD_CREATION_MESSAGE, @@ -346,15 +362,9 @@ async def handle_dm(message: discord.Message) -> None: message=message, ) - embed = discord.Embed( - color=discord.Color.green(), - description="The staff will get back to you as soon as possible.", - ) - embed.set_author(name="Thread Created") - embed.set_footer(text="Your message has been sent.") - embed.timestamp = datetime.utcnow() + await message.add_reaction("📨") - await message.author.send(embed=embed) + awaiting_confirmation.remove(message.author.id) async def create_thread( @@ -370,6 +380,24 @@ async def create_thread( user (discord.User): The user who sent the DM or is being contacted message (discord.Message, optional): The incoming message """ + # --> CHECKS <-- + + # These checks can be triggered on both the users and server side using .contact + # The code adjusts the error for formatting purposes + if user.id in active_threads: + if message.guild: + fmt_message = ( + f"User already has an open thread! <#{active_threads[user.id]}>", + ) + else: + fmt_message = ("You already have an open thread!",) + + await auxiliary.send_deny_embed( + message=fmt_message, + channel=message.channel, + ) + return + # --> WELCOME MESSAGE <-- embed = discord.Embed(color=discord.Color.blue()) @@ -402,7 +430,9 @@ async def create_thread( role_string = "None" roles = [] - for role in sorted(member.roles, key=lambda x: x.position, reverse=True): + deduplicated_roles = list(dict.fromkeys(member.roles)) + + for role in sorted(deduplicated_roles, key=lambda x: x.position, reverse=True): if role.is_default(): continue roles.append(role.mention) @@ -449,6 +479,7 @@ async def create_thread( # The thread creation was invoked from an incoming message if message: + # - Server side - embed = discord.Embed(color=discord.Color.blue(), description=message.content) embed.set_author(name=user, icon_url=url) embed.set_footer(text=f"Message ID: {message.id}") @@ -463,6 +494,18 @@ async def create_thread( await thread[0].send(embed=embed, files=attachments) + # - User side - + embed = discord.Embed( + color=discord.Color.green(), + description="The staff will get back to you as soon as possible.", + ) + embed.set_author(name="Thread Created") + embed.set_footer(text="Your message has been sent.") + embed.timestamp = datetime.utcnow() + + await message.author.send(embed=embed) + + # - Auto responses - for regex in AUTOMATIC_RESPONSES: if re.match(regex, message.content): await reply_to_thread( @@ -567,7 +610,9 @@ async def reply_to_thread( embed.set_footer(text="Response") if anonymous: - embed.set_author(name="rTechSupport Moderator", icon_url=thread.guild.icon.url) + embed.set_author( + name=f"{thread.guild.name} Moderator", icon_url=thread.guild.icon.url + ) # Refetches the user from modmails client so it can reply to it instead of TS user = Modmail_client.get_user(target_member.id) @@ -642,13 +687,19 @@ async def close_thread( locked=True, ) + del active_threads[user.id] + await log_closure(thread, user_id, log_channel, closed_by, silent) # User has left the guild if not user: + # No value needed, just has to exist in the dictionary delayed_people[int(thread.name.split("|")[-1].strip())] = "" return + # No value needed, just has to exist in the dictionary + delayed_people[user.id] = "" + if silent: return @@ -662,11 +713,6 @@ async def close_thread( await user.send(embed=embed) - # No value needed, just has to exist - delayed_people[user.id] = "" - - del active_threads[user.id] - async def log_closure( thread: discord.Thread, @@ -997,7 +1043,7 @@ async def on_message(self, message: discord.Message): if factoid.disabled or ( factoid.restricted - and str(message.channel.id) + and str(MODMAIL_FORUM_ID) not in config.extensions.factoids.restricted_list.value ): return @@ -1053,6 +1099,14 @@ async def contact(self, ctx: commands.Context, user: discord.User): ) return + if user.id in awaiting_confirmation: + await auxiliary.send_deny_embed( + message="User has already messaged modmail, is currently facing the confirmation" + + " prompt!", + channel=ctx.channel, + ) + return + confirmation = ui.Confirm() await confirmation.send( message=(f"Create a new modmail thread with {user.mention}?"), @@ -1129,8 +1183,8 @@ async def modmail_commands(self, ctx: commands.Context): name="sclose", value="Closes a thread without sending the user anything" ).add_field( name="tsclose", - value="Closes a thread in 5 minutes unlress rerun or a message" - + "is sent, closes without sending the user anything", + value="Closes a thread in 5 minutes unless rerun or a message" + + " is sent, closes without sending the user anything", ) await ctx.send(embed=embed) From cb2a20849530336e4758f0727add54a9a5be8146 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:16:00 +0200 Subject: [PATCH 19/32] Added protection for newlines without spaces --- techsupport_bot/commands/modmail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 18c797be6..80e07b2e0 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -992,7 +992,7 @@ async def on_message(self, message: discord.Message): case "reply": await message.delete() await reply_to_thread( - raw_contents=content.partition(" ")[2], + raw_contents=content[5:], message=message, thread=message.channel, anonymous=False, @@ -1002,7 +1002,7 @@ async def on_message(self, message: discord.Message): case "areply": await message.delete() await reply_to_thread( - raw_contents=content.partition(" ")[2], + raw_contents=content[6:], message=message, thread=message.channel, anonymous=True, From 4fe3bc9f1599930480d7cd3a541fd147e88fcd82 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:29:56 +0200 Subject: [PATCH 20/32] Fixed some handling when the member leaves the guild --- techsupport_bot/commands/modmail.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 80e07b2e0..e3739bc6b 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -687,16 +687,19 @@ async def close_thread( locked=True, ) - del active_threads[user.id] - await log_closure(thread, user_id, log_channel, closed_by, silent) # User has left the guild if not user: + del active_threads[user_id] + # No value needed, just has to exist in the dictionary - delayed_people[int(thread.name.split("|")[-1].strip())] = "" + delayed_people[user_id] = "" return + # User can't be None anymore + del active_threads[user.id] + # No value needed, just has to exist in the dictionary delayed_people[user.id] = "" @@ -737,7 +740,7 @@ async def log_closure( embed = discord.Embed( color=discord.Color.red(), description=f"<#{thread.id}>", - title=f"<<@!{user_id}> has left the guild> `{user_id}`", + title=f" `{user_id}`", ) else: embed = discord.Embed( @@ -1087,7 +1090,7 @@ async def contact(self, ctx: commands.Context, user: discord.User): """ if user.bot: await auxiliary.send_deny_embed( - message="I only talk to other bots using 1s and 0s!", + message="I only talk to other bots using 0s and 1s!", channel=ctx.channel, ) return From 0409cf109fc01da41ef8a63f0bd67c54acb95793 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:08:39 +0200 Subject: [PATCH 21/32] Added some further automatic handling, selfcontact --- techsupport_bot/commands/modmail.py | 116 ++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index e3739bc6b..7a4199d3e 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -359,6 +359,7 @@ async def handle_dm(message: discord.Message) -> None: await create_thread( channel=Ts_client.get_channel(MODMAIL_FORUM_ID), user=message.author, + source_channel=message.channel, message=message, ) @@ -370,33 +371,39 @@ async def handle_dm(message: discord.Message) -> None: async def create_thread( channel: discord.TextChannel, user: discord.User, + source_channel: discord.TextChannel, message: discord.Message = None, -) -> None: +) -> bool: """Creates a thread from a DM message. The message is left blank when invoked by the contact command Args: channel (discord.TextChannel): The forum channel to create the thread in user (discord.User): The user who sent the DM or is being contacted + source_channel (discord.TextChannel): Used for error handling message (discord.Message, optional): The incoming message + + Returns: + bool: Whether the thread was created succesfully """ # --> CHECKS <-- # These checks can be triggered on both the users and server side using .contact # The code adjusts the error for formatting purposes if user.id in active_threads: - if message.guild: - fmt_message = ( - f"User already has an open thread! <#{active_threads[user.id]}>", + # Ran from a DM + if message: + await auxiliary.send_deny_embed( + message=f"You already have an open thread!", + channel=source_channel, ) else: - fmt_message = ("You already have an open thread!",) + await auxiliary.send_deny_embed( + message=f"User already has an open thread! <#{active_threads[user.id]}>", + channel=source_channel, + ) - await auxiliary.send_deny_embed( - message=fmt_message, - channel=message.channel, - ) - return + return False # --> WELCOME MESSAGE <-- embed = discord.Embed(color=discord.Color.blue()) @@ -513,8 +520,11 @@ async def create_thread( message=message, thread=thread[0], anonymous=True, + automatic=True, ) - return + return True + + return True async def reply_to_thread( @@ -522,14 +532,16 @@ async def reply_to_thread( message: discord.Message, thread: discord.Thread, anonymous: bool, + automatic: bool = False, ) -> None: """Replies to a modmail thread on both the dm side and the modmail thread side Args: raw_contents (str): The raw content string - message (discord.Message): The outgoing message, used solely for attachments + message (discord.Message): The outgoing message, used for attachments and author handling thread (discord.Thread): The thread to reply to anonymous (bool): Whether to reply anonymously + automatic (bool, optional): Whether this response was automatic """ # If thread was going to be closed, cancel the task if thread.id in closure_jobs: @@ -591,14 +603,18 @@ async def reply_to_thread( embed.timestamp = datetime.utcnow() embed.set_footer(text="Response") - if message.author.avatar: + if automatic: + embed.set_author(name=thread.guild, icon_url=thread.guild.icon.url) + elif message.author.avatar: embed.set_author(name=message.author, icon_url=message.author.avatar.url) else: embed.set_author( name=message.author, icon_url=message.author.default_avatar.url ) - if message.author == Ts_client.user: + if automatic: + embed.set_footer(text="[Automatic] Response") + elif message.author == Ts_client.user: embed.set_footer(text="[Automatic] Response") elif anonymous: embed.set_footer(text="[Anonymous] Response") @@ -1135,12 +1151,78 @@ async def contact(self, ctx: commands.Context, user: discord.User): if user.id in delayed_people: del delayed_people[user.id] - await create_thread(self.bot.get_channel(MODMAIL_FORUM_ID), user=user) + if await create_thread( + channel=self.bot.get_channel(MODMAIL_FORUM_ID), + user=user, + source_channel=ctx.channel, + ): - await auxiliary.send_confirm_embed( - message="Thread successfully created!", channel=ctx.channel + await auxiliary.send_confirm_embed( + message="Thread successfully created!", channel=ctx.channel + ) + + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @commands.command( + name="selfcontact", + description="Creates a modmail thread with yourself, doesn't ping anyone when doing so", + usage="[user-to-contact]", + ) + async def selfcontact(self, ctx: commands.Context): + """Opens a modmail thread with yourself + + Args: + ctx (commands.Context): Context of the command execution + """ + if ctx.author.id in active_threads: + await auxiliary.send_deny_embed( + message=f"You already have an open thread! <#{active_threads[ctx.author.id]}>", + channel=ctx.channel, + ) + return + + if ctx.author.id in awaiting_confirmation: + await auxiliary.send_deny_embed( + message="You already have a confirmation prompt in DMs!", + channel=ctx.channel, + ) + return + + confirmation = ui.Confirm() + await confirmation.send( + message=(f"Create a new modmail thread with yourself?"), + channel=ctx.channel, + author=ctx.author, + ) + + await confirmation.wait() + + match confirmation.value: + case ui.ConfirmResponse.TIMEOUT: + pass + + case ui.ConfirmResponse.DENIED: + await auxiliary.send_deny_embed( + message="The thread was not created.", + channel=ctx.channel, ) + case ui.ConfirmResponse.CONFIRMED: + + # Makes sure the user can reply if they were timed out from creating threads + if ctx.author in delayed_people: + del delayed_people[ctx.author.id] + + if await create_thread( + channel=self.bot.get_channel(MODMAIL_FORUM_ID), + user=ctx.author, + source_channel=ctx.channel, + ): + + await auxiliary.send_confirm_embed( + message="Thread successfully created!", channel=ctx.channel + ) + @commands.group(name="modmail") async def modmail(self, ctx): """Method for the modmail command group.""" From 4b288b20d25abf1bf50fd6d3b49ce76ca452ef12 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:09:16 +0200 Subject: [PATCH 22/32] pylint --- techsupport_bot/commands/modmail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 7a4199d3e..d3f417cf6 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -394,7 +394,7 @@ async def create_thread( # Ran from a DM if message: await auxiliary.send_deny_embed( - message=f"You already have an open thread!", + message="You already have an open thread!", channel=source_channel, ) else: @@ -1190,7 +1190,7 @@ async def selfcontact(self, ctx: commands.Context): confirmation = ui.Confirm() await confirmation.send( - message=(f"Create a new modmail thread with yourself?"), + message=("Create a new modmail thread with yourself?"), channel=ctx.channel, author=ctx.author, ) From bc87be1cfeeaab688af6b8e4ab2ccbb253a25fcb Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:26:11 +0200 Subject: [PATCH 23/32] fixed confirmation issue --- techsupport_bot/commands/modmail.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index d3f417cf6..de90aef46 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -342,6 +342,7 @@ async def handle_dm(message: discord.Message) -> None: await confirmation.wait() if confirmation.value == ui.ConfirmResponse.DENIED: + awaiting_confirmation.remove(message.author.id) await auxiliary.send_deny_embed( message="Thread creation cancelled.", channel=message.channel, @@ -349,6 +350,7 @@ async def handle_dm(message: discord.Message) -> None: return if confirmation.value == ui.ConfirmResponse.TIMEOUT: + awaiting_confirmation.remove(message.author.id) await auxiliary.send_deny_embed( message="Thread confirmation prompt timed out, please hit `Confirm` or `Cancel` when " + "creating a new thread. You are welcome to send another message.", From 6ab35ff059bad27997bafe77bfb1b7a415db3e8a Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:28:58 +0200 Subject: [PATCH 24/32] DEDUPLICATES ROLES. --- techsupport_bot/commands/modmail.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index de90aef46..be7242689 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -439,9 +439,7 @@ async def create_thread( role_string = "None" roles = [] - deduplicated_roles = list(dict.fromkeys(member.roles)) - - for role in sorted(deduplicated_roles, key=lambda x: x.position, reverse=True): + for role in sorted(member.roles, key=lambda x: x.position, reverse=True): if role.is_default(): continue roles.append(role.mention) @@ -874,7 +872,10 @@ def __init__(self, bot: bot.TechSupportBot): # pylint: disable=W0603 global ROLES_TO_PING - ROLES_TO_PING = config.extensions.modmail.roles_to_ping.value + # dict.fromkeys() to deduplicate the list + ROLES_TO_PING = list( + dict.fromkeys(config.extensions.modmail.roles_to_ping.value) + ) # pylint: disable=W0603 global THREAD_CREATION_MESSAGE From 272527152eb2345e43beddfd9cd7b501ea61edd9 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:00:39 +0200 Subject: [PATCH 25/32] Last bugs squashed --- techsupport_bot/commands/modmail.py | 75 +++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index be7242689..a1afa6b1a 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -91,6 +91,16 @@ async def on_message(self, message: discord.Message) -> None: await message.add_reaction("❌") return + # Makes sure existing threads can still be responded to + if message.author.id not in active_threads and DISABLE_THREAD_CREATION: + await message.add_reaction("❌") + await auxiliary.send_deny_embed( + message="Modmail isn't accepting messages right now. " + + "Please try again later.", + channel=message.channel, + ) + return + # Spam protection if message.author.id in delayed_people: await message.add_reaction("🕒") @@ -102,16 +112,6 @@ async def on_message(self, message: discord.Message) -> None: ) return - # Makes sure existing threads can still be responded to - if message.author.id not in active_threads and DISABLE_THREAD_CREATION: - await message.add_reaction("❌") - await auxiliary.send_deny_embed( - message="Modmail isn't accepting messages right now. " - + "Please try again later.", - channel=message.channel, - ) - return - # Everything looks good - handle dm properly await handle_dm(message) @@ -164,6 +164,16 @@ async def on_message_edit( name="After", value="" ) + # Length handling has to be here, 1024 is the limit for inividual fields + elif len(before.content) > 1024 or len(after.content) > 1024: + embed.set_footer( + text="Edit was too long to send! Sending just the result instead..." + ) + embed.description += ( + f"\n\n**New contents:**\n```{after.content[:5975]}```" + ) + + # Length is fine, send as usual else: embed.add_field(name="Before", value=before.content).add_field( name="After", value=after.content @@ -675,8 +685,9 @@ async def close_thread( # - Actually starts closing the thread - - # Removes closure job from queue - if timed: + # Removes closure job from queue if it's there + if thread.id in closure_jobs: + closure_jobs[thread.id].cancel() del closure_jobs[thread.id] # Archives and locks the thread @@ -1121,14 +1132,6 @@ async def contact(self, ctx: commands.Context, user: discord.User): ) return - if user.id in awaiting_confirmation: - await auxiliary.send_deny_embed( - message="User has already messaged modmail, is currently facing the confirmation" - + " prompt!", - channel=ctx.channel, - ) - return - confirmation = ui.Confirm() await confirmation.send( message=(f"Create a new modmail thread with {user.mention}?"), @@ -1277,6 +1280,38 @@ async def modmail_commands(self, ctx: commands.Context): await ctx.send(embed=embed) + @auxiliary.with_typing + @commands.check(has_modmail_management_role) + @modmail.command( + name="aliases", + description="Lists all existing modmail aliases", + usage="", + ) + async def list_aliases(self, ctx: commands.context): + """Lists all existing modmail aliases + + Args: + ctx (commands.context): Context of the command execution + """ + + config = self.bot.guild_configs[str(ctx.guild.id)] + + # Checks if the command was an alias + aliases = config.extensions.modmail.aliases.value + if not aliases: + embed = auxiliary.generate_basic_embed( + color=discord.Color.green(), + description="There are no aliases registered for this guild", + ) + + for alias in aliases: + embed = discord.Embed( + color=discord.Color.green(), title="Registered aliases for this guild:" + ) + embed.add_field(name=f"{self.prefix}{alias}", value=aliases[alias]) + + await ctx.channel.send(embed=embed) + @auxiliary.with_typing @commands.check(has_modmail_management_role) @modmail.command( From a10b1feb6a31848a65381deb86dfa3ea6592e67f Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:14:53 +0200 Subject: [PATCH 26/32] more bugs --- techsupport_bot/commands/modmail.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index a1afa6b1a..95766071c 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -165,7 +165,7 @@ async def on_message_edit( ) # Length handling has to be here, 1024 is the limit for inividual fields - elif len(before.content) > 1024 or len(after.content) > 1024: + elif len(before.content) > 1016 or len(after.content) > 1016: embed.set_footer( text="Edit was too long to send! Sending just the result instead..." ) @@ -175,9 +175,9 @@ async def on_message_edit( # Length is fine, send as usual else: - embed.add_field(name="Before", value=before.content).add_field( - name="After", value=after.content - ) + embed.add_field( + name="Before", value=f"```{before.content}```" + ).add_field(name="After", value=f"```{after.content}```") await thread.send(embed=embed) @@ -687,7 +687,9 @@ async def close_thread( # Removes closure job from queue if it's there if thread.id in closure_jobs: - closure_jobs[thread.id].cancel() + if not timed: + closure_jobs[thread.id].cancel() + del closure_jobs[thread.id] # Archives and locks the thread From 69cd831eace3469a66ee789f996a9e5d1764320c Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:17:28 +0200 Subject: [PATCH 27/32] Added a comment --- techsupport_bot/commands/modmail.py | 1 + 1 file changed, 1 insertion(+) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 95766071c..fbccd53dd 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -687,6 +687,7 @@ async def close_thread( # Removes closure job from queue if it's there if thread.id in closure_jobs: + # Makes sure the close job doesn't kill itself if not timed: closure_jobs[thread.id].cancel() From f2947f76442bc2d982d81746d4dfeff34f619ca5 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:20:35 +0200 Subject: [PATCH 28/32] Removed erroneous space --- techsupport_bot/commands/modmail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index fbccd53dd..26e945579 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -712,7 +712,7 @@ async def close_thread( ) await thread.edit( - name=f"[CLOSED] {thread.name[6:]}", + name=f"[CLOSED] {thread.name[7:]}", archived=True, locked=True, ) From cf129770ace513b1838c164b44b460bf0bda3a47 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:29:57 +0200 Subject: [PATCH 29/32] Handling for failed created_thread --- techsupport_bot/commands/modmail.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 26e945579..9193bd45b 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -368,12 +368,13 @@ async def handle_dm(message: discord.Message) -> None: ) return - await create_thread( + if not await create_thread( channel=Ts_client.get_channel(MODMAIL_FORUM_ID), user=message.author, source_channel=message.channel, message=message, - ) + ): + return await message.add_reaction("📨") From 9b2c113e8c29c7036ab1a5100421a51f53102564 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:40:25 +0200 Subject: [PATCH 30/32] Bots can't talk to each other now. Binary is dead --- techsupport_bot/commands/modmail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index 9193bd45b..a075657b8 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -936,7 +936,7 @@ async def on_message(self, message: discord.Message): or not isinstance(message.channel, discord.Thread) or message.channel.parent_id != self.modmail_forum.id or message.channel.name.startswith("[CLOSED]") - ): + ) and not message.author.bot: return # Makes sure the person is actually allowed to run modmail commands From bd12b7cecc6a8b94e91a8deff2f365be1f361346 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Fri, 12 Apr 2024 23:03:24 +0200 Subject: [PATCH 31/32] Sticker protection --- techsupport_bot/commands/modmail.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index a075657b8..af2cf9a9b 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -325,6 +325,10 @@ async def handle_dm(message: discord.Message) -> None: attachments = await build_attachments(thread=thread, message=message) + # This should only happen if a sticker was sent, is here so an empty message isn't sent + if not embed.description: + return + await thread.send(embed=embed, files=attachments) await message.add_reaction("📨") From 5d58a715c7e5f8577a1fae72b1f836adad6ed956 Mon Sep 17 00:00:00 2001 From: Cpt-Dingus <100243410+Cpt-Dingus@users.noreply.github.com> Date: Sat, 13 Apr 2024 19:07:51 +0200 Subject: [PATCH 32/32] tiny bugs --- techsupport_bot/commands/modmail.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/techsupport_bot/commands/modmail.py b/techsupport_bot/commands/modmail.py index af2cf9a9b..cf3c3663f 100644 --- a/techsupport_bot/commands/modmail.py +++ b/techsupport_bot/commands/modmail.py @@ -176,8 +176,8 @@ async def on_message_edit( # Length is fine, send as usual else: embed.add_field( - name="Before", value=f"```{before.content}```" - ).add_field(name="After", value=f"```{after.content}```") + name="Before", value=f"```\n{before.content}```" + ).add_field(name="After", value=f"```\n{after.content}```") await thread.send(embed=embed) @@ -940,7 +940,8 @@ async def on_message(self, message: discord.Message): or not isinstance(message.channel, discord.Thread) or message.channel.parent_id != self.modmail_forum.id or message.channel.name.startswith("[CLOSED]") - ) and not message.author.bot: + or message.author.bot + ): return # Makes sure the person is actually allowed to run modmail commands