diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c4f1bdd4..f2c31e9487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ however, insignificant breaking changes do not guarantee a major version bump, s # v4.2.0 Upgraded discord.py to version 2.6.3, added support for CV2. -Forwarded messages now properly show in threads, rather than showing as an empty embed. +Forwarded messages now properly show in threads, rather then showing as an empty embed. ### Fixed - Make Modmail keep working when typing is disabled due to an outage caused by Discord. @@ -18,7 +18,7 @@ Forwarded messages now properly show in threads, rather than showing as an empty - Eliminated duplicate logs and notes. - Addressed inconsistent use of `logkey` after ticket restoration. - Fixed issues with identifying the user who sent internal messages. -- Solved an ancient bug where closing with words like `evening` wouldn't work. +- Solved an ancient bug where closing with words like `evening` wouldnt work. - Fixed the command from being included in the reply in rare conditions. ### Added @@ -29,16 +29,39 @@ Commands: * `clearsnoozed`: Clears all snoozed items. Configuration Options: -* `max_snooze_time`: Sets the maximum duration for snooze. +* `snooze_default_duration`: Sets the maximum duration for snooze. * `snooze_title`: Customizes the title for snooze notifications. * `snooze_text`: Customizes the text for snooze notifications. * `unsnooze_text`: Customizes the text for unsnooze notifications. * `unsnooze_notify_channel`: Specifies the channel for unsnooze notifications. +* `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown). +* `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing. +* `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`. * `thread_min_characters`: Minimum number of characters required. * `thread_min_characters_title`: Title shown when the message is too short. * `thread_min_characters_response`: Response shown to the user if their message is too short. * `thread_min_characters_footer`: Footer displaying the minimum required characters. +Features: +* Thread-creation menu: Adds an interactive select step before a thread channel is created. + * Commands: + * `threadmenu toggle`: Enable/disable the menu. + * `threadmenu show`: List current top-level options. + * `threadmenu option add`: Interactive wizard to create an option. + * `threadmenu option edit/remove/show`: Manage or inspect an existing option. + * `threadmenu submenu create/delete/list/show`: Manage submenus. + * `threadmenu submenu option add/edit/remove`: Manage options inside a submenu. + * Configuration / Behavior: + * Per-option `category` targeting when creating a thread; falls back to `main_category_id` if invalid/missing. + * Optional selection logging (`thread_creation_menu_selection_log`) posts the chosen option in the new thread. + * Anonymous prompt support (`thread_creation_menu_anonymous_menu`). + + +Behavioral changes: +- When `snooze_behavior` is set to `move`, the snoozed category now has a hard limit of 49 channels. New snoozes are blocked once it’s full until space is freed. +- When switching `snooze_behavior` to `move` via `?config set`, the bot reminds admins to set `snoozed_category_id` if it’s missing. +- Thread-creation menu options & submenu options now support an optional per-option `category` target. The interactive wizards (`threadmenu option add` / `threadmenu submenu option add`) and edit commands allow specifying or updating a category. If the stored category is missing or invalid at selection time, channel creation automatically falls back to `main_category_id`. + # v4.1.2 ### Fixed diff --git a/bot.py b/bot.py index 671d9ab9c4..0435cedc3d 100644 --- a/bot.py +++ b/bot.py @@ -4,7 +4,6 @@ import asyncio import copy import hashlib -import logging import os import re import string @@ -12,7 +11,7 @@ import sys import platform import typing -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from subprocess import PIPE from types import SimpleNamespace @@ -80,16 +79,24 @@ def __init__(self): if not self.config["enable_presence_intent"]: intents.presences = False - super().__init__(command_prefix=None, intents=intents) # implemented in `get_prefix` + super().__init__( + command_prefix=None, intents=intents + ) # implemented in `get_prefix` self.session = None self._api = None self.formatter = SafeFormatter() - self.loaded_cogs = ["cogs.modmail", "cogs.plugins", "cogs.utility"] + self.loaded_cogs = [ + "cogs.modmail", + "cogs.plugins", + "cogs.utility", + "cogs.threadmenu", + ] self._connected = None self.start_time = discord.utils.utcnow() self._started = False self.threads = ThreadManager(self) + self._message_queues = {} # User ID -> asyncio.Queue for message ordering log_dir = os.path.join(temp_dir, "logs") if not os.path.exists(log_dir): @@ -101,7 +108,10 @@ def __init__(self): self.startup() def get_guild_icon( - self, guild: typing.Optional[discord.Guild], *, size: typing.Optional[int] = None + self, + guild: typing.Optional[discord.Guild], + *, + size: typing.Optional[int] = None, ) -> str: if guild is None: guild = self.guild @@ -245,7 +255,11 @@ async def _cancel_tasks(): async with self: task_retriever = asyncio.all_tasks loop = self.loop - tasks = {t for t in task_retriever() if not t.done() and t.get_coro() != cancel_tasks_coro} + tasks = { + t + for t in task_retriever() + if not t.done() and t.get_coro() != cancel_tasks_coro + } if not tasks: return @@ -290,7 +304,9 @@ def bot_owner_ids(self): owner_ids = set(map(int, str(owner_ids).split(","))) if self.owner_id is not None: owner_ids.add(self.owner_id) - permissions = self.config["level_permissions"].get(PermissionLevel.OWNER.name, []) + permissions = self.config["level_permissions"].get( + PermissionLevel.OWNER.name, [] + ) for perm in permissions: owner_ids.add(int(perm)) return owner_ids @@ -316,7 +332,10 @@ def log_channel(self) -> typing.Optional[discord.TextChannel]: try: channel = self.main_category.channels[0] self.config["log_channel_id"] = channel.id - logger.warning("No log channel set, setting #%s to be the log channel.", channel.name) + logger.warning( + "No log channel set, setting #%s to be the log channel.", + channel.name, + ) return channel except IndexError: pass @@ -378,7 +397,9 @@ def auto_triggers(self) -> typing.Dict[str, str]: def token(self) -> str: token = self.config["token"] if token is None: - logger.critical("TOKEN must be set, set this as bot token found on the Discord Developer Portal.") + logger.critical( + "TOKEN must be set, set this as bot token found on the Discord Developer Portal." + ) sys.exit(0) return token @@ -432,7 +453,9 @@ def main_category(self) -> typing.Optional[discord.CategoryChannel]: category_id = self.config["main_category_id"] if category_id is not None: try: - cat = discord.utils.get(self.modmail_guild.categories, id=int(category_id)) + cat = discord.utils.get( + self.modmail_guild.categories, id=int(category_id) + ) if cat is not None: return cat except ValueError: @@ -486,7 +509,9 @@ def command_perm(self, command_name: str) -> PermissionLevel: try: return PermissionLevel[level.upper()] except KeyError: - logger.warning("Invalid override_command_level for command %s.", command_name) + logger.warning( + "Invalid override_command_level for command %s.", command_name + ) self.config["override_command_level"].pop(command_name) command = self.get_command(command_name) @@ -494,7 +519,11 @@ def command_perm(self, command_name: str) -> PermissionLevel: logger.debug("Command %s not found.", command_name) return PermissionLevel.INVALID level = next( - (check.permission_level for check in command.checks if hasattr(check, "permission_level")), + ( + check.permission_level + for check in command.checks + if hasattr(check, "permission_level") + ), None, ) if level is None: @@ -537,7 +566,8 @@ async def on_ready(self): logger.info("Logged in as: %s", self.user) logger.info("Bot ID: %s", self.user.id) owners = ", ".join( - getattr(self.get_user(owner_id), "name", str(owner_id)) for owner_id in self.bot_owner_ids + getattr(self.get_user(owner_id), "name", str(owner_id)) + for owner_id in self.bot_owner_ids ) logger.info("Owners: %s", owners) logger.info("Prefix: %s", self.prefix) @@ -563,13 +593,18 @@ async def on_ready(self): for recipient_id, items in tuple(closures.items()): after = ( - datetime.fromisoformat(items["time"]).astimezone(timezone.utc) - discord.utils.utcnow() + datetime.fromisoformat(items["time"]).astimezone(timezone.utc) + - discord.utils.utcnow() ).total_seconds() if after <= 0: logger.debug("Closing thread for recipient %s.", recipient_id) after = 0 else: - logger.debug("Thread for recipient %s will be closed after %s seconds.", recipient_id, after) + logger.debug( + "Thread for recipient %s will be closed after %s seconds.", + recipient_id, + after, + ) thread = await self.threads.find(recipient_id=int(recipient_id)) @@ -590,8 +625,13 @@ async def on_ready(self): ) for log in await self.api.get_open_logs(): - if self.get_channel(int(log["channel_id"])) is None: - logger.debug("Unable to resolve thread with channel %s.", log["channel_id"]) + if ( + log.get("channel_id") is None + or self.get_channel(int(log["channel_id"])) is None + ): + logger.debug( + "Unable to resolve thread with channel %s.", log["channel_id"] + ) log_data = await self.api.post_log( log["channel_id"], { @@ -609,18 +649,29 @@ async def on_ready(self): }, ) if log_data: - logger.debug("Successfully closed thread with channel %s.", log["channel_id"]) + logger.debug( + "Successfully closed thread with channel %s.", log["channel_id"] + ) else: - logger.debug("Failed to close thread with channel %s, skipping.", log["channel_id"]) + logger.debug( + "Failed to close thread with channel %s, skipping.", + log["channel_id"], + ) - other_guilds = [guild for guild in self.guilds if guild not in {self.guild, self.modmail_guild}] + other_guilds = [ + guild + for guild in self.guilds + if guild not in {self.guild, self.modmail_guild} + ] if any(other_guilds): logger.warning( "The bot is in more servers other than the main and staff server. " "This may cause data compromise (%s).", ", ".join(str(guild.name) for guild in other_guilds), ) - logger.warning("If the external servers are valid, you may ignore this message.") + logger.warning( + "If the external servers are valid, you may ignore this message." + ) self.post_metadata.start() self.autoupdate.start() @@ -649,7 +700,9 @@ async def get_or_fetch_user(self, id: int) -> discord.User: return self.get_user(id) or await self.fetch_user(id) @staticmethod - async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> typing.Optional[discord.Member]: + async def get_or_fetch_member( + guild: discord.Guild, member_id: int + ) -> typing.Optional[discord.Member]: """ Attempt to get a member from cache; on failure fetch from the API. @@ -722,7 +775,9 @@ def check_guild_age(self, author: discord.Member) -> bool: logger.debug("Blocked due to guild age, user %s.", author.name) if str(author.id) not in self.blocked_users: - new_reason = f"System Message: Recently Joined. User can try again {delta}." + new_reason = ( + f"System Message: Recently Joined. User can try again {delta}." + ) self.blocked_users[str(author.id)] = new_reason return False @@ -735,7 +790,9 @@ def check_manual_blocked_roles(self, author: discord.Member) -> bool: blocked_reason = self.blocked_roles.get(str(r.id)) or "" try: - end_time, after = extract_block_timestamp(blocked_reason, author.id) + end_time, after = extract_block_timestamp( + blocked_reason, author.id + ) except ValueError: return False @@ -778,7 +835,9 @@ def check_manual_blocked(self, author: discord.Member) -> bool: async def _process_blocked(self, message): _, blocked_emoji = await self.retrieve_emoji() - if await self.is_blocked(message.author, channel=message.channel, send_message=True): + if await self.is_blocked( + message.author, channel=message.channel, send_message=True + ): await self.add_reaction(message, blocked_emoji) return True return False @@ -854,7 +913,10 @@ async def get_thread_cooldown(self, author: discord.Member): return try: - cooldown = datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + thread_cooldown + cooldown = ( + datetime.fromisoformat(last_log_closed_at).astimezone(timezone.utc) + + thread_cooldown + ) except ValueError: logger.warning("Error with 'thread_cooldown'.", exc_info=True) cooldown = datetime.fromisoformat(last_log_closed_at).astimezone( @@ -870,7 +932,10 @@ async def get_thread_cooldown(self, author: discord.Member): @staticmethod async def add_reaction( - msg, reaction: typing.Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str] + msg, + reaction: typing.Union[ + discord.Emoji, discord.Reaction, discord.PartialEmoji, str + ], ) -> bool: if reaction != "disable": try: @@ -880,6 +945,38 @@ async def add_reaction( return False return True + async def _queue_dm_message(self, message: discord.Message) -> None: + """Queue DM messages to ensure they're processed in order per user.""" + user_id = message.author.id + + if user_id not in self._message_queues: + self._message_queues[user_id] = asyncio.Queue() + # Start processing task for this user + self.loop.create_task(self._process_user_messages(user_id)) + + await self._message_queues[user_id].put(message) + + async def _process_user_messages(self, user_id: int) -> None: + """Process messages for a specific user in order.""" + queue = self._message_queues[user_id] + + while True: + try: + # Wait for a message with timeout to clean up inactive queues + message = await asyncio.wait_for(queue.get(), timeout=300) # 5 minutes + await self.process_dm_modmail(message) + queue.task_done() + except asyncio.TimeoutError: + # Clean up inactive queue + if queue.empty(): + self._message_queues.pop(user_id, None) + break + except Exception as e: + logger.error( + f"Error processing message for user {user_id}: {e}", exc_info=True + ) + queue.task_done() + async def process_dm_modmail(self, message: discord.Message) -> None: """Processes messages sent to the bot.""" blocked = await self._process_blocked(message) @@ -899,12 +996,17 @@ async def process_dm_modmail(self, message: discord.Message) -> None: await message.channel.send( embed=discord.Embed( title=self.config["cooldown_thread_title"], - description=self.config["cooldown_thread_response"].format(delta=delta), + description=self.config[ + "cooldown_thread_response" + ].format(delta=delta), color=self.error_color, ) ) return - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, @@ -915,7 +1017,8 @@ async def process_dm_modmail(self, message: discord.Message) -> None: icon_url=self.get_guild_icon(guild=message.guild, size=128), ) logger.info( - "A new thread was blocked from %s due to disabled Modmail.", message.author + "A new thread was blocked from %s due to disabled Modmail.", + message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -931,11 +1034,17 @@ async def process_dm_modmail(self, message: discord.Message) -> None: text=self.config["disabled_current_thread_footer"], icon_url=self.get_guild_icon(guild=message.guild, size=128), ) - logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + logger.info( + "A message was blocked from %s due to disabled Modmail.", + message.author, + ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) # Extract forwarded content using utility function - combined_content = extract_forwarded_content(message) or "[Forwarded message with no content]" + combined_content = ( + extract_forwarded_content(message) + or "[Forwarded message with no content]" + ) class ForwardedMessage: def __init__(self, original_message, forwarded_content): @@ -958,10 +1067,16 @@ def __init__(self, original_message, forwarded_content): else: message.content = "[Forwarded message with no content]" # 2. Single-message forward (MessageType.forward) - elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + elif getattr(message, "type", None) == getattr( + discord.MessageType, "forward", None + ): # Check for message.reference and its type ref = getattr(message, "reference", None) - if ref and getattr(ref, "type", None) == getattr(discord, "MessageReferenceType", None).forward: + if ( + ref + and getattr(ref, "type", None) + == getattr(discord, "MessageReferenceType", None).forward + ): # Try to fetch the referenced message ref_msg = None try: @@ -983,12 +1098,17 @@ def __init__(self, original_message, forwarded_content): await message.channel.send( embed=discord.Embed( title=self.config["cooldown_thread_title"], - description=self.config["cooldown_thread_response"].format(delta=delta), + description=self.config[ + "cooldown_thread_response" + ].format(delta=delta), color=self.error_color, ) ) return - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, @@ -996,27 +1116,37 @@ def __init__(self, original_message, forwarded_content): ) embed.set_footer( text=self.config["disabled_new_thread_footer"], - icon_url=self.get_guild_icon(guild=message.guild, size=128), + icon_url=self.get_guild_icon( + guild=message.guild, size=128 + ), ) logger.info( - "A new thread was blocked from %s due to disabled Modmail.", message.author + "A new thread was blocked from %s due to disabled Modmail.", + message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) - thread = await self.threads.create(message.author, message=message) + thread = await self.threads.create( + message.author, message=message + ) else: if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( title=self.config["disabled_current_thread_title"], color=self.error_color, - description=self.config["disabled_current_thread_response"], + description=self.config[ + "disabled_current_thread_response" + ], ) embed.set_footer( text=self.config["disabled_current_thread_footer"], - icon_url=self.get_guild_icon(guild=message.guild, size=128), + icon_url=self.get_guild_icon( + guild=message.guild, size=128 + ), ) logger.info( - "A message was blocked from %s due to disabled Modmail.", message.author + "A message was blocked from %s due to disabled Modmail.", + message.author, ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -1026,7 +1156,9 @@ class ForwardedMessage: def __init__(self, original_message, ref_message): self.author = original_message.author # Use the utility function to extract content or fallback to ref message content - extracted_content = extract_forwarded_content(original_message) + extracted_content = extract_forwarded_content( + original_message + ) self.content = ( extracted_content or ref_message.content @@ -1055,13 +1187,33 @@ def __init__(self, original_message, ref_message): if thread and thread.snoozed: await thread.restore_from_snooze() self.threads.cache[thread.id] = thread - # Update the DB with the new channel_id after restoration - if thread.channel: - await self.api.logs.update_one( - {"recipient.id": str(thread.id)}, {"$set": {"channel_id": str(thread.channel.id)}} + # No need to re-fetch the thread - it's already restored and cached properly + + # If the previous thread was closed with delete_channel=True the channel object + # stored on the thread will now be invalid (deleted). In some rare race cases + # the thread can still be returned from the cache (or reconstructed) while the + # channel lookup returns None, causing downstream relay attempts to raise + # discord.NotFound ("Channel not found when trying to send message."). Treat + # this situation as "no active thread" so the user's new DM starts a fresh + # thread instead of silently failing. + try: + if ( + thread + and thread.channel + and isinstance(thread.channel, discord.TextChannel) + and self.get_channel(getattr(thread.channel, "id", None)) is None + ): + logger.info( + "Stale thread detected for %s (channel deleted). Purging cache entry and creating new thread.", + message.author, ) - # Re-fetch the thread object to ensure channel is valid - thread = await self.threads.find(recipient=message.author) + # Best-effort removal; ignore if already gone. + self.threads.cache.pop(thread.id, None) + thread = None + except Exception: + # If any attribute access fails, fall back to treating it as closed. + self.threads.cache.pop(getattr(thread, "id", None), None) + thread = None if thread is None: delta = await self.get_thread_cooldown(message.author) @@ -1069,13 +1221,18 @@ def __init__(self, original_message, ref_message): await message.channel.send( embed=discord.Embed( title=self.config["cooldown_thread_title"], - description=self.config["cooldown_thread_response"].format(delta=delta), + description=self.config["cooldown_thread_response"].format( + delta=delta + ), color=self.error_color, ) ) return - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, @@ -1085,11 +1242,17 @@ def __init__(self, original_message, ref_message): text=self.config["disabled_new_thread_footer"], icon_url=self.get_guild_icon(guild=message.guild, size=128), ) - logger.info("A new thread was blocked from %s due to disabled Modmail.", message.author) + logger.info( + "A new thread was blocked from %s due to disabled Modmail.", + message.author, + ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) thread = await self.threads.create(message.author, message=message) + # If thread menu is enabled, thread creation is deferred until user selects an option. + if getattr(thread, "_pending_menu", False): + return else: if self.config["dm_disabled"] == DMDisabled.ALL_THREADS: embed = discord.Embed( @@ -1101,7 +1264,10 @@ def __init__(self, original_message, ref_message): text=self.config["disabled_current_thread_footer"], icon_url=self.get_guild_icon(guild=message.guild, size=128), ) - logger.info("A message was blocked from %s due to disabled Modmail.", message.author) + logger.info( + "A message was blocked from %s due to disabled Modmail.", + message.author, + ) await self.add_reaction(message, blocked_emoji) return await message.channel.send(embed=embed) @@ -1111,6 +1277,58 @@ def __init__(self, original_message, ref_message): except Exception: logger.error("Failed to send message:", exc_info=True) await self.add_reaction(message, blocked_emoji) + + try: + # Re-check channel existence + if ( + thread + and thread.channel + and isinstance(thread.channel, discord.TextChannel) + ): + if self.get_channel(thread.channel.id) is None: + logger.info( + "Relay failed due to deleted channel for %s; creating new thread.", + message.author, + ) + self.threads.cache.pop(thread.id, None) + new_thread = await self.threads.create( + message.author, message=message + ) + if ( + not getattr(new_thread, "_pending_menu", False) + and not new_thread.cancelled + ): + try: + await new_thread.send(message) + except Exception: + logger.error( + "Failed to relay message after creating new thread:", + exc_info=True, + ) + else: + for user in new_thread.recipients: + if user != message.author: + try: + await new_thread.send(message, user) + except Exception: + logger.error( + "Failed to send message to additional recipient:", + exc_info=True, + ) + await self.add_reaction(message, sent_emoji) + self.dispatch( + "thread_reply", + new_thread, + False, + message, + False, + False, + ) + except Exception: + logger.warning( + "Unexpected failure in DM relay/new-thread follow-up block.", + exc_info=True, + ) else: for user in thread.recipients: # send to all other recipients @@ -1168,7 +1386,9 @@ async def get_contexts(self, message, *, cls=commands.Context): alias = self.aliases.get(invoker) if alias is not None and snippet_text is None: ctxs = [] - aliases = normalize_alias(alias, message.content[len(f"{invoked_prefix}{invoker}") :]) + aliases = normalize_alias( + alias, message.content[len(f"{invoked_prefix}{invoker}") :] + ) if not aliases: logger.warning("Alias %s is invalid, removing.", invoker) self.aliases.pop(invoker) @@ -1181,7 +1401,9 @@ async def get_contexts(self, message, *, cls=commands.Context): command_invocation_text = alias else: command = self._get_snippet_command() - command_invocation_text = f"{invoked_prefix}{command} {snippet_text}" + command_invocation_text = ( + f"{invoked_prefix}{command} {snippet_text}" + ) view = StringView(invoked_prefix + command_invocation_text) ctx_ = cls(prefix=self.prefix, view=view, bot=self, message=message) ctx_.thread = thread @@ -1219,11 +1441,20 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) invoker = None if self.config.get("use_regex_autotrigger"): - trigger = next(filter(lambda x: re.search(x, message.content), self.auto_triggers.keys())) + trigger = next( + filter( + lambda x: re.search(x, message.content), self.auto_triggers.keys() + ) + ) if trigger: invoker = re.search(trigger, message.content).group(0) else: - trigger = next(filter(lambda x: x.lower() in message.content.lower(), self.auto_triggers.keys())) + trigger = next( + filter( + lambda x: x.lower() in message.content.lower(), + self.auto_triggers.keys(), + ) + ) if trigger: invoker = trigger.lower() @@ -1243,7 +1474,9 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) message.content = invoked_prefix + alias ctxs += await self.get_contexts(message) - message.author = self.modmail_guild.me # Fix message so commands execute properly + message.author = ( + self.modmail_guild.me + ) # Fix message so commands execute properly for ctx in ctxs: if ctx.command: @@ -1318,7 +1551,10 @@ async def on_message(self, message): await message.delete() if ( - (f"<@{self.user.id}" in message.content or f"<@!{self.user.id}" in message.content) + ( + f"<@{self.user.id}" in message.content + or f"<@!{self.user.id}" in message.content + ) and self.config["alert_on_mention"] and not message.author.bot ): @@ -1338,7 +1574,9 @@ async def on_message(self, message): # --- MODERATOR-ONLY MESSAGE LOGGING --- # If a moderator sends a message directly in a thread channel (not via modmail command), log it - if not message.author.bot and not isinstance(message.channel, discord.DMChannel): + if not message.author.bot and not isinstance( + message.channel, discord.DMChannel + ): thread = await self.threads.find(channel=message.channel) if thread is not None: ctxs = await self.get_contexts(message) @@ -1356,23 +1594,60 @@ async def process_commands(self, message): return if isinstance(message.channel, discord.DMChannel): - return await self.process_dm_modmail(message) + return await self._queue_dm_message(message) ctxs = await self.get_contexts(message) for ctx in ctxs: if ctx.command: - if not any(1 for check in ctx.command.checks if hasattr(check, "permission_level")): + if not any( + 1 + for check in ctx.command.checks + if hasattr(check, "permission_level") + ): logger.debug( "Command %s has no permissions check, adding invalid level.", ctx.command.qualified_name, ) checks.has_permissions(PermissionLevel.INVALID)(ctx.command) + # Check if thread is unsnoozing and queue command if so + thread = await self.threads.find(channel=ctx.channel) + if thread and thread._unsnoozing: + queued = await thread.queue_command(ctx, ctx.command) + if queued: + # Send a brief acknowledgment that command is queued + try: + await ctx.message.add_reaction("⏳") + except Exception as e: + logger.warning("Failed to add queued-reaction: %s", e) + continue + await self.invoke(ctx) continue thread = await self.threads.find(channel=ctx.channel) if thread is not None: + # If thread is snoozed (moved), auto-unsnooze when a mod sends a message directly in channel + behavior = self.config.get("snooze_behavior", "delete").lower() + if thread.snoozed and behavior == "move": + if not thread.snooze_data: + try: + log_entry = await self.api.logs.find_one( + {"recipient.id": str(thread.id), "snoozed": True} + ) + if log_entry: + thread.snooze_data = log_entry.get("snooze_data") + except Exception: + logger.debug( + "Failed to add queued command reaction (⏳).", + exc_info=True, + ) + try: + await thread.restore_from_snooze() + # refresh local cache + self.threads.cache[thread.id] = thread + except Exception as e: + logger.warning("Auto-unsnooze on direct message failed: %s", e) anonymous = False plain = False if self.config.get("anon_reply_without_command"): @@ -1387,7 +1662,9 @@ async def process_commands(self, message): ): await thread.reply(message, anonymous=anonymous, plain=plain) elif ctx.invoked_with: - exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) + exc = commands.CommandNotFound( + 'Command "{}" is not found'.format(ctx.invoked_with) + ) self.dispatch("command_error", ctx, exc) async def on_typing(self, channel, user, _): @@ -1406,7 +1683,10 @@ async def on_typing(self, channel, user, _): try: await thread.channel.typing() except Exception: - pass + logger.debug( + "Failed to trigger typing indicator in recipient DM.", + exc_info=True, + ) else: if not self.config.get("mod_typing"): return @@ -1419,7 +1699,11 @@ async def on_typing(self, channel, user, _): try: await user.typing() except Exception: - pass + logger.debug( + "Failed to trigger typing for recipient %s.", + getattr(user, "id", "?"), + exc_info=True, + ) async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) @@ -1472,14 +1756,18 @@ async def handle_reaction_events(self, payload): message.author == self.user and message.embeds and self.config.get("confirm_thread_creation") - and message.embeds[0].title == self.config["confirm_thread_creation_title"] - and message.embeds[0].description == self.config["confirm_thread_response"] + and message.embeds[0].title + == self.config["confirm_thread_creation_title"] + and message.embeds[0].description + == self.config["confirm_thread_response"] ): return if not thread.recipient.dm_channel: await thread.recipient.create_dm() try: - linked_messages = await thread.find_linked_message_from_dm(message, either_direction=True) + linked_messages = await thread.find_linked_message_from_dm( + message, either_direction=True + ) except ValueError as e: logger.warning("Failed to find linked message for reactions: %s", e) return @@ -1492,7 +1780,7 @@ async def handle_reaction_events(self, payload): logger.warning("Failed to find linked message for reactions: %s", e) return - if self.config["transfer_reactions"] and linked_messages is not [None]: + if self.config["transfer_reactions"] and linked_messages != [None]: if payload.event_type == "REACTION_ADD": for msg in linked_messages: await self.add_reaction(msg, reaction) @@ -1508,7 +1796,10 @@ async def handle_reaction_events(self, payload): async def handle_react_to_contact(self, payload): react_message_id = tryint(self.config.get("react_to_contact_message")) react_message_emoji = self.config.get("react_to_contact_emoji") - if not all((react_message_id, react_message_emoji)) or payload.message_id != react_message_id: + if ( + not all((react_message_id, react_message_emoji)) + or payload.message_id != react_message_id + ): return if payload.emoji.is_unicode_emoji(): emoji_fmt = payload.emoji.name @@ -1525,7 +1816,10 @@ async def handle_react_to_contact(self, payload): await message.remove_reaction(payload.emoji, member) await message.add_reaction(emoji_fmt) # bot adds as well - if self.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): embed = discord.Embed( title=self.config["disabled_new_thread_title"], color=self.error_color, @@ -1541,8 +1835,23 @@ async def handle_react_to_contact(self, payload): ) return await member.send(embed=embed) + # Check if user has a snoozed thread + existing_thread = await self.threads.find(recipient=member) + if existing_thread and existing_thread.snoozed: + # Unsnooze the thread + await existing_thread.restore_from_snooze() + self.threads.cache[existing_thread.id] = existing_thread + # Send notification to the thread channel + if existing_thread.channel: + await existing_thread.channel.send( + f"ℹ️ {member.mention} reacted to contact and their snoozed thread has been unsnoozed." + ) + return + ctx = await self.get_context(message) - await ctx.invoke(self.get_command("contact"), users=[member], manual_trigger=False) + await ctx.invoke( + self.get_command("contact"), users=[member], manual_trigger=False + ) async def on_raw_reaction_add(self, payload): await asyncio.gather( @@ -1574,7 +1883,9 @@ async def on_guild_channel_delete(self, channel): await self.config.update() return - audit_logs = self.modmail_guild.audit_logs(limit=10, action=discord.AuditLogAction.channel_delete) + audit_logs = self.modmail_guild.audit_logs( + limit=10, action=discord.AuditLogAction.channel_delete + ) found_entry = False async for entry in audit_logs: if int(entry.target.id) == channel.id: @@ -1582,7 +1893,9 @@ async def on_guild_channel_delete(self, channel): break if not found_entry: - logger.debug("Cannot find the audit log entry for channel delete of %d.", channel.id) + logger.debug( + "Cannot find the audit log entry for channel delete of %d.", channel.id + ) return mod = entry.user @@ -1609,15 +1922,15 @@ async def on_member_remove(self, member): remaining_guilds = member.mutual_guilds if remaining_guilds: - remaining_guild_names = [guild.name for guild in remaining_guilds] + remaining_guild_names = [ + guild.name for guild in remaining_guilds + ] leave_message = ( f"The recipient has left {guild_left}. " f"They are still in {human_join(remaining_guild_names, final='and')}." ) else: - leave_message = ( - f"The recipient has left {guild_left}. We no longer share any mutual servers." - ) + leave_message = f"The recipient has left {guild_left}. We no longer share any mutual servers." else: leave_message = "The recipient has left the server." @@ -1648,7 +1961,9 @@ async def on_message_delete(self, message): if not thread: return try: - message = await thread.find_linked_message_from_dm(message, get_thread_channel=True) + message = await thread.find_linked_message_from_dm( + message, get_thread_channel=True + ) except ValueError as e: if str(e) != "Thread channel message not found.": logger.debug("Failed to find linked message to delete: %s", e) @@ -1674,11 +1989,20 @@ async def on_message_delete(self, message): try: await thread.delete_message(message, note=False) - embed = discord.Embed(description="Successfully deleted message.", color=self.main_color) + embed = discord.Embed( + description="Successfully deleted message.", color=self.main_color + ) except ValueError as e: - if str(e) not in {"DM message not found.", "Malformed thread message."}: + # Treat common non-fatal cases as benign: relay counterpart not present, note embeds, etc. + if str(e) not in { + "DM message not found.", + "Malformed thread message.", + "Thread message not found.", + }: logger.debug("Failed to find linked message to delete: %s", e) - embed = discord.Embed(description="Failed to delete message.", color=self.error_color) + embed = discord.Embed( + description="Failed to delete message.", color=self.error_color + ) else: return except discord.NotFound: @@ -1706,7 +2030,9 @@ async def on_message_edit(self, before, after): _, blocked_emoji = await self.retrieve_emoji() await self.add_reaction(after, blocked_emoji) else: - embed = discord.Embed(description="Successfully Edited Message", color=self.main_color) + embed = discord.Embed( + description="Successfully Edited Message", color=self.main_color + ) embed.set_footer(text=f"Message ID: {after.id}") await after.channel.send(embed=embed) @@ -1715,7 +2041,11 @@ async def on_error(self, event_method, *args, **kwargs): logger.error("Unexpected exception:", exc_info=sys.exc_info()) async def on_command_error( - self, context: commands.Context, exception: Exception, *, unhandled_by_cog: bool = False + self, + context: commands.Context, + exception: Exception, + *, + unhandled_by_cog: bool = False, ) -> None: if not unhandled_by_cog: command = context.command @@ -1729,8 +2059,13 @@ async def on_command_error( try: await context.typing() except Exception: - pass - await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) + logger.debug( + "Failed to start typing context for command error feedback.", + exc_info=True, + ) + await context.send( + embed=discord.Embed(color=self.error_color, description=str(exception)) + ) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) elif isinstance(exception, commands.MissingRequiredArgument): @@ -1748,10 +2083,14 @@ async def on_command_error( if not await check(context): if hasattr(check, "fail_msg"): await context.send( - embed=discord.Embed(color=self.error_color, description=check.fail_msg) + embed=discord.Embed( + color=self.error_color, description=check.fail_msg + ) ) if hasattr(check, "permission_level"): - corrected_permission_level = self.command_perm(context.command.qualified_name) + corrected_permission_level = self.command_perm( + context.command.qualified_name + ) logger.warning( "User %s does not have permission to use this command: `%s` (%s).", context.author.name, @@ -1760,7 +2099,10 @@ async def on_command_error( ) logger.warning("CheckFailure: %s", exception) elif isinstance(exception, commands.DisabledCommand): - logger.info("DisabledCommand: %s is trying to run eval but it's disabled", context.author.name) + logger.info( + "DisabledCommand: %s is trying to run eval but it's disabled", + context.author.name, + ) else: logger.error("Unexpected exception:", exc_info=exception) @@ -1786,13 +2128,21 @@ async def post_metadata(self): if info.team is not None: data.update( { - "owner_name": info.team.owner.name if info.team.owner is not None else "No Owner", + "owner_name": info.team.owner.name + if info.team.owner is not None + else "No Owner", "owner_id": info.team.owner_id, "team": True, } ) else: - data.update({"owner_name": info.owner.name, "owner_id": info.owner.id, "team": False}) + data.update( + { + "owner_name": info.owner.name, + "owner_id": info.owner.id, + "team": False, + } + ) async with self.session.post("https://api.modmail.dev/metadata", json=data): logger.debug("Uploading metadata to Modmail server.") @@ -1845,7 +2195,7 @@ async def autoupdate(self): user = data["user"] embed.add_field( name="Merge Commit", - value=f"[`{short_sha}`]({html_url}) " f"{message} - {user['username']}", + value=f"[`{short_sha}`]({html_url}) {message} - {user['username']}", ) embed.set_author( name=user["username"] + " - Updating Bot", @@ -1853,7 +2203,9 @@ async def autoupdate(self): url=user["url"], ) - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + embed.set_footer( + text=f"Updating Modmail v{self.version} -> v{latest.version}" + ) embed.description = latest.description for name, value in latest.fields.items(): @@ -1892,10 +2244,16 @@ async def autoupdate(self): logger.info("Bot has been updated.") channel = self.update_channel - if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): - embed = discord.Embed(title="Bot has been updated", color=self.main_color) + if self.hosting_method in ( + HostingMethod.PM2, + HostingMethod.SYSTEMD, + ): + embed = discord.Embed( + title="Bot has been updated", color=self.main_color + ) embed.set_footer( - text=f"Updating Modmail v{self.version} " f"-> v{latest.version} {message}" + text=f"Updating Modmail v{self.version} " + f"-> v{latest.version} {message}" ) if self.config["update_notifications"]: await channel.send(embed=embed) @@ -1905,7 +2263,9 @@ async def autoupdate(self): description=f"If you do not have an auto-restart setup, please manually start the bot. {message}", color=self.main_color, ) - embed.set_footer(text=f"Updating Modmail v{self.version} -> v{latest.version}") + embed.set_footer( + text=f"Updating Modmail v{self.version} -> v{latest.version}" + ) if self.config["update_notifications"]: await channel.send(embed=embed) return await self.close() @@ -1925,7 +2285,10 @@ async def before_autoupdate(self): self.autoupdate.cancel() return - if not self.config.get("github_token") and self.hosting_method == HostingMethod.HEROKU: + if ( + not self.config.get("github_token") + and self.hosting_method == HostingMethod.HEROKU + ): logger.warning("GitHub access token not found.") logger.warning("Autoupdates disabled.") self.autoupdate.cancel() @@ -1941,7 +2304,9 @@ async def log_expiry(self): expiration_datetime = now - log_expire_after # WARNING: comparison is done lexicographically, not by date. # This is fine as long as the date is in zero-padded ISO format, which it should be. - expired_logs = await self.db.logs.delete_many({"closed_at": {"$lte": str(expiration_datetime)}}) + expired_logs = await self.db.logs.delete_many( + {"closed_at": {"$lte": str(expiration_datetime)}} + ) logger.info(f"Deleted {expired_logs.deleted_count} expired logs.") @@ -1961,7 +2326,9 @@ def format_channel_name(self, author, exclude_channel=None, force_null=False): elif self.config["use_user_id_channel_name"]: name = new_name = str(author.id) elif self.config["use_timestamp_channel_name"]: - name = new_name = author.created_at.isoformat(sep="-", timespec="minutes") + name = new_name = author.created_at.isoformat( + sep="-", timespec="minutes" + ) else: if self.config["use_nickname_channel_name"]: author_member = self.guild.get_member(author.id) @@ -1972,7 +2339,14 @@ def format_channel_name(self, author, exclude_channel=None, force_null=False): if force_null: name = "null" - name = "".join(l for l in name if l not in string.punctuation and l.isprintable()) or "null" + name = ( + "".join( + l + for l in name + if l not in string.punctuation and l.isprintable() + ) + or "null" + ) if author.discriminator != "0": name += f"-{author.discriminator}" new_name = name @@ -2009,7 +2383,10 @@ def main(): "Unable to import cairosvg, install GTK Installer for Windows and restart your system (https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/latest)" ) else: - if "ubuntu" in platform.version().lower() or "debian" in platform.version().lower(): + if ( + "ubuntu" in platform.version().lower() + or "debian" in platform.version().lower() + ): logger.error( "Unable to import cairosvg, try running `sudo apt-get install libpangocairo-1.0-0` or report on our support server with your OS details: https://discord.gg/etJNHCQ" ) diff --git a/cogs/modmail.py b/cogs/modmail.py index a63eea9103..0b53b2937a 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -7,6 +7,7 @@ import discord from discord.ext import commands +from discord.ext import tasks from discord.ext.commands.view import StringView from discord.ext.commands.cooldowns import BucketType from discord.role import Role @@ -29,6 +30,65 @@ class Modmail(commands.Cog): def __init__(self, bot): self.bot = bot + self._snoozed_cache = [] + self._auto_unsnooze_task = self.bot.loop.create_task(self.auto_unsnooze_task()) + + async def auto_unsnooze_task(self): + await self.bot.wait_until_ready() + last_db_query = 0 + while not self.bot.is_closed(): + now = datetime.now(timezone.utc) + try: + # Query DB every 2 minutes + if (now.timestamp() - last_db_query) > 120: + snoozed_threads = await self.bot.api.logs.find( + {"snooze_until": {"$gte": now.isoformat()}} + ).to_list(None) + self._snoozed_cache = snoozed_threads or [] + last_db_query = now.timestamp() + # Check cache every 10 seconds + to_unsnooze = [] + for thread_data in list(self._snoozed_cache): + snooze_until = thread_data.get("snooze_until") + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + if snooze_until: + try: + dt = parser.isoparse(snooze_until) + except Exception: + continue + if now >= dt: + to_unsnooze.append(thread_data) + for thread_data in to_unsnooze: + recipient = thread_data.get("recipient") + if not recipient or not recipient.get("id"): + continue + thread_id = int(recipient.get("id")) + thread = self.bot.threads.cache.get( + thread_id + ) or await self.bot.threads.find(id=thread_id) + if thread and thread.snoozed: + await thread.restore_from_snooze() + logging.info( + f"[AUTO-UNSNOOZE] Thread {thread_id} auto-unsnoozed." + ) + try: + channel = thread.channel + if channel: + await channel.send( + "⏰ This thread has been automatically unsnoozed." + ) + except Exception as e: + logger.info( + "Failed to notify channel after auto-unsnooze: %s", + e, + ) + self._snoozed_cache.remove(thread_data) + except Exception as e: + logging.error(f"Error in auto_unsnooze_task: {e}") + await asyncio.sleep(10) def _resolve_user(self, user_str): """Helper to resolve a user from mention, ID, or username.""" @@ -55,7 +115,9 @@ async def setup(self, ctx): """ if ctx.guild != self.bot.modmail_guild: - return await ctx.send(f"You can only setup in the Modmail guild: {self.bot.modmail_guild}.") + return await ctx.send( + f"You can only setup in the Modmail guild: {self.bot.modmail_guild}." + ) if self.bot.main_category is not None: logger.debug("Can't re-setup server, main_category is found.") @@ -70,7 +132,9 @@ async def setup(self, ctx): return await ctx.send(embed=embed) overwrites = { - self.bot.modmail_guild.default_role: discord.PermissionOverwrite(read_messages=False), + self.bot.modmail_guild.default_role: discord.PermissionOverwrite( + read_messages=False + ), self.bot.modmail_guild.me: discord.PermissionOverwrite(read_messages=True), } @@ -90,11 +154,15 @@ async def setup(self, ctx): logger.info("Granting %s access to Modmail category.", key.name) overwrites[key] = discord.PermissionOverwrite(read_messages=True) - category = await self.bot.modmail_guild.create_category(name="Modmail", overwrites=overwrites) + category = await self.bot.modmail_guild.create_category( + name="Modmail", overwrites=overwrites + ) await category.edit(position=0) - log_channel = await self.bot.modmail_guild.create_text_channel(name="bot-logs", category=category) + log_channel = await self.bot.modmail_guild.create_text_channel( + name="bot-logs", category=category + ) embed = discord.Embed( title="Friendly Reminder", @@ -111,7 +179,9 @@ async def setup(self, ctx): "feeling extra generous, buy us coffee on [Patreon](https://patreon.com/kyber) :heart:!", ) - embed.set_footer(text=f'Type "{self.bot.prefix}help" for a complete list of commands.') + embed.set_footer( + text=f'Type "{self.bot.prefix}help" for a complete list of commands.' + ) await log_channel.send(embed=embed) self.bot.config["main_category_id"] = category.id @@ -127,7 +197,10 @@ async def setup(self, ctx): f"- `{self.bot.prefix}config help` for a list of available customizations." ) - if not self.bot.config["command_permissions"] and not self.bot.config["level_permissions"]: + if ( + not self.bot.config["command_permissions"] + and not self.bot.config["level_permissions"] + ): await self.bot.update_perms(PermissionLevel.REGULAR, -1) for owner_id in self.bot.bot_owner_ids: await self.bot.update_perms(PermissionLevel.OWNER, owner_id) @@ -160,11 +233,16 @@ async def snippet(self, ctx, *, name: str.lower = None): if name == "compact": embeds = [] - for i, names in enumerate(zip_longest(*(iter(sorted(self.bot.snippets)),) * 15)): + for i, names in enumerate( + zip_longest(*(iter(sorted(self.bot.snippets)),) * 15) + ): description = format_description(i, names) - embed = discord.Embed(color=self.bot.main_color, description=description) + embed = discord.Embed( + color=self.bot.main_color, description=description + ) embed.set_author( - name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128) + name="Snippets", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), ) embeds.append(embed) @@ -175,25 +253,41 @@ async def snippet(self, ctx, *, name: str.lower = None): snippet_name = self.bot._resolve_snippet(name) if snippet_name is None: - embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + embed = create_not_found_embed( + name, self.bot.snippets.keys(), "Snippet" + ) else: val = self.bot.snippets[snippet_name] embed = discord.Embed( - title=f'Snippet - "{snippet_name}":', description=val, color=self.bot.main_color + title=f'Snippet - "{snippet_name}":', + description=val, + color=self.bot.main_color, ) return await ctx.send(embed=embed) if not self.bot.snippets: embed = discord.Embed( - color=self.bot.error_color, description="You dont have any snippets at the moment." + color=self.bot.error_color, + description="You dont have any snippets at the moment.", + ) + embed.set_footer( + text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.' + ) + embed.set_author( + name="Snippets", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), ) - embed.set_footer(text=f'Check "{self.bot.prefix}help snippet add" to add a snippet.') - embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) return await ctx.send(embed=embed) - embeds = [discord.Embed(color=self.bot.main_color) for _ in range((len(self.bot.snippets) // 10) + 1)] + embeds = [ + discord.Embed(color=self.bot.main_color) + for _ in range((len(self.bot.snippets) // 10) + 1) + ] for embed in embeds: - embed.set_author(name="Snippets", icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128)) + embed.set_author( + name="Snippets", + icon_url=self.bot.get_guild_icon(guild=ctx.guild, size=128), + ) for i, snippet in enumerate(sorted(self.bot.snippets.items())): embeds[i // 10].add_field( @@ -337,11 +431,11 @@ async def snippet_remove(self, ctx, *, name: str.lower): deleted_aliases_string = ",".join(f"`{alias}`" for alias in deleted_aliases) if len(deleted_aliases) == 1: - deleted_aliases_output = f"The `{deleted_aliases_string}` direct alias has been removed." - elif deleted_aliases: deleted_aliases_output = ( - f"The following direct aliases have been removed: {deleted_aliases_string}." + f"The `{deleted_aliases_string}` direct alias has been removed." ) + elif deleted_aliases: + deleted_aliases_output = f"The following direct aliases have been removed: {deleted_aliases_string}." else: deleted_aliases_output = None @@ -448,7 +542,10 @@ async def move(self, ctx, *, arguments): silent = any(word in silent_words for word in options.split()) await thread.channel.move( - category=category, end=True, sync_permissions=True, reason=f"{ctx.author} moved this thread." + category=category, + end=True, + sync_permissions=True, + reason=f"{ctx.author} moved this thread.", ) if self.bot.config["thread_move_notify"] and not silent: @@ -471,21 +568,28 @@ async def move(self, ctx, *, arguments): await self.bot.add_reaction(ctx.message, sent_emoji) async def send_scheduled_close_message(self, ctx, after, silent=False): - human_delta = human_timedelta(after.dt) + """Send a scheduled close notice only to the staff thread channel. + Uses Discord relative timestamp formatting for better UX. + """ + ts = int( + ( + after.dt if after.dt.tzinfo else after.dt.replace(tzinfo=timezone.utc) + ).timestamp() + ) embed = discord.Embed( title="Scheduled close", - description=f"This thread will{' silently' if silent else ''} close in {human_delta}.", + description=f"This thread will{' silently' if silent else ''} close .", color=self.bot.error_color, ) - if after.arg and not silent: embed.add_field(name="Message", value=after.arg) - embed.set_footer(text="Closing will be cancelled if a thread message is sent.") embed.timestamp = after.dt - await ctx.send(embed=embed) + thread = getattr(ctx, "thread", None) + if thread and ctx.channel == thread.channel: + await thread.channel.send(embed=embed) @commands.command(usage="[after] [close message]") @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -526,7 +630,8 @@ async def close( if thread.close_task is not None or thread.auto_close_task is not None: await thread.cancel_closure(all=True) embed = discord.Embed( - color=self.bot.error_color, description="Scheduled close has been cancelled." + color=self.bot.error_color, + description="Scheduled close has been cancelled.", ) else: embed = discord.Embed( @@ -543,7 +648,9 @@ async def close( if after and after.dt > after.now: await self.send_scheduled_close_message(ctx, after, silent) - await thread.close(closer=ctx.author, after=close_after, message=message, silent=silent) + await thread.close( + closer=ctx.author, after=close_after, message=message, silent=silent + ) @staticmethod def parse_user_or_role(ctx, user_or_role): @@ -559,7 +666,9 @@ def parse_user_or_role(ctx, user_or_role): @commands.command(aliases=["alert"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def notify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + async def notify( + self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None + ): """ Notify a user or role when the next thread message received. @@ -597,7 +706,9 @@ async def notify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower @commands.command(aliases=["unalert"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + async def unnotify( + self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None + ): """ Un-notify a user, role, or yourself from a thread. @@ -625,14 +736,17 @@ async def unnotify(self, ctx, *, user_or_role: Union[discord.Role, User, str.low mentions.remove(mention) await self.bot.config.update() embed = discord.Embed( - color=self.bot.main_color, description=f"{mention} will no longer be notified." + color=self.bot.main_color, + description=f"{mention} will no longer be notified.", ) return await ctx.send(embed=embed) @commands.command(aliases=["sub"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def subscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + async def subscribe( + self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None + ): """ Notify a user, role, or yourself for every thread message received. @@ -670,7 +784,9 @@ async def subscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lo @commands.command(aliases=["unsub"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() - async def unsubscribe(self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None): + async def unsubscribe( + self, ctx, *, user_or_role: Union[discord.Role, User, str.lower, None] = None + ): """ Unsubscribe a user, role, or yourself from a thread. @@ -736,10 +852,13 @@ async def msglink(self, ctx, message_id: int): continue if not found: embed = discord.Embed( - color=self.bot.error_color, description="Message not found or no longer exists." + color=self.bot.error_color, + description="Message not found or no longer exists.", ) else: - embed = discord.Embed(color=self.bot.main_color, description=message.jump_url) + embed = discord.Embed( + color=self.bot.main_color, description=message.jump_url + ) await ctx.send(embed=embed) @commands.command() @@ -748,7 +867,9 @@ async def msglink(self, ctx, message_id: int): async def loglink(self, ctx): """Retrieves the link to the current thread's logs.""" log_link = await self.bot.api.get_log_link(ctx.channel.id) - await ctx.send(embed=discord.Embed(color=self.bot.main_color, description=log_link)) + await ctx.send( + embed=discord.Embed(color=self.bot.main_color, description=log_link) + ) def format_log_embeds(self, logs, avatar_url): embeds = [] @@ -761,16 +882,16 @@ def format_log_embeds(self, logs, avatar_url): prefix = self.bot.config["log_url_prefix"].strip("/") if prefix == "NONE": prefix = "" - log_url = ( - f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" - ) + log_url = f"{self.bot.config['log_url'].strip('/')}{'/' + prefix if prefix else ''}/{entry['key']}" username = entry["recipient"]["name"] if entry["recipient"]["discriminator"] != "0": username += "#" + entry["recipient"]["discriminator"] embed = discord.Embed(color=self.bot.main_color, timestamp=created_at) - embed.set_author(name=f"{title} - {username}", icon_url=avatar_url, url=log_url) + embed.set_author( + name=f"{title} - {username}", icon_url=avatar_url, url=log_url + ) embed.url = log_url embed.add_field(name="Created", value=human_timedelta(created_at)) closer = entry.get("closer") @@ -786,7 +907,9 @@ def format_log_embeds(self, logs, avatar_url): if entry.get("title"): embed.add_field(name="Title", value=entry["title"], inline=False) - embed.add_field(name="Preview", value=format_preview(entry["messages"]), inline=False) + embed.add_field( + name="Preview", value=format_preview(entry["messages"]), inline=False + ) if closer is not None: # BUG: Currently, logviewer can't display logs without a closer. @@ -810,7 +933,9 @@ async def title(self, ctx, *, name: str): await ctx.message.pin() await self.bot.add_reaction(ctx.message, sent_emoji) - @commands.command(usage=" [options]", cooldown_after_parsing=True) + @commands.command( + usage=" [options]", cooldown_after_parsing=True + ) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @commands.cooldown(1, 600, BucketType.channel) @@ -881,7 +1006,9 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str em.timestamp = discord.utils.utcnow() em.set_footer( text=str(ctx.author), - icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + icon_url=ctx.author.display_avatar.url + if ctx.author.display_avatar + else None, ) for u in users: to_exec.append(u.send(embed=em)) @@ -899,7 +1026,10 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() em.set_footer( - text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + text=f"{users[0]}", + icon_url=users[0].display_avatar.url + if users[0].display_avatar + else None, ) for i in ctx.thread.recipients: @@ -913,11 +1043,15 @@ async def adduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - @commands.command(usage=" [options]", cooldown_after_parsing=True) + @commands.command( + usage=" [options]", cooldown_after_parsing=True + ) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @commands.cooldown(1, 600, BucketType.channel) - async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + async def removeuser( + self, ctx, *users_arg: Union[discord.Member, discord.Role, str] + ): """Removes a user from a modmail thread `options` can be `silent` or `silently`. @@ -968,7 +1102,8 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, to_exec = [] if not silent: description = self.bot.formatter.format( - self.bot.config["private_removed_from_group_response"], moderator=ctx.author + self.bot.config["private_removed_from_group_response"], + moderator=ctx.author, ) em = discord.Embed( title=self.bot.config["private_removed_from_group_title"], @@ -979,7 +1114,9 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, em.timestamp = discord.utils.utcnow() em.set_footer( text=str(ctx.author), - icon_url=ctx.author.display_avatar.url if ctx.author.display_avatar else None, + icon_url=ctx.author.display_avatar.url + if ctx.author.display_avatar + else None, ) for u in users: to_exec.append(u.send(embed=em)) @@ -997,7 +1134,10 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() em.set_footer( - text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + text=f"{users[0]}", + icon_url=users[0].display_avatar.url + if users[0].display_avatar + else None, ) for i in ctx.thread.recipients: @@ -1011,11 +1151,15 @@ async def removeuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - @commands.command(usage=" [options]", cooldown_after_parsing=True) + @commands.command( + usage=" [options]", cooldown_after_parsing=True + ) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @commands.cooldown(1, 600, BucketType.channel) - async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + async def anonadduser( + self, ctx, *users_arg: Union[discord.Member, discord.Role, str] + ): """Adds a user to a modmail thread anonymously `options` can be `silent` or `silently`. @@ -1069,10 +1213,12 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + tag = str( + get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) + ) name = self.bot.config["anon_username"] if name is None: - name = tag + name = "Anonymous" avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) @@ -1093,7 +1239,10 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() em.set_footer( - text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + text=f"{users[0]}", + icon_url=users[0].display_avatar.url + if users[0].display_avatar + else None, ) for i in ctx.thread.recipients: @@ -1107,11 +1256,15 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - @commands.command(usage=" [options]", cooldown_after_parsing=True) + @commands.command( + usage=" [options]", cooldown_after_parsing=True + ) @checks.has_permissions(PermissionLevel.SUPPORTER) @checks.thread_only() @commands.cooldown(1, 600, BucketType.channel) - async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Role, str]): + async def anonremoveuser( + self, ctx, *users_arg: Union[discord.Member, discord.Role, str] + ): """Removes a user from a modmail thread anonymously `options` can be `silent` or `silently`. @@ -1152,7 +1305,9 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro if not silent: em = discord.Embed( title=self.bot.config["private_removed_from_group_title"], - description=self.bot.config["private_removed_from_group_description_anon"], + description=self.bot.config[ + "private_removed_from_group_description_anon" + ], color=self.bot.main_color, ) if self.bot.config["show_timestamp"]: @@ -1160,10 +1315,12 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"])) + tag = str( + get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) + ) name = self.bot.config["anon_username"] if name is None: - name = tag + name = "Anonymous" avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=ctx.guild, size=128) @@ -1184,7 +1341,10 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() em.set_footer( - text=f"{users[0]}", icon_url=users[0].display_avatar.url if users[0].display_avatar else None + text=f"{users[0]}", + icon_url=users[0].display_avatar.url + if users[0].display_avatar + else None, ) for i in ctx.thread.recipients: @@ -1250,7 +1410,9 @@ async def logs_closed_by(self, ctx, *, user: User = None): user = user if user is not None else ctx.author entries = await self.bot.api.search_closed_by(user.id) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) + embeds = self.format_log_embeds( + entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild) + ) if not embeds: embed = discord.Embed( @@ -1322,7 +1484,9 @@ async def logs_responded(self, ctx, *, user: User = None): entries = await self.bot.api.get_responded_logs(user.id) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) + embeds = self.format_log_embeds( + entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild) + ) if not embeds: embed = discord.Embed( @@ -1348,7 +1512,9 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): entries = await self.bot.api.search_by_text(query, limit) - embeds = self.format_log_embeds(entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild)) + embeds = self.format_log_embeds( + entries, avatar_url=self.bot.get_guild_icon(guild=ctx.guild) + ) if not embeds: embed = discord.Embed( @@ -1392,7 +1558,10 @@ async def freply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) ctx.message.content = msg async with safe_typing(ctx): @@ -1414,7 +1583,10 @@ async def fareply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) ctx.message.content = msg async with safe_typing(ctx): @@ -1436,7 +1608,10 @@ async def fpreply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) ctx.message.content = msg async with safe_typing(ctx): @@ -1458,7 +1633,10 @@ async def fpareply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ msg = self.bot.formatter.format( - msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author + msg, + channel=ctx.channel, + recipient=ctx.thread.recipient, + author=ctx.message.author, ) ctx.message.content = msg async with safe_typing(ctx): @@ -1522,6 +1700,13 @@ async def note(self, ctx, *, msg: str = ""): async with safe_typing(ctx): msg = await ctx.thread.note(ctx.message) await msg.pin() + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound): + pass @note.command(name="persistent", aliases=["persist"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1534,7 +1719,16 @@ async def note_persistent(self, ctx, *, msg: str = ""): async with safe_typing(ctx): msg = await ctx.thread.note(ctx.message, persistent=True) await msg.pin() - await self.bot.api.create_note(recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id) + await self.bot.api.create_note( + recipient=ctx.thread.recipient, message=ctx.message, message_id=msg.id + ) + # Acknowledge and clean up the invoking command message + sent_emoji, _ = await self.bot.retrieve_emoji() + await self.bot.add_reaction(ctx.message, sent_emoji) + try: + await ctx.message.delete(delay=3) + except (discord.Forbidden, discord.NotFound): + pass @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1568,6 +1762,29 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): @checks.has_permissions(PermissionLevel.REGULAR) async def selfcontact(self, ctx): """Creates a thread with yourself""" + # Check if user already has a thread + existing_thread = await self.bot.threads.find(recipient=ctx.author) + if existing_thread: + if existing_thread.snoozed: + # Unsnooze the thread + msg = await ctx.send("ℹ️ You had a snoozed thread. Unsnoozing now...") + await existing_thread.restore_from_snooze() + self.bot.threads.cache[existing_thread.id] = existing_thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound): + pass + return + else: + # Thread already exists and is active + embed = discord.Embed( + title="Thread not created", + description=f"A thread for you already exists in {existing_thread.channel.mention}.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed, delete_after=10) + return + await ctx.invoke(self.contact, users=[ctx.author]) @commands.command(usage=" [category] [options]") @@ -1576,7 +1793,12 @@ async def contact( self, ctx, users: commands.Greedy[ - Union[Literal["silent", "silently"], discord.Member, discord.User, discord.Role] + Union[ + Literal["silent", "silently"], + discord.Member, + discord.User, + discord.Role, + ] ], *, category: SimilarCategoryConverter = None, @@ -1626,9 +1848,14 @@ async def contact( users += u.members users.remove(u) + snoozed_users = [] for u in list(users): exists = await self.bot.threads.find(recipient=u) if exists: + # Check if thread is snoozed + if exists.snoozed: + snoozed_users.append(u) + continue errors.append(f"A thread for {u} already exists.") if exists.channel: errors[-1] += f" in {exists.channel.mention}" @@ -1639,9 +1866,28 @@ async def contact( users.remove(u) elif await self.bot.is_blocked(u): ref = f"{u.mention} is" if ctx.author != u else "You are" - errors.append(f"{ref} currently blocked from contacting {self.bot.user.name}.") + errors.append( + f"{ref} currently blocked from contacting {self.bot.user.name}." + ) users.remove(u) + # Handle snoozed users - unsnooze them and return early + if snoozed_users: + for u in snoozed_users: + thread = await self.bot.threads.find(recipient=u) + if thread and thread.snoozed: + msg = await ctx.send( + f"ℹ️ {u.mention} had a snoozed thread. Unsnoozing now..." + ) + await thread.restore_from_snooze() + self.bot.threads.cache[thread.id] = thread + try: + await msg.delete(delay=10) + except (discord.Forbidden, discord.NotFound): + pass + # Don't try to create a new thread - we just unsnoozed existing ones + return + if len(users) > 5: errors.append("Group conversations only support 5 users.") users = [] @@ -1654,11 +1900,14 @@ async def contact( title = None if manual_trigger: # not react to contact - embed = discord.Embed(title=title, color=self.bot.error_color, description="\n".join(errors)) + embed = discord.Embed( + title=title, + color=self.bot.error_color, + description="\n".join(errors), + ) await ctx.send(embed=embed, delete_after=10) if not users: - # end return creator = ctx.author if manual_trigger else users[0] @@ -1674,7 +1923,10 @@ async def contact( if thread.cancelled: return - if self.bot.config["dm_disabled"] in (DMDisabled.NEW_THREADS, DMDisabled.ALL_THREADS): + if self.bot.config["dm_disabled"] in ( + DMDisabled.NEW_THREADS, + DMDisabled.ALL_THREADS, + ): logger.info("Contacting user %s when Modmail DM is disabled.", users[0]) if not silent and not self.bot.config.get("thread_contact_silently"): @@ -1693,7 +1945,8 @@ async def contact( if self.bot.config["show_timestamp"]: em.timestamp = discord.utils.utcnow() em.set_footer( - text=f"{creator}", icon_url=creator.display_avatar.url if creator.display_avatar else None + text=f"{creator}", + icon_url=creator.display_avatar.url if creator.display_avatar else None, ) for u in users: @@ -1714,8 +1967,10 @@ async def contact( if manual_trigger: sent_emoji, _ = await self.bot.retrieve_emoji() await self.bot.add_reaction(ctx.message, sent_emoji) - await asyncio.sleep(5) - await ctx.message.delete() + try: + await ctx.message.delete(delay=5) + except (discord.Forbidden, discord.NotFound): + pass @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.MODERATOR) @@ -1763,7 +2018,11 @@ async def blocked(self, ctx): if role: roles.append((role.mention, reason)) - user_embeds = [discord.Embed(title="Blocked Users", color=self.bot.main_color, description="")] + user_embeds = [ + discord.Embed( + title="Blocked Users", color=self.bot.main_color, description="" + ) + ] if users: embed = user_embeds[0] @@ -1786,7 +2045,11 @@ async def blocked(self, ctx): for n, em in enumerate(user_embeds): em.title = f"{em.title} [{n + 1}]" - role_embeds = [discord.Embed(title="Blocked Roles", color=self.bot.main_color, description="")] + role_embeds = [ + discord.Embed( + title="Blocked Roles", color=self.bot.main_color, description="" + ) + ] if roles: embed = role_embeds[-1] @@ -1980,7 +2243,10 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): mention = getattr(user_or_role, "mention", f"`{user_or_role.id}`") name = getattr(user_or_role, "name", f"`{user_or_role.id}`") - if not isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_users: + if ( + not isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_users + ): msg = self.bot.blocked_users.pop(str(user_or_role.id)) or "" await self.bot.config.update() @@ -2005,7 +2271,10 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): color=self.bot.main_color, description=f"{mention} is no longer blocked.", ) - elif isinstance(user_or_role, discord.Role) and str(user_or_role.id) in self.bot.blocked_roles: + elif ( + isinstance(user_or_role, discord.Role) + and str(user_or_role.id) in self.bot.blocked_roles + ): msg = self.bot.blocked_roles.pop(str(user_or_role.id)) or "" await self.bot.config.update() @@ -2016,7 +2285,9 @@ async def unblock(self, ctx, *, user_or_role: Union[User, Role] = None): ) else: embed = discord.Embed( - title="Error", description=f"{mention} is not blocked.", color=self.bot.error_color + title="Error", + description=f"{mention} is not blocked.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -2069,12 +2340,18 @@ async def repair(self, ctx): # Search cache for channel user_id, thread = next( - ((k, v) for k, v in self.bot.threads.cache.items() if v.channel == ctx.channel), + ( + (k, v) + for k, v in self.bot.threads.cache.items() + if v.channel == ctx.channel + ), (-1, None), ) if thread is not None: logger.debug("Found thread with tempered ID.") - await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}") + await ctx.channel.edit( + reason="Fix broken Modmail thread", topic=f"User ID: {user_id}" + ) return await self.bot.add_reaction(ctx.message, sent_emoji) # find genesis message to retrieve User ID @@ -2102,8 +2379,12 @@ async def repair(self, ctx): self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True - logger.info("Setting current channel's topic to User ID and created new thread.") - await ctx.channel.edit(reason="Fix broken Modmail thread", topic=f"User ID: {user_id}") + logger.info( + "Setting current channel's topic to User ID and created new thread." + ) + await ctx.channel.edit( + reason="Fix broken Modmail thread", topic=f"User ID: {user_id}" + ) return await self.bot.add_reaction(ctx.message, sent_emoji) else: @@ -2116,7 +2397,10 @@ async def repair(self, ctx): users = set( filter( lambda member: member.name == m.group(1) - and (member.discriminator == "0" or member.discriminator == m.group(2)), + and ( + member.discriminator == "0" + or member.discriminator == m.group(2) + ), ctx.guild.members, ) ) @@ -2154,9 +2438,13 @@ async def repair(self, ctx): self.bot.threads, recipient, ctx.channel, other_recipients ) thread.ready = True - logger.info("Setting current channel's topic to User ID and created new thread.") + logger.info( + "Setting current channel's topic to User ID and created new thread." + ) await ctx.channel.edit( - reason="Fix broken Modmail thread", name=name, topic=f"User ID: {user.id}" + reason="Fix broken Modmail thread", + name=name, + topic=f"User ID: {user.id}", ) return await self.bot.add_reaction(ctx.message, sent_emoji) @@ -2268,35 +2556,146 @@ async def isenable(self, ctx): @checks.thread_only() async def snooze(self, ctx, *, duration: UserFriendlyTime = None): """ - Snooze this thread: deletes the channel, keeps the ticket open in DM, and restores it when the user replies or a moderator unsnoozes it. - Optionally specify a duration, e.g. 'snooze 2d' for 2 days. - Uses config: max_snooze_time, snooze_title, snooze_text + Snooze this thread. Behavior depends on config: + - delete (default): deletes the channel and restores it later + - move: moves the channel to the configured snoozed category + Optionally specify a duration, e.g. 'snooze 2d' for 2 days. + Uses config: snooze_default_duration, snooze_title, snooze_text """ thread = ctx.thread if thread.snoozed: await ctx.send("This thread is already snoozed.") - logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.") + logging.info( + f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed." + ) return - max_snooze = self.bot.config.get("max_snooze_time") - if max_snooze is None: - max_snooze = 604800 - max_snooze = int(max_snooze) + # Default snooze duration with safe fallback + try: + default_snooze = int(self.bot.config.get("snooze_default_duration", 604800)) + except (ValueError, TypeError): + default_snooze = 604800 if duration: snooze_for = int((duration.dt - duration.now).total_seconds()) - if snooze_for > max_snooze: - snooze_for = max_snooze + snooze_for = min(snooze_for, default_snooze) else: - snooze_for = max_snooze + snooze_for = default_snooze + + # Capacity pre-check: if behavior is move, ensure snoozed category has room (<49 channels) + behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + if behavior == "move": + snoozed_cat_id = self.bot.config.get("snoozed_category_id") + target_category = None + if snoozed_cat_id: + try: + target_category = self.bot.modmail_guild.get_channel( + int(snoozed_cat_id) + ) + except Exception: + target_category = None + # Auto-create snoozed category if missing + if not isinstance(target_category, discord.CategoryChannel): + try: + logging.info( + "Auto-creating snoozed category for move-based snoozing." + ) + # Hide category by default; only bot can view/manage + overwrites = { + self.bot.modmail_guild.default_role: discord.PermissionOverwrite( + view_channel=False + ) + } + bot_member = self.bot.modmail_guild.me + if bot_member is not None: + overwrites[bot_member] = discord.PermissionOverwrite( + view_channel=True, + send_messages=True, + read_message_history=True, + manage_channels=True, + manage_messages=True, + attach_files=True, + embed_links=True, + add_reactions=True, + ) + target_category = await self.bot.modmail_guild.create_category( + name="Snoozed Threads", + overwrites=overwrites, + reason="Auto-created snoozed category for move-based snoozing", + ) + try: + await self.bot.config.set( + "snoozed_category_id", target_category.id + ) + await self.bot.config.update() + except Exception as e: + logging.warning("Failed to persist snoozed_category_id: %s", e) + try: + await ctx.send( + "⚠️ Created snoozed category but failed to save it to config. Please set `snoozed_category_id` manually." + ) + except Exception as e: + logging.info( + "Failed to notify about snoozed category persistence issue: %s", + e, + ) + await ctx.send( + embed=discord.Embed( + title="Snoozed category created", + description=( + f"Created category {target_category.mention if hasattr(target_category, 'mention') else target_category.name} " + "and set it as `snoozed_category_id`." + ), + color=self.bot.main_color, + ) + ) + except Exception as e: + await ctx.send( + embed=discord.Embed( + title="Could not create snoozed category", + description=( + "I couldn't create a category automatically. Please ensure I have Manage Channels " + "permission, or set `snoozed_category_id` manually." + ), + color=self.bot.error_color, + ) + ) + logging.warning("Failed to auto-create snoozed category: %s", e) + # Capacity check after ensuring category exists + if isinstance(target_category, discord.CategoryChannel): + try: + if len(target_category.channels) >= 49: + await ctx.send( + embed=discord.Embed( + title="Snooze unavailable", + description=( + "The configured snoozed category is full (49 channels). " + "Unsnooze or move some channels out before snoozing more." + ), + color=self.bot.error_color, + ) + ) + return + except Exception as e: + logging.debug( + "Failed to check snoozed category channel count: %s", e + ) - # Storing snooze_start and snooze_for in the log entry + # Store snooze_until timestamp for reliable auto-unsnooze now = datetime.now(timezone.utc) + snooze_until = now + timedelta(seconds=snooze_for) await self.bot.api.logs.update_one( {"recipient.id": str(thread.id)}, - {"$set": {"snooze_start": now.isoformat(), "snooze_for": snooze_for}}, + { + "$set": { + "snooze_start": now.isoformat(), + "snooze_for": snooze_for, + "snooze_until": snooze_until.isoformat(), + } + }, ) embed = discord.Embed( title=self.bot.config.get("snooze_title") or "Thread Snoozed", - description=self.bot.config.get("snooze_text") or "This thread has been snoozed.", + description=self.bot.config.get("snooze_text") + or "This thread has been snoozed.", color=self.bot.error_color, ) await ctx.send(embed=embed) @@ -2308,7 +2707,9 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): self.bot.threads.cache[thread.id] = thread else: await ctx.send("Failed to snooze this thread.") - logging.error(f"[SNOOZE] Failed to snooze thread for {getattr(thread.recipient, 'id', None)}.") + logging.error( + f"[SNOOZE] Failed to snooze thread for {getattr(thread.recipient, 'id', None)}." + ) @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -2328,12 +2729,21 @@ async def unsnooze(self, ctx, *, user: str = None): try: user_obj = await self.bot.get_or_fetch_user(user_id) except Exception: + logger.debug( + "Failed fetching user during unsnooze; falling back to partial object (%s).", + user_id, + exc_info=True, + ) user_obj = discord.Object(user_id) if user_obj: thread = await self.bot.threads.find(recipient=user_obj) if not thread: - await ctx.send(f"[DEBUG] No thread found for user {user} (obj: {user_obj}).") - logging.warning(f"[UNSNOOZE] No thread found for user {user} (obj: {user_obj})") + await ctx.send( + f"[DEBUG] No thread found for user {user} (obj: {user_obj})." + ) + logging.warning( + f"[UNSNOOZE] No thread found for user {user} (obj: {user_obj})" + ) return elif hasattr(ctx, "thread"): thread = ctx.thread @@ -2343,12 +2753,16 @@ async def unsnooze(self, ctx, *, user: str = None): return if not thread.snoozed: await ctx.send("This thread is not snoozed.") - logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} is not snoozed.") + logging.info( + f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} is not snoozed." + ) return # Manually fetch snooze_data if the thread object doesn't have it if not thread.snooze_data: - log_entry = await self.bot.api.logs.find_one({"recipient.id": str(thread.id), "snoozed": True}) + log_entry = await self.bot.api.logs.find_one( + {"recipient.id": str(thread.id), "snoozed": True} + ) if log_entry: thread.snooze_data = log_entry.get("snooze_data") @@ -2356,9 +2770,12 @@ async def unsnooze(self, ctx, *, user: str = None): if ok: self.bot.threads.cache[thread.id] = thread await ctx.send( - self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." + self.bot.config.get("unsnooze_text") + or "This thread has been unsnoozed and restored." + ) + logging.info( + f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} unsnoozed." ) - logging.info(f"[UNSNOOZE] Thread for {getattr(thread.recipient, 'id', None)} unsnoozed.") else: await ctx.send("Failed to unsnooze this thread.") logging.error( @@ -2371,7 +2788,9 @@ async def snoozed(self, ctx): """ List all currently snoozed threads/users. """ - snoozed_threads = [thread for thread in self.bot.threads.cache.values() if thread.snoozed] + snoozed_threads = [ + thread for thread in self.bot.threads.cache.values() if thread.snoozed + ] if not snoozed_threads: await ctx.send("No threads are currently snoozed.") return @@ -2394,13 +2813,17 @@ async def snoozed(self, ctx): since_dt = datetime.fromisoformat(since) since_str = f"" # Discord relative timestamp except (ValueError, TypeError) as e: - logging.warning(f"[SNOOZED] Invalid snooze_start for {user_id}: {since} ({e})") + logging.warning( + f"[SNOOZED] Invalid snooze_start for {user_id}: {since} ({e})" + ) else: logging.warning(f"[SNOOZED] Missing snooze_start for {user_id}") if duration and since_str != "?": try: - until_dt = datetime.fromisoformat(since) + timedelta(seconds=int(duration)) + until_dt = datetime.fromisoformat(since) + timedelta( + seconds=int(duration) + ) until_str = f"" except (ValueError, TypeError) as e: logging.warning( @@ -2412,32 +2835,32 @@ async def snoozed(self, ctx): await ctx.send("Snoozed threads:\n" + "\n".join(lines)) async def cog_load(self): - self.bot.loop.create_task(self.snooze_auto_unsnooze_task()) + self.snooze_auto_unsnooze.start() + + @tasks.loop(seconds=10) + async def snooze_auto_unsnooze(self): + now = datetime.now(timezone.utc) + snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) + for entry in snoozed: + snooze_until = entry.get("snooze_until") + if snooze_until: + try: + until_dt = datetime.fromisoformat(snooze_until) + if now >= until_dt: + thread = await self.bot.threads.find( + recipient_id=int(entry["recipient"]["id"]) + ) + if thread and thread.snoozed: + await thread.restore_from_snooze() + except (ValueError, TypeError) as e: + logger.debug( + "Failed parsing snooze_until timestamp for auto-unsnooze loop: %s", + e, + ) - async def snooze_auto_unsnooze_task(self): + @snooze_auto_unsnooze.before_loop + async def _snooze_auto_unsnooze_before(self): await self.bot.wait_until_ready() - while True: - now = datetime.now(timezone.utc) - snoozed = await self.bot.api.logs.find({"snoozed": True}).to_list(None) - for entry in snoozed: - start = entry.get("snooze_start") - snooze_for = entry.get("snooze_for") - if not start: - continue - start_dt = datetime.fromisoformat(start) - if snooze_for is not None: - duration = int(snooze_for) - else: - max_snooze = self.bot.config.get("max_snooze_time") - if max_snooze is None: - max_snooze = 604800 - duration = int(max_snooze) - if (now - start_dt).total_seconds() > duration: - # Auto-unsnooze - thread = await self.bot.threads.find(recipient_id=int(entry["recipient"]["id"])) - if thread and thread.snoozed: - await thread.restore_from_snooze() - await asyncio.sleep(60) async def process_dm_modmail(self, message: discord.Message) -> None: # ... existing code ... diff --git a/cogs/plugins.py b/cogs/plugins.py index c7dceb7283..f3269b5ae1 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -52,7 +52,9 @@ def __init__(self, user, repo=None, name=None, branch=None): def path(self): if self.local: return PurePath("plugins") / "@local" / self.name - return PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" + return ( + PurePath("plugins") / self.user / self.repo / f"{self.name}-{self.branch}" + ) @property def abs_path(self): @@ -147,10 +149,14 @@ async def initial_load_plugins(self): # For backwards compat plugin = Plugin.from_string(plugin_name) except InvalidPluginError: - logger.error("Failed to parse plugin name: %s.", plugin_name, exc_info=True) + logger.error( + "Failed to parse plugin name: %s.", plugin_name, exc_info=True + ) continue - logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) + logger.info( + "Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin) + ) self.bot.config["plugins"].append(str(plugin)) try: @@ -202,7 +208,9 @@ async def download_plugin(self, plugin, force=False): if raw == "Not Found": raise InvalidPluginError("Plugin not found") else: - raise InvalidPluginError("Invalid download received, non-bytes object") + raise InvalidPluginError( + "Invalid download received, non-bytes object" + ) plugin_io = io.BytesIO(raw) if not plugin.cache_path.parent.exists(): @@ -234,7 +242,9 @@ async def load_plugin(self, plugin): if req_txt.exists(): # Install PIP requirements - venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env + venv = hasattr(sys, "real_prefix") or hasattr( + sys, "base_prefix" + ) # in a virtual env user_install = " --user" if not venv else "" proc = await asyncio.create_subprocess_shell( f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q', @@ -251,8 +261,14 @@ async def load_plugin(self, plugin): if stderr: logger.debug("[stderr]\n%s.", stderr.decode()) - logger.error("Failed to download requirements for %s.", plugin.ext_string, exc_info=True) - raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```") + logger.error( + "Failed to download requirements for %s.", + plugin.ext_string, + exc_info=True, + ) + raise InvalidPluginError( + f"Unable to download requirements: ```\n{stderr.decode()}\n```" + ) if os.path.exists(USER_SITE): sys.path.insert(0, USER_SITE) @@ -361,7 +377,10 @@ async def plugins_add(self, ctx, *, plugin_name: str): return if str(plugin) in self.bot.config["plugins"]: - embed = discord.Embed(description="This plugin is already installed.", color=self.bot.error_color) + embed = discord.Embed( + description="This plugin is already installed.", + color=self.bot.error_color, + ) return await ctx.send(embed=embed) if plugin.name in self.bot.cogs: @@ -444,7 +463,9 @@ async def plugins_remove(self, ctx, *, plugin_name: str): return if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + embed = discord.Embed( + description="Plugin is not installed.", color=self.bot.error_color + ) return await ctx.send(embed=embed) if self.bot.config.get("enable_plugins"): @@ -470,7 +491,8 @@ async def plugins_remove(self, ctx, *, plugin_name: str): pass # dir not empty embed = discord.Embed( - description="The plugin is successfully uninstalled.", color=self.bot.main_color + description="The plugin is successfully uninstalled.", + color=self.bot.main_color, ) await ctx.send(embed=embed) @@ -481,12 +503,15 @@ async def update_plugin(self, ctx, plugin_name): return if str(plugin) not in self.bot.config["plugins"]: - embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) + embed = discord.Embed( + description="Plugin is not installed.", color=self.bot.error_color + ) return await ctx.send(embed=embed) async with safe_typing(ctx): embed = discord.Embed( - description=f"Successfully updated {plugin.name}.", color=self.bot.main_color + description=f"Successfully updated {plugin.name}.", + color=self.bot.main_color, ) await self.download_plugin(plugin, force=True) if self.bot.config.get("enable_plugins"): @@ -503,7 +528,9 @@ async def update_plugin(self, ctx, plugin_name): color=self.bot.error_color, ) self.bot.config["plugins"].remove(str(plugin)) - logger.debug("Failed to update %s. Removed plugin from config.", plugin) + logger.debug( + "Failed to update %s. Removed plugin from config.", plugin + ) else: logger.debug("Updated %s.", plugin) else: @@ -543,7 +570,9 @@ async def plugins_reset(self, ctx): continue logger.error("Unloading plugin: %s.", ext) try: - plugin = next((p for p in self.loaded_plugins if p.ext_string == ext), None) + plugin = next( + (p for p in self.loaded_plugins if p.ext_string == ext), None + ) if plugin: await self.unload_plugin(plugin) self.loaded_plugins.remove(plugin) @@ -570,7 +599,8 @@ async def plugins_reset(self, ctx): logger.warning("Removing %s.", entry.name) embed = discord.Embed( - description="Successfully purged all plugins from the bot.", color=self.bot.main_color + description="Successfully purged all plugins from the bot.", + color=self.bot.main_color, ) return await ctx.send(embed=embed) @@ -598,7 +628,8 @@ async def plugins_loaded(self, ctx): if not self.loaded_plugins: embed = discord.Embed( - description="There are no plugins currently loaded.", color=self.bot.error_color + description="There are no plugins currently loaded.", + color=self.bot.error_color, ) return await ctx.send(embed=embed) @@ -617,14 +648,20 @@ async def plugins_loaded(self, ctx): embeds = [] for page in pages: - embed = discord.Embed(title="Loaded plugins:", description=page, color=self.bot.main_color) + embed = discord.Embed( + title="Loaded plugins:", description=page, color=self.bot.main_color + ) embeds.append(embed) paginator = EmbedPaginatorSession(ctx, *embeds) await paginator.run() - @plugins.group(invoke_without_command=True, name="registry", aliases=["list", "info"]) + @plugins.group( + invoke_without_command=True, name="registry", aliases=["list", "info"] + ) @checks.has_permissions(PermissionLevel.OWNER) - async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = None): + async def plugins_registry( + self, ctx, *, plugin_name: typing.Union[int, str] = None + ): """ Shows a list of all approved plugins. @@ -655,7 +692,9 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N if index >= len(registry): index = len(registry) - 1 else: - index = next((i for i, (n, _) in enumerate(registry) if plugin_name == n), 0) + index = next( + (i for i, (n, _) in enumerate(registry) if plugin_name == n), 0 + ) if not index and plugin_name is not None: embed = discord.Embed( @@ -666,7 +705,10 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N matches = get_close_matches(plugin_name, self.registry.keys()) if matches: - embed.add_field(name="Perhaps you meant:", value="\n".join(f"`{m}`" for m in matches)) + embed.add_field( + name="Perhaps you meant:", + value="\n".join(f"`{m}`" for m in matches), + ) return await ctx.send(embed=embed) @@ -684,9 +726,13 @@ async def plugins_registry(self, ctx, *, plugin_name: typing.Union[int, str] = N title=details["repository"], ) - embed.add_field(name="Installation", value=f"```{self.bot.prefix}plugins add {name}```") + embed.add_field( + name="Installation", value=f"```{self.bot.prefix}plugins add {name}```" + ) - embed.set_author(name=details["title"], icon_url=details.get("icon_url"), url=plugin.link) + embed.set_author( + name=details["title"], icon_url=details.get("icon_url"), url=plugin.link + ) if details.get("thumbnail_url"): embed.set_thumbnail(url=details.get("thumbnail_url")) @@ -732,7 +778,9 @@ async def plugins_registry_compact(self, ctx): plugin = Plugin(user, repo, plugin_name, branch) - desc = discord.utils.escape_markdown(details["description"].replace("\n", "")) + desc = discord.utils.escape_markdown( + details["description"].replace("\n", "") + ) name = f"[`{plugin.name}`]({plugin.link})" fmt = f"{name} - {desc}" @@ -761,7 +809,9 @@ async def plugins_registry_compact(self, ctx): embed = discord.Embed(color=self.bot.main_color, description=page) embed.set_author( name="Plugin Registry", - icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, + icon_url=self.bot.user.display_avatar.url + if self.bot.user.display_avatar + else None, ) embeds.append(embed) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py new file mode 100644 index 0000000000..24c2e2abd1 --- /dev/null +++ b/cogs/threadmenu.py @@ -0,0 +1,832 @@ +import json +import asyncio +from copy import copy as _copy + +import discord +from discord.ext import commands + +from core import checks +from core.models import PermissionLevel + + +class ThreadCreationMenuCore(commands.Cog): + """Core-integrated thread menu configuration and management. + + This Cog exposes the same commands as the legacy plugin to manage menu options, + but stores settings in core config (no plugin DB). + """ + + def __init__(self, bot): + self.bot = bot + + # ----- helpers ----- + def _get_conf(self) -> dict: + return { + "enabled": bool(self.bot.config.get("thread_creation_menu_enabled")), + "options": self.bot.config.get("thread_creation_menu_options") or {}, + "submenus": self.bot.config.get("thread_creation_menu_submenus") or {}, + "timeout": int(self.bot.config.get("thread_creation_menu_timeout") or 20), + "close_on_timeout": bool(self.bot.config.get("thread_creation_menu_close_on_timeout")), + "anonymous_menu": bool(self.bot.config.get("thread_creation_menu_anonymous_menu")), + "embed_text": self.bot.config.get("thread_creation_menu_embed_text") + or "Please select an option.", + "dropdown_placeholder": self.bot.config.get("thread_creation_menu_dropdown_placeholder") + or "Select an option to contact the staff team.", + "embed_title": self.bot.config.get("thread_creation_menu_embed_title"), + "embed_footer": self.bot.config.get("thread_creation_menu_embed_footer"), + "embed_thumbnail_url": self.bot.config.get("thread_creation_menu_embed_thumbnail_url"), + "embed_footer_icon_url": self.bot.config.get("thread_creation_menu_embed_footer_icon_url"), + "embed_color": self.bot.config.get("thread_creation_menu_embed_color"), + } + + async def _save_conf(self, conf: dict): + await self.bot.config.set("thread_creation_menu_enabled", conf.get("enabled", False)) + await self.bot.config.set("thread_creation_menu_options", conf.get("options", {}), convert=False) + await self.bot.config.set("thread_creation_menu_submenus", conf.get("submenus", {}), convert=False) + await self.bot.config.set("thread_creation_menu_timeout", conf.get("timeout", 20)) + await self.bot.config.set( + "thread_creation_menu_close_on_timeout", conf.get("close_on_timeout", False) + ) + await self.bot.config.set("thread_creation_menu_anonymous_menu", conf.get("anonymous_menu", False)) + await self.bot.config.set( + "thread_creation_menu_embed_text", conf.get("embed_text", "Please select an option.") + ) + await self.bot.config.set( + "thread_creation_menu_dropdown_placeholder", + conf.get("dropdown_placeholder", "Select an option to contact the staff team."), + ) + await self.bot.config.set("thread_creation_menu_embed_title", conf.get("embed_title")) + await self.bot.config.set("thread_creation_menu_embed_footer", conf.get("embed_footer")) + await self.bot.config.set("thread_creation_menu_embed_thumbnail_url", conf.get("embed_thumbnail_url")) + await self.bot.config.set( + "thread_creation_menu_embed_footer_icon_url", conf.get("embed_footer_icon_url") + ) + if conf.get("embed_color"): + try: + await self.bot.config.set("thread_creation_menu_embed_color", conf.get("embed_color")) + except Exception: + pass + await self.bot.config.update() + + # ----- commands ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @commands.group(invoke_without_command=True) + async def threadmenu(self, ctx): + """Thread-creation menu settings (core).""" + await ctx.send_help(ctx.command) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="toggle") + async def threadmenu_toggle(self, ctx): + """Enable or disable the thread-creation menu. + + Toggles the global on/off state. When disabled, users won't see + or be able to use the interactive thread creation select menu. + """ + conf = self._get_conf() + conf["enabled"] = not conf["enabled"] + await self._save_conf(conf) + await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.command(name="show") + async def threadmenu_show(self, ctx): + """Show all current main-menu options. + + Lists every option (label + description) configured in the root + (non-submenu) select menu so you can review what users will see. + """ + conf = self._get_conf() + if not conf["options"]: + return await ctx.send("There are no options in the main menu.") + embed = discord.Embed(title="Main menu", color=discord.Color.blurple()) + for v in conf["options"].values(): + embed.add_field(name=v["label"], value=v["description"], inline=False) + await ctx.send(embed=embed) + + # ----- options ----- + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu.group(name="option", invoke_without_command=True) + async def threadmenu_option(self, ctx): + """Manage main-menu options (add/remove/edit/show). + + Use subcommands: + - add: interactive wizard to create an option + - remove