diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c4f1bdd4..5eb2ca26cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..b79d6e40fe 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 @@ -84,12 +83,18 @@ def __init__(self): 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 +106,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 @@ -316,7 +324,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 @@ -569,7 +580,11 @@ async def on_ready(self): 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,7 +605,7 @@ async def on_ready(self): ) for log in await self.api.get_open_logs(): - if self.get_channel(int(log["channel_id"])) is None: + 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"], @@ -611,7 +626,10 @@ async def on_ready(self): if log_data: 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}] if any(other_guilds): @@ -870,7 +888,8 @@ 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 +899,36 @@ 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) @@ -904,7 +953,10 @@ async def process_dm_modmail(self, message: discord.Message) -> None: ) ) 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 +967,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,7 +984,10 @@ 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 @@ -988,7 +1044,10 @@ def __init__(self, original_message, forwarded_content): ) ) 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, @@ -999,7 +1058,8 @@ def __init__(self, original_message, forwarded_content): 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) @@ -1016,7 +1076,8 @@ def __init__(self, original_message, forwarded_content): 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) @@ -1055,13 +1116,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) @@ -1075,7 +1156,10 @@ def __init__(self, original_message, ref_message): ) 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 +1169,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 +1191,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 +1204,49 @@ 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 @@ -1223,7 +1359,12 @@ async def trigger_auto_triggers(self, message, channel, *, cls=commands.Context) 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() @@ -1356,7 +1497,7 @@ 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: @@ -1368,11 +1509,44 @@ async def process_commands(self, message): ) 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") or "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"): @@ -1385,7 +1559,9 @@ async def process_commands(self, message): or self.config.get("anon_reply_without_command") or self.config.get("plain_reply_without_command") ): - await thread.reply(message, anonymous=anonymous, plain=plain) + # When replying without a command in a thread channel, use the raw content + # from the sent message as reply text while still preserving attachments. + await thread.reply(message, message.content, anonymous=anonymous, plain=plain) elif ctx.invoked_with: exc = commands.CommandNotFound('Command "{}" is not found'.format(ctx.invoked_with)) self.dispatch("command_error", ctx, exc) @@ -1406,7 +1582,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 +1598,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) @@ -1492,7 +1675,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) @@ -1525,7 +1708,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,6 +1727,19 @@ 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) @@ -1676,7 +1875,12 @@ async def on_message_delete(self, message): await thread.delete_message(message, note=False) 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) else: @@ -1715,7 +1919,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,7 +1937,10 @@ async def on_command_error( try: await context.typing() except Exception: - pass + 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) @@ -1760,7 +1971,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) @@ -1792,7 +2006,13 @@ async def post_metadata(self): } ) 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 +2065,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", @@ -1892,7 +2112,10 @@ async def autoupdate(self): logger.info("Bot has been updated.") channel = self.update_channel - if self.hosting_method in (HostingMethod.PM2, HostingMethod.SYSTEMD): + 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}" diff --git a/cogs/modmail.py b/cogs/modmail.py index d7d9d7b010..7333f9b2bd 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,61 @@ 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.""" @@ -164,7 +220,8 @@ async def snippet(self, ctx, *, name: str.lower = None): description = format_description(i, names) 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) @@ -179,21 +236,30 @@ async def snippet(self, ctx, *, name: str.lower = None): 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_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)] 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( @@ -448,7 +514,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 +540,24 @@ 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 +598,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( @@ -625,7 +698,8 @@ 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) @@ -736,7 +810,8 @@ 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) @@ -899,7 +974,8 @@ 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: @@ -968,7 +1044,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"], @@ -997,7 +1074,8 @@ 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: @@ -1072,7 +1150,7 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.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 +1171,8 @@ 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: @@ -1163,7 +1242,7 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro 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 +1263,8 @@ 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: @@ -1371,10 +1451,8 @@ async def reply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ - ctx.message.content = msg - async with safe_typing(ctx): - await ctx.thread.reply(ctx.message) + await ctx.thread.reply(ctx.message, msg) @commands.command(aliases=["formatreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1392,11 +1470,13 @@ 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): - await ctx.thread.reply(ctx.message) + await ctx.thread.reply(ctx.message, msg) @commands.command(aliases=["formatanonreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1414,11 +1494,13 @@ 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): - await ctx.thread.reply(ctx.message, anonymous=True) + await ctx.thread.reply(ctx.message, msg, anonymous=True) @commands.command(aliases=["formatplainreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1436,11 +1518,13 @@ 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): - await ctx.thread.reply(ctx.message, plain=True) + await ctx.thread.reply(ctx.message, msg, plain=True) @commands.command(aliases=["formatplainanonreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1458,11 +1542,13 @@ 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): - await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) @commands.command(aliases=["anonreply", "anonymousreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1477,9 +1563,8 @@ async def areply(self, ctx, *, msg: str = ""): Edit the `anon_username`, `anon_avatar_url` and `anon_tag` config variables to do so. """ - ctx.message.content = msg async with safe_typing(ctx): - await ctx.thread.reply(ctx.message, anonymous=True) + await ctx.thread.reply(ctx.message, msg, anonymous=True) @commands.command(aliases=["plainreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1491,9 +1576,8 @@ async def preply(self, ctx, *, msg: str = ""): Supports attachments and images as well as automatically embedding image URLs. """ - ctx.message.content = msg async with safe_typing(ctx): - await ctx.thread.reply(ctx.message, plain=True) + await ctx.thread.reply(ctx.message, msg, plain=True) @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1505,9 +1589,8 @@ async def pareply(self, ctx, *, msg: str = ""): Supports attachments and images as well as automatically embedding image URLs. """ - ctx.message.content = msg async with safe_typing(ctx): - await ctx.thread.reply(ctx.message, anonymous=True, plain=True) + await ctx.thread.reply(ctx.message, msg, anonymous=True, plain=True) @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1522,6 +1605,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) @@ -1535,6 +1625,13 @@ async def note_persistent(self, ctx, *, msg: str = ""): 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) + # 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 +1665,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 +1696,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 +1751,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}" @@ -1642,6 +1772,21 @@ async def contact( 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 +1799,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 +1822,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 +1844,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 +1866,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) @@ -2016,7 +2170,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) @@ -2156,7 +2312,9 @@ async def repair(self, ctx): 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", 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,31 +2426,129 @@ 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.") 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", @@ -2328,6 +2584,11 @@ 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) @@ -2412,32 +2673,30 @@ 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() - async def snooze_auto_unsnooze_task(self): + @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, + ) + + @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..aa4ad5a65c 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -251,7 +251,11 @@ 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) + 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): @@ -361,7 +365,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: @@ -470,7 +477,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) @@ -486,7 +494,8 @@ async def update_plugin(self, ctx, plugin_name): 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"): @@ -570,7 +579,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 +608,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) @@ -666,7 +677,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) 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