From 42070be3652f8b66a197cd5df405b91b54f7d612 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:07:29 +0200 Subject: [PATCH 01/39] fix: ignore typing failures Make Modmail keep working when typing is disabled/outage --- bot.py | 15 ++++++++++++--- cogs/modmail.py | 26 ++++++++++++++------------ cogs/plugins.py | 4 ++-- cogs/utility.py | 1 + core/thread.py | 3 +++ core/utils.py | 36 +++++++++++++++++++++++++++++++++++- diff-summary.txt | 0 7 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 diff-summary.txt diff --git a/bot.py b/bot.py index ab4ee173cc..847ec43f08 100644 --- a/bot.py +++ b/bot.py @@ -1403,7 +1403,10 @@ async def on_typing(self, channel, user, _): thread = await self.threads.find(recipient=user) if thread: - await thread.channel.typing() + try: + await thread.channel.typing() + except Exception: + pass else: if not self.config.get("mod_typing"): return @@ -1413,7 +1416,10 @@ async def on_typing(self, channel, user, _): for user in thread.recipients: if await self.is_blocked(user): continue - await user.typing() + try: + await user.typing() + except Exception: + pass async def handle_reaction_events(self, payload): user = self.get_user(payload.user_id) @@ -1720,7 +1726,10 @@ async def on_command_error( return if isinstance(exception, (commands.BadArgument, commands.BadUnionArgument)): - await context.typing() + try: + await context.typing() + except Exception: + pass await context.send(embed=discord.Embed(color=self.error_color, description=str(exception))) elif isinstance(exception, commands.CommandNotFound): logger.warning("CommandNotFound: %s", exception) diff --git a/cogs/modmail.py b/cogs/modmail.py index f89d9da92b..a63eea9103 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1210,7 +1210,8 @@ async def logs(self, ctx, *, user: User = None): `user` may be a user ID, mention, or name. """ - await ctx.typing() + async with safe_typing(ctx): + pass if not user: thread = ctx.thread @@ -1342,7 +1343,8 @@ async def logs_search(self, ctx, limit: Optional[int] = None, *, query): Provide a `limit` to specify the maximum number of logs the bot should find. """ - await ctx.typing() + async with safe_typing(ctx): + pass entries = await self.bot.api.search_by_text(query, limit) @@ -1371,7 +1373,7 @@ async def reply(self, ctx, *, msg: str = ""): ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message) @commands.command(aliases=["formatreply"]) @@ -1393,7 +1395,7 @@ async def freply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message) @commands.command(aliases=["formatanonreply"]) @@ -1415,7 +1417,7 @@ async def fareply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True) @commands.command(aliases=["formatplainreply"]) @@ -1437,7 +1439,7 @@ async def fpreply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, plain=True) @commands.command(aliases=["formatplainanonreply"]) @@ -1459,7 +1461,7 @@ async def fpareply(self, ctx, *, msg: str = ""): msg, channel=ctx.channel, recipient=ctx.thread.recipient, author=ctx.message.author ) ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True, plain=True) @commands.command(aliases=["anonreply", "anonymousreply"]) @@ -1476,7 +1478,7 @@ async def areply(self, ctx, *, msg: str = ""): and `anon_tag` config variables to do so. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True) @commands.command(aliases=["plainreply"]) @@ -1490,7 +1492,7 @@ async def preply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, plain=True) @commands.command(aliases=["plainanonreply", "plainanonymousreply"]) @@ -1504,7 +1506,7 @@ async def pareply(self, ctx, *, msg: str = ""): automatically embedding image URLs. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): await ctx.thread.reply(ctx.message, anonymous=True, plain=True) @commands.group(invoke_without_command=True) @@ -1517,7 +1519,7 @@ async def note(self, ctx, *, msg: str = ""): Useful for noting context. """ ctx.message.content = msg - async with ctx.typing(): + async with safe_typing(ctx): msg = await ctx.thread.note(ctx.message) await msg.pin() @@ -1529,7 +1531,7 @@ async def note_persistent(self, ctx, *, msg: str = ""): Take a persistent note about the current user. """ ctx.message.content = msg - async with ctx.typing(): + 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) diff --git a/cogs/plugins.py b/cogs/plugins.py index 45a9e98803..c7dceb7283 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -20,7 +20,7 @@ from core import checks from core.models import PermissionLevel, getLogger from core.paginator import EmbedPaginatorSession -from core.utils import trigger_typing, truncate +from core.utils import trigger_typing, truncate, safe_typing logger = getLogger(__name__) @@ -484,7 +484,7 @@ async def update_plugin(self, ctx, plugin_name): embed = discord.Embed(description="Plugin is not installed.", color=self.bot.error_color) return await ctx.send(embed=embed) - async with ctx.typing(): + async with safe_typing(ctx): embed = discord.Embed( description=f"Successfully updated {plugin.name}.", color=self.bot.main_color ) diff --git a/cogs/utility.py b/cogs/utility.py index acc57a6950..4387a0b653 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1,3 +1,4 @@ +from core.utils import trigger_typing, truncate, safe_typing import asyncio import inspect import os diff --git a/core/thread.py b/core/thread.py index 8e445682f0..d380552694 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1457,11 +1457,14 @@ def lottie_to_png(data): ): logger.info("Sending a message to %s when DM disabled is set.", self.recipient) + # Best-effort typing: never block message delivery if typing fails try: await destination.typing() except discord.NotFound: logger.warning("Channel not found.") raise + except (discord.Forbidden, discord.HTTPException, Exception) as e: + logger.warning("Unable to send typing to %s: %s. Continuing without typing.", destination, e) if not from_mod and not note: mentions = await self.get_notifications() diff --git a/core/utils.py b/core/utils.py index cf369c6213..e5c3cdcfe8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,5 +1,6 @@ import base64 import functools +import contextlib import re import typing from datetime import datetime, timezone @@ -34,6 +35,7 @@ "normalize_alias", "format_description", "trigger_typing", + "safe_typing", "escape_code_block", "tryint", "get_top_role", @@ -425,10 +427,42 @@ def format_description(i, names): ) +class _SafeTyping: + """Best-effort typing context manager. + + Suppresses errors from Discord's typing endpoint so core flows continue + when typing is disabled or experiencing outages. + """ + + def __init__(self, target): + # target can be a Context or any Messageable (channel/DM/user) + self._target = target + self._cm = None + + async def __aenter__(self): + try: + self._cm = self._target.typing() + return await self._cm.__aenter__() + except Exception: + # typing is best-effort; ignore any failure + self._cm = None + + async def __aexit__(self, exc_type, exc, tb): + if self._cm is not None: + with contextlib.suppress(Exception): + return await self._cm.__aexit__(exc_type, exc, tb) + + +def safe_typing(target): + return _SafeTyping(target) + + def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - await ctx.typing() + # Fire and forget typing; do not block on failures + async with safe_typing(ctx): + pass return await func(self, ctx, *args, **kwargs) return wrapper diff --git a/diff-summary.txt b/diff-summary.txt new file mode 100644 index 0000000000..e69de29bb2 From 6683311c91d38853c14ecbe03750cad6524165ad Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:25:59 +0200 Subject: [PATCH 02/39] fix: only surpress failures --- core/utils.py | 5 ++--- diff-summary.txt | 0 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 diff-summary.txt diff --git a/core/utils.py b/core/utils.py index e5c3cdcfe8..f249cb9e50 100644 --- a/core/utils.py +++ b/core/utils.py @@ -460,10 +460,9 @@ def safe_typing(target): def trigger_typing(func): @functools.wraps(func) async def wrapper(self, ctx: commands.Context, *args, **kwargs): - # Fire and forget typing; do not block on failures + # Keep typing active for the duration of the command; suppress failures async with safe_typing(ctx): - pass - return await func(self, ctx, *args, **kwargs) + return await func(self, ctx, *args, **kwargs) return wrapper diff --git a/diff-summary.txt b/diff-summary.txt deleted file mode 100644 index e69de29bb2..0000000000 From 71fd480a36a12bd55ee179742b5b46c3670c3a20 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 4 Oct 2025 13:58:44 +0200 Subject: [PATCH 03/39] chore: sync local edits before push --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ bot.py | 2 +- pyproject.toml | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec54e0f8a..6fb4706eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. +# v4.2.0 + +Upgraded discord.py to version 2.6.3, added support for CV2. + +### Fixed +- Make Modmail keep working when typing is disabled due to a outage caused by Discord. +- Resolved an issue where forwarded messages appeared as empty embeds. +- Fixed internal message handling and restoration processes. +- Corrected a bug in the unsnooze functionality. +- Eliminated duplicate logs and notes. +- Addressed inconsistent use of `logkey` after ticket restoration. +- Fixed issues with identifying the user who sent internal messages. + +### Added +Commands: +* `snooze`: Initiates a snooze action. +* `snoozed`: Displays snoozed items. +* `unsnooze`: Reverses the snooze action. +* `clearsnoozed`: Clears all snoozed items. + +Configuration Options: +* `max_snooze_time`: 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. +* `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. + # v4.1.2 ### Fixed diff --git a/bot.py b/bot.py index 847ec43f08..089a88dc14 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,4 @@ -__version__ = "4.1.2" +__version__ = "4.2.0" import asyncio diff --git a/pyproject.toml b/pyproject.toml index 7e29a4d4ef..0a6d6eaa6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ extend-exclude = ''' [tool.poetry] name = 'Modmail' -version = '4.1.2' +version = '4.2.0' description = "Modmail is similar to Reddit's Modmail, both in functionality and purpose. It serves as a shared inbox for server staff to communicate with their users in a seamless way." license = 'AGPL-3.0-only' authors = [ From e76412148f0c2f08f0ebbb2a41695508ac280d18 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 5 Oct 2025 23:53:35 +0200 Subject: [PATCH 04/39] Fix: closing with timed words/ command in reply. --- CHANGELOG.md | 2 ++ bot.py | 1 - cogs/utility.py | 56 +++++++++++++++++++++++++++++++++++++++++++------ core/time.py | 28 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb4706eff..921d3dde52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Upgraded discord.py to version 2.6.3, added support for CV2. - 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` wouldnt work. +- Fixed the command from in rare conditions being included in the reply. ### Added Commands: diff --git a/bot.py b/bot.py index 089a88dc14..671d9ab9c4 100644 --- a/bot.py +++ b/bot.py @@ -2035,4 +2035,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/cogs/utility.py b/cogs/utility.py index 4387a0b653..aa6c6881e9 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1363,7 +1363,18 @@ async def permissions_add( key = self.bot.modmail_guild.get_member(value) if key is not None: logger.info("Granting %s access to Modmail category.", key.name) - await self.bot.main_category.set_permissions(key, read_messages=True) + try: + await self.bot.main_category.set_permissions(key, read_messages=True) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) embed = discord.Embed( title="Success", @@ -1454,17 +1465,50 @@ async def permissions_remove( if level > PermissionLevel.REGULAR: if value == -1: logger.info("Denying @everyone access to Modmail category.") - await self.bot.main_category.set_permissions( - self.bot.modmail_guild.default_role, read_messages=False - ) + try: + await self.bot.main_category.set_permissions( + self.bot.modmail_guild.default_role, read_messages=False + ) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) elif isinstance(user_or_role, discord.Role): logger.info("Denying %s access to Modmail category.", user_or_role.name) - await self.bot.main_category.set_permissions(user_or_role, overwrite=None) + try: + await self.bot.main_category.set_permissions(user_or_role, overwrite=None) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) else: member = self.bot.modmail_guild.get_member(value) if member is not None and member != self.bot.modmail_guild.me: logger.info("Denying %s access to Modmail category.", member.name) - await self.bot.main_category.set_permissions(member, overwrite=None) + try: + await self.bot.main_category.set_permissions(member, overwrite=None) + except discord.Forbidden: + warn = discord.Embed( + title="Missing Permissions", + color=self.bot.error_color, + description=( + "I couldn't update the Modmail category permissions. " + "Please grant me 'Manage Channels' and 'Manage Roles' for this category." + ), + ) + await ctx.send(embed=warn) embed = discord.Embed( title="Success", diff --git a/core/time.py b/core/time.py index 71f4ca3c8a..a8c474f74e 100644 --- a/core/time.py +++ b/core/time.py @@ -160,6 +160,14 @@ def __init__(self, dt: datetime.datetime, now: datetime.datetime = None): async def ensure_constraints( self, ctx: Context, uft: UserFriendlyTime, now: datetime.datetime, remaining: str ) -> None: + # Strip stray connector words like "in", "to", or "at" that may + # remain when the natural language parser isolates the time token + # positioned at the end (e.g. "in 10m" leaves "in" before the token). + if isinstance(remaining, str): + cleaned = remaining.strip(" ,.!") + if cleaned.lower() in {"in", "to", "at", "me"}: + remaining = "" + if self.dt < now: raise commands.BadArgument("This time is in the past.") @@ -199,6 +207,26 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if now is None: now = ctx.message.created_at + # Heuristic: If the user provides only certain single words that are commonly + # used as salutations or vague times of day, interpret them as a message + # rather than a schedule. This avoids accidental scheduling when the intent + # is a short message (e.g. '?close evening'). Explicit scheduling still works + # via 'in 2h', '2m30s', 'at 8pm', etc. + if argument.strip().lower() in { + "evening", + "night", + "midnight", + "morning", + "afternoon", + "tonight", + "noon", + "today", + "tomorrow", + }: + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + match = regex.match(argument) if match is not None and match.group(0): data = {k: int(v) for k, v in match.groupdict(default=0).items()} From 7e3ce81c921582d4abfee54ad8548c23598acf1f Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 5 Oct 2025 23:56:34 +0200 Subject: [PATCH 05/39] Fix: typing in changelog command. --- core/paginator.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/core/paginator.py b/core/paginator.py index d0b10c0b4b..7356804ccb 100644 --- a/core/paginator.py +++ b/core/paginator.py @@ -156,9 +156,20 @@ async def run(self) -> typing.Optional[Message]: if not self.running: await self.show_page(self.current) - if self.view is not None: - await self.view.wait() - + # Don't block command execution while waiting for the View timeout. + # Schedule the wait-and-close sequence in the background so the command + # returns immediately (prevents typing indicator from hanging). + if self.view is not None: + + async def _wait_and_close(): + try: + await self.view.wait() + finally: + await self.close(delete=False) + + # Fire and forget + self.ctx.bot.loop.create_task(_wait_and_close()) + else: await self.close(delete=False) async def close( From 48c4d5aeaa3dcca5ed197347c37e58be696d92f9 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 6 Oct 2025 00:12:23 +0200 Subject: [PATCH 06/39] Fix: closing with timed words (additional)) --- core/time.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/core/time.py b/core/time.py index a8c474f74e..b6ce91f419 100644 --- a/core/time.py +++ b/core/time.py @@ -165,7 +165,23 @@ async def ensure_constraints( # positioned at the end (e.g. "in 10m" leaves "in" before the token). if isinstance(remaining, str): cleaned = remaining.strip(" ,.!") - if cleaned.lower() in {"in", "to", "at", "me"}: + stray_tokens = { + "in", + "to", + "at", + "me", + # also treat vague times of day as stray tokens when they are the only leftover word + "evening", + "night", + "midnight", + "morning", + "afternoon", + "tonight", + "noon", + "today", + "tomorrow", + } + if cleaned.lower() in stray_tokens: remaining = "" if self.dt < now: @@ -273,7 +289,10 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if not status.hasDateOrTime: raise commands.BadArgument('Invalid time provided, try e.g. "tomorrow" or "3 days".') - if begin not in (0, 1) and end != len(argument): + # If the parsed time token is embedded in the text but only followed by + # trailing punctuation/whitespace, treat it as if it's positioned at the end. + trailing = argument[end:].strip(" ,.!") + if begin not in (0, 1) and trailing != "": raise commands.BadArgument( "Time is either in an inappropriate location, which " "must be either at the end or beginning of your input, " @@ -288,6 +307,20 @@ async def convert(self, ctx: Context, argument: str, *, now=None) -> FriendlyTim if status.accuracy == pdt.pdtContext.ACU_HALFDAY: dt = dt.replace(day=now.day + 1) + # Heuristic: If the matched time string is a vague time-of-day (e.g., + # 'evening', 'morning', 'afternoon', 'night') and there's additional + # non-punctuation text besides that token, assume the user intended a + # closing message rather than scheduling. This avoids cases like + # '?close Have a good evening!' being treated as a scheduled close. + vague_tod = {"evening", "morning", "afternoon", "night"} + matched_text = dt_string.strip().strip('"').rstrip(" ,.!").lower() + pre_text = argument[:begin].strip(" ,.!") + post_text = argument[end:].strip(" ,.!") + if matched_text in vague_tod and (pre_text or post_text): + result = FriendlyTimeResult(now) + await result.ensure_constraints(ctx, self, now, argument) + return result + result = FriendlyTimeResult(dt.replace(tzinfo=datetime.timezone.utc), now) remaining = "" From 7e958fc0ae8031246262505e9d8e58d49ce36399 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:27:21 +0200 Subject: [PATCH 07/39] Fix changelog entry for command reply issue Corrected wording in the changelog entry regarding command inclusion in replies. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 921d3dde52..e3b230ea6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Upgraded discord.py to version 2.6.3, added support for CV2. - 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` wouldnt work. -- Fixed the command from in rare conditions being included in the reply. +- Fixed the command from being included in the reply in rare conditions. ### Added Commands: From e853585f0ca4c5ee490b43a30fb9c1f489617d42 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:28:32 +0200 Subject: [PATCH 08/39] Update CHANGELOG for v4.2.0 enhancements Forwarded messages now display correctly in threads. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b230ea6c..80bff1b0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +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 then showing as an empty embed. ### Fixed - Make Modmail keep working when typing is disabled due to a outage caused by Discord. From d89f4054b81c04dbde450a485cce20ff9a9d462e Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 8 Oct 2025 17:32:08 +0200 Subject: [PATCH 09/39] fix; raceconditions, thread duplication on unsnooze, message queue for accurasy on high load --- bot.py | 48 ++++++++++++++---- cogs/modmail.py | 24 ++++++++- core/thread.py | 128 ++++++++++++++++++++++++++++++++++++++--------- requirements.txt | 4 +- 4 files changed, 167 insertions(+), 37 deletions(-) diff --git a/bot.py b/bot.py index 671d9ab9c4..a244c672e6 100644 --- a/bot.py +++ b/bot.py @@ -90,6 +90,7 @@ def __init__(self): 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): @@ -880,6 +881,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) @@ -1055,13 +1086,7 @@ 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)}} - ) - # Re-fetch the thread object to ensure channel is valid - thread = await self.threads.find(recipient=message.author) + # No need to re-fetch the thread - it's already restored and cached properly if thread is None: delta = await self.get_thread_cooldown(message.author) @@ -1356,7 +1381,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: @@ -1676,7 +1701,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: diff --git a/cogs/modmail.py b/cogs/modmail.py index a63eea9103..8abacabdeb 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1522,6 +1522,14 @@ 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 asyncio.sleep(3) + await ctx.message.delete() + except (discord.Forbidden, discord.NotFound): + pass @note.command(name="persistent", aliases=["persist"]) @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -1535,6 +1543,14 @@ 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 asyncio.sleep(3) + await ctx.message.delete() + except (discord.Forbidden, discord.NotFound): + pass @commands.command() @checks.has_permissions(PermissionLevel.SUPPORTER) @@ -2277,10 +2293,16 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): await ctx.send("This thread is already snoozed.") logging.info(f"[SNOOZE] Thread for {getattr(thread.recipient, 'id', None)} already snoozed.") return + from core.time import ShortTime + max_snooze = self.bot.config.get("max_snooze_time") if max_snooze is None: max_snooze = 604800 - max_snooze = int(max_snooze) + else: + try: + max_snooze = int((ShortTime(str(max_snooze)).dt - ShortTime("0s").dt).total_seconds()) + except Exception: + max_snooze = 604800 if duration: snooze_for = int((duration.dt - duration.now).total_seconds()) if snooze_for > max_snooze: diff --git a/core/thread.py b/core/thread.py index d380552694..5c1bd14492 100644 --- a/core/thread.py +++ b/core/thread.py @@ -180,18 +180,18 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): ) else None ), - "author_name": getattr(m.author, "name", None), + "author_name": ( + getattr(m.embeds[0].author, "name", "").split(" (")[0] + if m.embeds and m.embeds[0].author and m.author == self.bot.user + else getattr(m.author, "name", None) if m.author != self.bot.user else None + ), + "author_avatar": ( + getattr(m.embeds[0].author, "icon_url", None) + if m.embeds and m.embeds[0].author and m.author == self.bot.user + else m.author.display_avatar.url if m.author != self.bot.user else None + ), } async for m in channel.history(limit=None, oldest_first=True) - if not ( - m.embeds - and getattr(m.embeds[0], "author", None) - and ( - getattr(m.embeds[0].author, "name", "").startswith("πŸ“ Note") - or getattr(m.embeds[0].author, "name", "").startswith("πŸ“ Persistent Note") - ) - ) - and getattr(m, "type", None) not in ("internal", "note") ], "snoozed_by": getattr(moderator, "name", None) if moderator else None, "snooze_command": command_used, @@ -254,7 +254,12 @@ async def restore_from_snooze(self): self.log_key = self.snooze_data.get("log_key") # Replay messages for msg in self.snooze_data["messages"]: - author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user(msg["author_id"]) + try: + author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( + msg["author_id"] + ) + except discord.NotFound: + author = None content = msg["content"] embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] attachments = msg.get("attachments", []) @@ -264,12 +269,16 @@ async def restore_from_snooze(self): continue # Skip empty messages author_is_mod = msg["author_id"] not in [r.id for r in self.recipients] if author_is_mod: - username = msg.get("author_name") or (getattr(author, "name", None)) or "Unknown" + # Prioritize stored author_name from snooze data over fetched user + username = ( + msg.get("author_name") or (getattr(author, "name", None) if author else None) or "Unknown" + ) user_id = msg.get("author_id") if embeds: embeds[0].set_author( name=f"{username} ({user_id})", - icon_url=( + icon_url=msg.get("author_avatar") + or ( author.display_avatar.url if author and hasattr(author, "display_avatar") else None @@ -811,31 +820,73 @@ async def find_linked_messages( note: bool = True, ) -> typing.Tuple[discord.Message, typing.List[typing.Optional[discord.Message]]]: if message1 is not None: - if not message1.embeds or not message1.embeds[0].author.url or message1.author != self.bot.user: - raise ValueError("Malformed thread message.") + if note: + # For notes, don't require author.url; rely on footer/author.name markers + if not message1.embeds or message1.author != self.bot.user: + logger.warning( + f"Malformed note for deletion: embeds={bool(message1.embeds)}, author={message1.author}" + ) + raise ValueError("Malformed note message.") + else: + if ( + not message1.embeds + or not message1.embeds[0].author.url + or message1.author != self.bot.user + ): + logger.debug( + f"Malformed thread message for deletion: embeds={bool(message1.embeds)}, author_url={getattr(message1.embeds[0], 'author', None) and message1.embeds[0].author.url}, author={message1.author}" + ) + # Keep original error string to avoid extra failure embeds in on_message_delete + raise ValueError("Malformed thread message.") elif message_id is not None: try: message1 = await self.channel.fetch_message(message_id) except discord.NotFound: + logger.warning(f"Message ID {message_id} not found in channel history.") raise ValueError("Thread message not found.") + if note: + # Try to treat as note/persistent note first + if message1.embeds and message1.author == self.bot.user: + footer_text = (message1.embeds[0].footer and message1.embeds[0].footer.text) or "" + author_name = getattr(message1.embeds[0].author, "name", "") or "" + is_note = ( + "internal note" in footer_text.lower() + or "persistent internal note" in footer_text.lower() + or author_name.startswith("πŸ“ Note") + or author_name.startswith("πŸ“ Persistent Note") + ) + if is_note: + # Notes have no linked DM counterpart; keep None sentinel + return message1, None + # else: fall through to relay checks below + + # Non-note path (regular relayed messages): require author.url and colors if not ( message1.embeds and message1.embeds[0].author.url and message1.embeds[0].color and message1.author == self.bot.user ): + logger.warning( + f"Message {message_id} is not a valid modmail relay message. embeds={bool(message1.embeds)}, author_url={getattr(message1.embeds[0], 'author', None) and message1.embeds[0].author.url}, color={getattr(message1.embeds[0], 'color', None)}, author={message1.author}" + ) raise ValueError("Thread message not found.") if message1.embeds[0].footer and "Internal Message" in message1.embeds[0].footer.text: if not note: - raise ValueError("Thread message not found.") + logger.warning( + f"Message {message_id} is an internal message, but note deletion not requested." + ) + raise ValueError("Thread message is an internal message, not a note.") + # Internal bot-only message treated similarly; keep None sentinel return message1, None if message1.embeds[0].color.value != self.bot.mod_color and not ( either_direction and message1.embeds[0].color.value == self.bot.recipient_color ): + logger.warning("Message color does not match mod/recipient colors.") raise ValueError("Thread message not found.") else: async for message1 in self.channel.history(): @@ -891,7 +942,7 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1.description = message tasks = [self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1)] - if message1.embeds[0].footer and "Persistent Internal Message" in message1.embeds[0].footer.text: + if message1.embeds[0].footer and "Persistent Internal Note" in message1.embeds[0].footer.text: tasks += [self.bot.api.edit_note(message1.id, message)] else: for m2 in message2: @@ -910,15 +961,14 @@ async def delete_message( else: message1, *message2 = await self.find_linked_messages(message, note=note) tasks = [] - - if not isinstance(message, discord.Message): - tasks += [message1.delete()] + # Always delete the primary thread message + tasks += [message1.delete()] for m2 in message2: if m2 is not None: tasks += [m2.delete()] - if message1.embeds[0].footer and "Persistent Internal Message" in message1.embeds[0].footer.text: + if message1.embeds[0].footer and "Persistent Internal Note" in message1.embeds[0].footer.text: tasks += [self.bot.api.delete_note(message1.id)] if tasks: @@ -1457,12 +1507,25 @@ def lottie_to_png(data): ): logger.info("Sending a message to %s when DM disabled is set.", self.recipient) - # Best-effort typing: never block message delivery if typing fails + # Best-effort typing with snooze-aware retry: if channel was deleted during snooze, restore and retry once + restored = False try: await destination.typing() except discord.NotFound: - logger.warning("Channel not found.") - raise + # Unknown Channel: if snoozed or we have snooze data, attempt to restore and retry once + if isinstance(destination, discord.TextChannel) and (self.snoozed or self.snooze_data): + logger.info("Thread channel missing while typing; attempting restore from snooze.") + try: + await self.restore_from_snooze() + destination = self.channel or destination + restored = True + await destination.typing() + except Exception as e: + logger.warning("Restore/typing retry failed: %s", e) + raise + else: + logger.warning("Channel not found.") + raise except (discord.Forbidden, discord.HTTPException, Exception) as e: logger.warning("Unable to send typing to %s: %s. Continuing without typing.", destination, e) @@ -1495,7 +1558,22 @@ def lottie_to_png(data): msg = await destination.send(mentions, embed=embed) else: - msg = await destination.send(mentions, embed=embed) + try: + msg = await destination.send(mentions, embed=embed) + except discord.NotFound: + # If channel vanished right before send, try to restore and resend once + if ( + isinstance(destination, discord.TextChannel) + and (self.snoozed or self.snooze_data) + and not restored + ): + logger.info("Thread channel missing while sending; attempting restore and resend.") + await self.restore_from_snooze() + destination = self.channel or destination + msg = await destination.send(mentions, embed=embed) + else: + logger.warning("Channel not found during send.") + raise if additional_images: self.ready = False diff --git a/requirements.txt b/requirements.txt index 0aa1206d2f..636a9ecf6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ frozenlist==1.7.0; python_version >= '3.9' idna==3.10; python_version >= '3.6' isodate==0.6.1 lottie[pdf]==0.7.0; python_version >= '3' -motor==3.3.2; python_version >= '3.7' +motor==3.1.2; python_version >= '3.7' multidict==6.6.4; python_version >= '3.9' natural==0.2.0 orjson==3.11.3; python_version >= '3.9' @@ -30,7 +30,7 @@ pillow==11.3.0; python_version >= '3.9' propcache==0.4.0; python_version >= '3.9' pycares==4.11.0; python_version >= '3.9' pycparser==2.23; python_version >= '3.8' -pymongo[srv]==4.15.2; python_version >= '3.9' +pymongo[srv]==4.3.3; python_version >= '3.9' python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' python-dotenv==1.0.0; python_version >= '3.8' requests==2.31.0; python_version >= '3.7' From aa2810714d19fc0d6615bf8c17853ca864725bdf Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:34:03 +0200 Subject: [PATCH 10/39] Update package versions in requirements.txt Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- requirements.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 636a9ecf6f..8530e8e0d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,11 +16,11 @@ defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, discord.py[speed]==2.6.3; python_version >= '3.8' dnspython==2.8.0; python_version >= '3.10' emoji==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -frozenlist==1.7.0; python_version >= '3.9' +frozenlist==1.8.0; python_version >= '3.9' idna==3.10; python_version >= '3.6' isodate==0.6.1 lottie[pdf]==0.7.0; python_version >= '3' -motor==3.1.2; python_version >= '3.7' +motor==3.3.2; python_version >= '3.7' multidict==6.6.4; python_version >= '3.9' natural==0.2.0 orjson==3.11.3; python_version >= '3.9' @@ -30,13 +30,14 @@ pillow==11.3.0; python_version >= '3.9' propcache==0.4.0; python_version >= '3.9' pycares==4.11.0; python_version >= '3.9' pycparser==2.23; python_version >= '3.8' -pymongo[srv]==4.3.3; python_version >= '3.9' +pymongo[srv]==4.15.2; python_version >= '3.9' python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' python-dotenv==1.0.0; python_version >= '3.8' requests==2.31.0; python_version >= '3.7' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' tinycss2==1.4.0; python_version >= '3.8' urllib3==2.5.0; python_version >= '3.9' +uvloop==0.21.0; sys_platform != 'win32' webencodings==0.5.1 yarl==1.21.0; python_version >= '3.9' zstandard==0.25.0; python_version >= '3.9' From 6181467ad8fbbea057d6d14c4dac3fde6ac6f074 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 8 Oct 2025 19:11:25 +0200 Subject: [PATCH 11/39] snooze(move): auto-unsnooze on reply/any mod message; enforce hidden permissions on auto-created Snoozed Threads and sync perms on move; restore original overwrites on unsnooze; add capacity guard and config docs --- CHANGELOG.md | 6 + bot.py | 21 ++++ cogs/modmail.py | 87 ++++++++++++- cogs/utility.py | 25 ++++ core/config.py | 3 + core/config_help.json | 23 ++++ core/thread.py | 275 +++++++++++++++++++++++++++++++++--------- core/utils.py | 2 +- 8 files changed, 379 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3269ea042..3c54e4c5c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,17 @@ Configuration Options: * `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. +* `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. +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. + # v4.1.2 ### Fixed diff --git a/bot.py b/bot.py index a244c672e6..d29846f033 100644 --- a/bot.py +++ b/bot.py @@ -1398,6 +1398,27 @@ async def process_commands(self, message): 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 + try: + behavior = (self.config.get("snooze_behavior") or "delete").lower() + except Exception: + behavior = "delete" + 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: + pass + 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"): diff --git a/cogs/modmail.py b/cogs/modmail.py index 8abacabdeb..5537914f85 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -2284,9 +2284,11 @@ 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: max_snooze_time, snooze_title, snooze_text """ thread = ctx.thread if thread.snoozed: @@ -2310,6 +2312,85 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): else: snooze_for = max_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: + # 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: + pass + 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: + pass + # Storing snooze_start and snooze_for in the log entry now = datetime.now(timezone.utc) await self.bot.api.logs.update_one( diff --git a/cogs/utility.py b/cogs/utility.py index aa6c6881e9..3c6c4c1cb4 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -810,6 +810,31 @@ async def config_set(self, ctx, key: str.lower, *, value: str): color=self.bot.main_color, description=f"Set `{key}` to `{self.bot.config[key]}`.", ) + # If turning on move-based snoozing, remind to set snoozed_category_id + if key == "snooze_behavior": + behavior = ( + str(self.bot.config.get("snooze_behavior", convert=False)).strip().lower().strip('"') + ) + if behavior == "move": + cat_id = self.bot.config.get("snoozed_category_id", convert=False) + valid = False + if cat_id: + try: + cat_obj = self.bot.modmail_guild.get_channel(int(str(cat_id))) + valid = isinstance(cat_obj, discord.CategoryChannel) + except Exception: + valid = False + if not valid: + example = f"`{self.bot.prefix}config set snoozed_category_id `" + embed.add_field( + name="Action required", + value=( + "You set `snooze_behavior` to `move`. Please set `snoozed_category_id` " + "to the category where snoozed threads should be moved.\n" + f"For example: {example}" + ), + inline=False, + ) except InvalidConfigError as exc: embed = exc.embed else: diff --git a/core/config.py b/core/config.py index a704443bb1..ba4e7a06a2 100644 --- a/core/config.py +++ b/core/config.py @@ -140,6 +140,9 @@ class ConfigManager: "snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.", "unsnooze_text": "This thread has been unsnoozed and restored.", "unsnooze_notify_channel": "thread", # Can be a channel ID or 'thread' for the thread's own channel + # snooze behavior + "snooze_behavior": "delete", # 'delete' to delete channel, 'move' to move channel to snoozed_category_id + "snoozed_category_id": None, # Category ID to move snoozed channels into when snooze_behavior == 'move' } private_keys = { diff --git a/core/config_help.json b/core/config_help.json index 462e4e1c03..39eae08a2b 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1322,5 +1322,28 @@ "If set to a channel ID, the notification will be sent to that specific channel.", "See also: `unsnooze_text`, `max_snooze_time`." ] + }, + "snooze_behavior": { + "default": "\"delete\"", + "description": "Controls how snoozing behaves. 'delete' removes the thread channel and restores it later; 'move' moves the channel into the 'snoozed_category_id' without deleting it.", + "examples": [ + "`{prefix}config set snooze_behavior delete`", + "`{prefix}config set snooze_behavior move`" + ], + "notes": [ + "When set to 'move', set `snoozed_category_id` to a valid Category ID.", + "When unsnoozed, channels moved will return to their original category and position when possible; if original no longer exists they will be moved under `main_category_id`." + ] + }, + "snoozed_category_id": { + "default": "None", + "description": "The category ID where snoozed threads are moved when `snooze_behavior` is set to 'move'.", + "examples": [ + "`{prefix}config set snoozed_category_id 123456789012345678`" + ], + "notes": [ + "Only used when `snooze_behavior` is 'move'.", + "If not set or invalid, the channel will remain in its current category or the bot will fall back to deleting on failure." + ] } } \ No newline at end of file diff --git a/core/thread.py b/core/thread.py index 5c1bd14492..f27ef3008e 100644 --- a/core/thread.py +++ b/core/thread.py @@ -133,13 +133,38 @@ def cancelled(self, flag: bool): async def snooze(self, moderator=None, command_used=None, snooze_for=None): """ - Save channel/category/position/messages to DB, mark as snoozed, delete channel. + Save channel/category/position/messages to DB, mark as snoozed. + Behavior is configurable: + - delete (default): delete the channel and store all data for full restore later + - move: move channel to a configured snoozed category and hide it (keeps channel alive) """ if self.snoozed: return False # Already snoozed channel = self.channel if not isinstance(channel, discord.TextChannel): return False + # If using move-based snooze, hard-cap snoozed category to 49 channels + behavior_pre = (self.bot.config.get("snooze_behavior") or "delete").lower() + if behavior_pre == "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 + if isinstance(target_category, discord.CategoryChannel): + try: + if len(target_category.channels) >= 49: + logger.warning( + "Snoozed category (%s) is full (>=49 channels). Blocking snooze for thread %s.", + target_category.id, + self.id, + ) + return False + except Exception: + # If we cannot determine channel count, proceed; downstream will handle errors + pass # Ensure self.log_key is set before snoozing if not self.log_key: # Try to fetch from DB using channel_id @@ -213,14 +238,79 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): import logging logging.info(f"[SNOOZE] DB update result: {result.modified_count}") - # Delete channel - await channel.delete(reason="Thread snoozed by moderator") - self._channel = None + + behavior = behavior_pre + if behavior == "move": + # Move the channel to the snoozed category (if configured) and optionally apply a prefix + snoozed_cat_id = self.bot.config.get("snoozed_category_id") + target_category = None + guild = self.bot.modmail_guild + if snoozed_cat_id: + try: + target_category = guild.get_channel(int(snoozed_cat_id)) + except Exception: + target_category = None + # If no valid snooze category is configured, create one automatically + if not isinstance(target_category, discord.CategoryChannel): + try: + # By default, hide the snoozed category from everyone and allow only the bot to see it + overwrites = {guild.default_role: discord.PermissionOverwrite(view_channel=False)} + bot_member = 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 guild.create_category( + name="Snoozed Threads", + overwrites=overwrites, + reason="Auto-created snoozed category for move-based snoozing", + ) + # Persist the newly created category ID into config for future runs + try: + await self.bot.config.set("snoozed_category_id", target_category.id) + await self.bot.config.update() + except Exception: + logger.warning("Failed to persist snoozed_category_id after auto-creation.") + except Exception as e: + logger.warning( + "Failed to auto-create snoozed category (%s). Falling back to current category.", e + ) + target_category = channel.category + try: + # Move and sync permissions so the channel inherits the hidden snoozed-category perms + await channel.edit( + category=target_category, + reason="Thread snoozed (moved)", + sync_permissions=True, + ) + # Keep channel reference; just moved + self._channel = channel + # mark in snooze data that this was a move-based snooze + self.snooze_data["moved"] = True + except Exception as e: + logger.warning("Failed to move channel to snoozed category: %s. Falling back to delete.", e) + await channel.delete(reason="Thread snoozed by moderator (fallback delete)") + self._channel = None + else: + # Delete channel + await channel.delete(reason="Thread snoozed by moderator") + self._channel = None return True async def restore_from_snooze(self): """ - Recreate channel in original category/position, replay messages, mark as not snoozed. + Restore a snoozed thread. + - If channel was deleted (delete behavior), recreate and replay messages. + - If channel was moved (move behavior), move back to original category and position. + Mark as not snoozed and clear snooze data. """ if not self.snooze_data or not isinstance(self.snooze_data, dict): import logging @@ -233,65 +323,111 @@ async def restore_from_snooze(self): snoozed_by = self.snooze_data.get("snoozed_by") snooze_command = self.snooze_data.get("snooze_command") guild = self.bot.modmail_guild - category = guild.get_channel(self.snooze_data["category_id"]) - overwrites = {} - for role_id, perm_values in self.snooze_data["overwrites"]: - role = guild.get_role(role_id) or guild.get_member(role_id) - if role: - overwrites[role] = discord.PermissionOverwrite(**perm_values) - channel = await guild.create_text_channel( - name=self.snooze_data["name"], - category=category, - topic=self.snooze_data["topic"], - slowmode_delay=self.snooze_data["slowmode_delay"], - overwrites=overwrites, - nsfw=self.snooze_data["nsfw"], - position=self.snooze_data["position"], - reason="Thread unsnoozed/restored", + behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + # Determine original category; fall back to main_category_id if original missing + orig_category = ( + guild.get_channel(self.snooze_data["category_id"]) + if self.snooze_data.get("category_id") + else None ) - self._channel = channel - # Strictly restore the log_key from snooze_data (never create a new one) - self.log_key = self.snooze_data.get("log_key") - # Replay messages - for msg in self.snooze_data["messages"]: + if not isinstance(orig_category, discord.CategoryChannel): + main_cat_id = self.bot.config.get("main_category_id") + orig_category = guild.get_channel(int(main_cat_id)) if main_cat_id else None + + if behavior == "move" and isinstance(self.channel, discord.TextChannel): + # Channel exists but is snoozed in another category; move back try: - author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( - msg["author_id"] - ) - except discord.NotFound: - author = None - content = msg["content"] - embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] - attachments = msg.get("attachments", []) - msg_type = msg.get("type") - # Only send if there is content, embeds, or attachments - if not content and not embeds and not attachments: - continue # Skip empty messages - author_is_mod = msg["author_id"] not in [r.id for r in self.recipients] - if author_is_mod: - # Prioritize stored author_name from snooze data over fetched user - username = ( - msg.get("author_name") or (getattr(author, "name", None) if author else None) or "Unknown" + await self.channel.edit( + category=orig_category, + position=self.snooze_data.get("position", self.channel.position), + reason="Thread unsnoozed/restored", ) - user_id = msg.get("author_id") - if embeds: - embeds[0].set_author( - name=f"{username} ({user_id})", - icon_url=msg.get("author_avatar") - or ( - author.display_avatar.url - if author and hasattr(author, "display_avatar") - else None - ), + # After moving back, restore original overwrites captured at snooze time + try: + overwrites = {} + for role_id, perm_values in self.snooze_data.get("overwrites", []): + target = guild.get_role(role_id) or guild.get_member(role_id) + if target: + overwrites[target] = discord.PermissionOverwrite(**perm_values) + if overwrites: + await self.channel.edit(overwrites=overwrites, reason="Restore original overwrites") + except Exception as e: + logger.warning("Failed to restore original overwrites on unsnooze: %s", e) + + channel = self.channel + except Exception as e: + logger.warning("Failed to move snoozed channel back, recreating: %s", e) + channel = None + else: + channel = None + + if channel is None: + # Recreate channel and replay messages (delete behavior or move fallback) + overwrites = {} + for role_id, perm_values in self.snooze_data["overwrites"]: + role = guild.get_role(role_id) or guild.get_member(role_id) + if role: + overwrites[role] = discord.PermissionOverwrite(**perm_values) + channel = await guild.create_text_channel( + name=self.snooze_data["name"], + category=orig_category, + topic=self.snooze_data["topic"], + slowmode_delay=self.snooze_data["slowmode_delay"], + overwrites=overwrites, + nsfw=self.snooze_data["nsfw"], + position=self.snooze_data["position"], + reason="Thread unsnoozed/restored", + ) + self._channel = channel + else: + self._channel = channel + # Strictly restore the log_key from snooze_data (never create a new one) + self.log_key = self.snooze_data.get("log_key") + # Replay messages only if we re-created the channel (delete behavior or move fallback) + if behavior != "move" or (behavior == "move" and not self.snooze_data.get("moved", False)): + for msg in self.snooze_data["messages"]: + try: + author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( + msg["author_id"] ) - await channel.send(embeds=embeds) - else: - formatted = ( - f"**{username} ({user_id})**: {content}" if content else f"**{username} ({user_id})**" + except discord.NotFound: + author = None + content = msg["content"] + embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] + attachments = msg.get("attachments", []) + msg_type = msg.get("type") + # Only send if there is content, embeds, or attachments + if not content and not embeds and not attachments: + continue # Skip empty messages + author_is_mod = msg["author_id"] not in [r.id for r in self.recipients] + if author_is_mod: + # Prioritize stored author_name from snooze data over fetched user + username = ( + msg.get("author_name") + or (getattr(author, "name", None) if author else None) + or "Unknown" ) - await channel.send(formatted) - else: - await channel.send(content=content or None, embeds=embeds or None) + user_id = msg.get("author_id") + if embeds: + embeds[0].set_author( + name=f"{username} ({user_id})", + icon_url=msg.get("author_avatar") + or ( + author.display_avatar.url + if author and hasattr(author, "display_avatar") + else None + ), + ) + await channel.send(embeds=embeds) + else: + formatted = ( + f"**{username} ({user_id})**: {content}" + if content + else f"**{username} ({user_id})**" + ) + await channel.send(formatted) + else: + await channel.send(content=content or None, embeds=embeds or None) self.snoozed = False # Store snooze_data for notification before clearing snooze_data_for_notify = self.snooze_data @@ -1077,6 +1213,27 @@ async def reply( self, message: discord.Message, anonymous: bool = False, plain: bool = False ) -> typing.Tuple[typing.List[discord.Message], discord.Message]: """Returns List[user_dm_msg] and thread_channel_msg""" + # If this thread was snoozed using move-behavior, unsnooze automatically when a mod replies + try: + behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + except Exception: + behavior = "delete" + if self.snoozed and behavior == "move": + # Ensure we have snooze_data to restore location + if not self.snooze_data: + try: + log_entry = await self.bot.api.logs.find_one( + {"recipient.id": str(self.id), "snoozed": True} + ) + if log_entry: + self.snooze_data = log_entry.get("snooze_data") + except Exception: + pass + try: + await self.restore_from_snooze() + except Exception as e: + logger.warning("Auto-unsnooze on reply failed: %s", e) + if not message.content and not message.attachments and not message.stickers: raise MissingRequiredArgument(DummyParam("msg")) for guild in self.bot.guilds: diff --git a/core/utils.py b/core/utils.py index 19ff4cd37b..69f831f85f 100644 --- a/core/utils.py +++ b/core/utils.py @@ -369,7 +369,7 @@ def create_not_found_embed(word, possibilities, name, n=2, cutoff=0.6) -> discor def parse_alias(alias, *, split=True): def encode_alias(m): - return "\x1AU" + base64.b64encode(m.group(1).encode()).decode() + "\x1AU" + return "\x1aU" + base64.b64encode(m.group(1).encode()).decode() + "\x1aU" def decode_alias(m): return base64.b64decode(m.group(1).encode()).decode() From 19c066851813061d4bb8b34298a6d1346491f1cb Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 9 Oct 2025 21:04:19 +0200 Subject: [PATCH 12/39] unsnooze: suppress mentions during restore (AllowedMentions.none on replay and notifications) --- core/thread.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/core/thread.py b/core/thread.py index f27ef3008e..479295869b 100644 --- a/core/thread.py +++ b/core/thread.py @@ -418,16 +418,24 @@ async def restore_from_snooze(self): else None ), ) - await channel.send(embeds=embeds) + await channel.send( + embeds=embeds, allowed_mentions=discord.AllowedMentions.none() + ) else: formatted = ( f"**{username} ({user_id})**: {content}" if content else f"**{username} ({user_id})**" ) - await channel.send(formatted) + await channel.send( + formatted, allowed_mentions=discord.AllowedMentions.none() + ) else: - await channel.send(content=content or None, embeds=embeds or None) + await channel.send( + content=content or None, + embeds=embeds or None, + allowed_mentions=discord.AllowedMentions.none(), + ) self.snoozed = False # Store snooze_data for notification before clearing snooze_data_for_notify = self.snooze_data @@ -458,18 +466,23 @@ async def restore_from_snooze(self): notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." if notify_channel == "thread": - await channel.send(notify_text) + await channel.send( + notify_text, allowed_mentions=discord.AllowedMentions.none() + ) else: ch = self.bot.get_channel(int(notify_channel)) if ch: - await ch.send(f"Thread for user <@{self.id}> has been unsnoozed and restored.") + await ch.send( + f"Thread for user <@{self.id}> has been unsnoozed and restored.", + allowed_mentions=discord.AllowedMentions.none(), + ) # Show who ran the snooze command and the command used # Use snooze_data_for_notify to avoid accessing self.snooze_data after it is set to None snoozed_by = snooze_data_for_notify.get("snoozed_by") if snooze_data_for_notify else None snooze_command = snooze_data_for_notify.get("snooze_command") if snooze_data_for_notify else None if snoozed_by or snooze_command: info = f"Snoozed by: {snoozed_by or 'Unknown'} | Command: {snooze_command or '?snooze'}" - await channel.send(info) + await channel.send(info, allowed_mentions=discord.AllowedMentions.none()) return True @classmethod From 0cb515061d11b047237c8e015136085f8c725c6f Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 9 Oct 2025 22:00:33 +0200 Subject: [PATCH 13/39] Remove base64 snooze/unsnooze logic, fix notification crash, clean up replay logic --- core/thread.py | 110 ++++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/core/thread.py b/core/thread.py index 479295869b..2a98721f86 100644 --- a/core/thread.py +++ b/core/thread.py @@ -319,14 +319,17 @@ async def restore_from_snooze(self): f"[UNSNOOZE] Tried to restore thread {self.id} but snooze_data is None or not a dict." ) return False - # Now safe to access self.snooze_data + + # Cache some fields we need later (before we potentially clear snooze_data) snoozed_by = self.snooze_data.get("snoozed_by") snooze_command = self.snooze_data.get("snooze_command") + guild = self.bot.modmail_guild behavior = (self.bot.config.get("snooze_behavior") or "delete").lower() + # Determine original category; fall back to main_category_id if original missing orig_category = ( - guild.get_channel(self.snooze_data["category_id"]) + guild.get_channel(self.snooze_data.get("category_id")) if self.snooze_data.get("category_id") else None ) @@ -334,23 +337,27 @@ async def restore_from_snooze(self): main_cat_id = self.bot.config.get("main_category_id") orig_category = guild.get_channel(int(main_cat_id)) if main_cat_id else None + # Default: assume we'll need to recreate + channel: typing.Optional[discord.TextChannel] = None + + # If move-behavior and channel still exists, move it back and restore overwrites if behavior == "move" and isinstance(self.channel, discord.TextChannel): - # Channel exists but is snoozed in another category; move back try: await self.channel.edit( category=orig_category, position=self.snooze_data.get("position", self.channel.position), reason="Thread unsnoozed/restored", ) - # After moving back, restore original overwrites captured at snooze time + # Restore original overwrites captured at snooze time try: - overwrites = {} + ow_map: dict = {} for role_id, perm_values in self.snooze_data.get("overwrites", []): target = guild.get_role(role_id) or guild.get_member(role_id) - if target: - overwrites[target] = discord.PermissionOverwrite(**perm_values) - if overwrites: - await self.channel.edit(overwrites=overwrites, reason="Restore original overwrites") + if target is None: + continue + ow_map[target] = discord.PermissionOverwrite(**perm_values) + if ow_map: + await self.channel.edit(overwrites=ow_map, reason="Restore original overwrites") except Exception as e: logger.warning("Failed to restore original overwrites on unsnooze: %s", e) @@ -358,50 +365,55 @@ async def restore_from_snooze(self): except Exception as e: logger.warning("Failed to move snoozed channel back, recreating: %s", e) channel = None - else: - channel = None + # If we couldn't move back (or behavior=delete), recreate the channel if channel is None: - # Recreate channel and replay messages (delete behavior or move fallback) - overwrites = {} - for role_id, perm_values in self.snooze_data["overwrites"]: - role = guild.get_role(role_id) or guild.get_member(role_id) - if role: - overwrites[role] = discord.PermissionOverwrite(**perm_values) - channel = await guild.create_text_channel( - name=self.snooze_data["name"], - category=orig_category, - topic=self.snooze_data["topic"], - slowmode_delay=self.snooze_data["slowmode_delay"], - overwrites=overwrites, - nsfw=self.snooze_data["nsfw"], - position=self.snooze_data["position"], - reason="Thread unsnoozed/restored", - ) - self._channel = channel - else: - self._channel = channel + try: + ow_map: dict = {} + for role_id, perm_values in self.snooze_data.get("overwrites", []): + target = guild.get_role(role_id) or guild.get_member(role_id) + if target is None: + continue + ow_map[target] = discord.PermissionOverwrite(**perm_values) + + channel = await guild.create_text_channel( + name=self.snooze_data.get("name") or f"thread-{self.id}", + category=orig_category, + overwrites=ow_map or None, + position=self.snooze_data.get("position"), + topic=self.snooze_data.get("topic"), + slowmode_delay=self.snooze_data.get("slowmode_delay") or 0, + nsfw=bool(self.snooze_data.get("nsfw")), + reason="Thread unsnoozed/restored (recreated)", + ) + self._channel = channel + except Exception: + logger.error("Failed to recreate thread channel during unsnooze.", exc_info=True) + return False # Strictly restore the log_key from snooze_data (never create a new one) self.log_key = self.snooze_data.get("log_key") + # Replay messages only if we re-created the channel (delete behavior or move fallback) if behavior != "move" or (behavior == "move" and not self.snooze_data.get("moved", False)): - for msg in self.snooze_data["messages"]: + for msg in self.snooze_data.get("messages", []): try: author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( msg["author_id"] ) except discord.NotFound: author = None - content = msg["content"] + + content = msg.get("content") embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] attachments = msg.get("attachments", []) - msg_type = msg.get("type") - # Only send if there is content, embeds, or attachments + + # Only send if there is something to send if not content and not embeds and not attachments: - continue # Skip empty messages + continue + author_is_mod = msg["author_id"] not in [r.id for r in self.recipients] if author_is_mod: - # Prioritize stored author_name from snooze data over fetched user + # Prefer stored author_name/avatar username = ( msg.get("author_name") or (getattr(author, "name", None) if author else None) @@ -409,6 +421,7 @@ async def restore_from_snooze(self): ) user_id = msg.get("author_id") if embeds: + # Ensure embeds show author details embeds[0].set_author( name=f"{username} ({user_id})", icon_url=msg.get("author_avatar") @@ -418,21 +431,22 @@ async def restore_from_snooze(self): else None ), ) - await channel.send( - embeds=embeds, allowed_mentions=discord.AllowedMentions.none() - ) + await channel.send(embeds=embeds, allowed_mentions=discord.AllowedMentions.none()) else: - formatted = ( - f"**{username} ({user_id})**: {content}" - if content - else f"**{username} ({user_id})**" - ) - await channel.send( - formatted, allowed_mentions=discord.AllowedMentions.none() - ) + # Build a non-empty message; include attachment URLs if no content + header = f"**{username} ({user_id})**" + if content: + formatted = f"{header}: {content}" + elif attachments: + formatted = header + "\n" + "\n".join(attachments) + else: + formatted = header + await channel.send(formatted, allowed_mentions=discord.AllowedMentions.none()) else: + # Recipient message: include attachment URLs if content is empty + content_to_send = content if content else ("\n".join(attachments) if attachments else None) await channel.send( - content=content or None, + content=content_to_send, embeds=embeds or None, allowed_mentions=discord.AllowedMentions.none(), ) From f46a668373932551389443dbc0dd8d1abac84018 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 9 Oct 2025 22:03:16 +0200 Subject: [PATCH 14/39] fix: escape mentions on unsnooze --- core/config.py | 5 ++++ core/config_help.json | 24 +++++++++++++++++ core/thread.py | 62 ++++++++++++++++++++++++++----------------- 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/core/config.py b/core/config.py index ba4e7a06a2..faabadf6c9 100644 --- a/core/config.py +++ b/core/config.py @@ -143,6 +143,9 @@ class ConfigManager: # snooze behavior "snooze_behavior": "delete", # 'delete' to delete channel, 'move' to move channel to snoozed_category_id "snoozed_category_id": None, # Category ID to move snoozed channels into when snooze_behavior == 'move' + # attachments persistence for delete-behavior snooze + "snooze_store_attachments": False, # when True, store image attachments as base64 in snooze_data + "snooze_attachment_max_bytes": 4_194_304, # 4 MiB per attachment cap to avoid Mongo 16MB limit } private_keys = { @@ -242,6 +245,8 @@ class ConfigManager: "use_hoisted_top_role", "enable_presence_intent", "registry_plugins_only", + # snooze + "snooze_store_attachments", } enums = { diff --git a/core/config_help.json b/core/config_help.json index 39eae08a2b..8b7b3e9bc6 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1345,5 +1345,29 @@ "Only used when `snooze_behavior` is 'move'.", "If not set or invalid, the channel will remain in its current category or the bot will fall back to deleting on failure." ] + }, + "snooze_store_attachments": { + "default": "No", + "description": "When enabled and `snooze_behavior` is 'delete', image attachments are stored as base64 within the snooze data so they can be re-uploaded on unsnooze, preserving media even if the original channel was deleted.", + "examples": [ + "`{prefix}config set snooze_store_attachments yes`", + "`{prefix}config set snooze_store_attachments no`" + ], + "notes": [ + "Only applies to delete-behavior snoozes. In move-behavior, attachments remain in the channel history.", + "To avoid exceeding Mongo's 16MB document size, consider also adjusting `snooze_attachment_max_bytes`." + ] + }, + "snooze_attachment_max_bytes": { + "default": "4194304 (4 MiB)", + "description": "Maximum size per attachment to store as base64 when `snooze_store_attachments` is enabled.", + "examples": [ + "`{prefix}config set snooze_attachment_max_bytes 2097152` (2 MiB)", + "`{prefix}config set snooze_attachment_max_bytes 0` (disable size check; not recommended)" + ], + "notes": [ + "This cap helps prevent hitting MongoDB's 16MB per-document limit when storing large attachments.", + "Non-image files are not stored as base64 and will be preserved as their original URLs if available." + ] } } \ No newline at end of file diff --git a/core/thread.py b/core/thread.py index 2a98721f86..3bf2bf3622 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1,6 +1,6 @@ import asyncio -import base64 import copy +import base64 import functools import io import re @@ -431,6 +431,14 @@ async def restore_from_snooze(self): else None ), ) + # If there were attachment URLs, include them as a field so mods can access them + if attachments: + try: + embeds[0].add_field( + name="Attachments", value="\n".join(attachments), inline=False + ) + except Exception: + pass await channel.send(embeds=embeds, allowed_mentions=discord.AllowedMentions.none()) else: # Build a non-empty message; include attachment URLs if no content @@ -444,7 +452,9 @@ async def restore_from_snooze(self): await channel.send(formatted, allowed_mentions=discord.AllowedMentions.none()) else: # Recipient message: include attachment URLs if content is empty - content_to_send = content if content else ("\n".join(attachments) if attachments else None) + content_to_send = ( + content if content else ("\n".join(attachments) if attachments else None) + ) await channel.send( content=content_to_send, embeds=embeds or None, @@ -480,9 +490,7 @@ async def restore_from_snooze(self): notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." if notify_channel == "thread": - await channel.send( - notify_text, allowed_mentions=discord.AllowedMentions.none() - ) + await channel.send(notify_text, allowed_mentions=discord.AllowedMentions.none()) else: ch = self.bot.get_channel(int(notify_channel)) if ch: @@ -1720,32 +1728,33 @@ def lottie_to_png(data): if plain: if from_mod and not isinstance(destination, discord.TextChannel): - # Plain to user + # Plain to user (DM) with warnings.catch_warnings(): - # Catch coroutines not awaited warning warnings.simplefilter("ignore") additional_images = [] - if embed.footer.text: - plain_message = f"**{embed.footer.text} " - else: - plain_message = "**" - plain_message += f"{embed.author.name}:** {embed.description}" + prefix = f"**{embed.footer.text} " if embed.footer and embed.footer.text else "**" + body = embed.description or "" + plain_message = f"{prefix}{embed.author.name}:** {body}" + files = [] - for i in message.attachments: - files.append(await i.to_file()) + for att in message.attachments: + try: + files.append(await att.to_file()) + except Exception: + logger.warning("Failed to attach file in plain DM.", exc_info=True) - msg = await destination.send(plain_message, files=files) + msg = await destination.send(plain_message, files=files or None) else: # Plain to mods - embed.set_footer(text="[PLAIN] " + embed.footer.text) + footer_text = embed.footer.text if embed.footer else "" + embed.set_footer(text=f"[PLAIN] {footer_text}".strip()) msg = await destination.send(mentions, embed=embed) else: try: msg = await destination.send(mentions, embed=embed) except discord.NotFound: - # If channel vanished right before send, try to restore and resend once if ( isinstance(destination, discord.TextChannel) and (self.snoozed or self.snooze_data) @@ -1768,16 +1777,19 @@ def lottie_to_png(data): async def get_notifications(self) -> str: key = str(self.id) - - mentions = [] - mentions.extend(self.bot.config["subscriptions"].get(key, [])) - - if key in self.bot.config["notification_squad"]: - mentions.extend(self.bot.config["notification_squad"][key]) - self.bot.config["notification_squad"].pop(key) + mentions: typing.List[str] = [] + subs = self.bot.config["subscriptions"].get(key, []) + mentions.extend(subs) + one_time = self.bot.config["notification_squad"].get(key, []) + mentions.extend(one_time) + + if one_time: + self.bot.config["notification_squad"].pop(key, None) self.bot.loop.create_task(self.bot.config.update()) - return " ".join(set(mentions)) + if not mentions: + return "" + return " ".join(list(dict.fromkeys(mentions))) async def set_title(self, title: str) -> None: topic = f"Title: {title}\n" From 7fc13595e8eb20229939321d20a0c0a24436714a Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 9 Oct 2025 22:15:01 +0200 Subject: [PATCH 15/39] Fix: Only create log URL button if valid, and robust channel restore for snooze --- core/thread.py | 58 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/core/thread.py b/core/thread.py index 3bf2bf3622..300f6c95a5 100644 --- a/core/thread.py +++ b/core/thread.py @@ -390,6 +390,40 @@ async def restore_from_snooze(self): except Exception: logger.error("Failed to recreate thread channel during unsnooze.", exc_info=True) return False + # Helper to safely send to thread channel, recreating once if deleted + async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=None): + nonlocal channel + try: + return await channel.send( + content=content, embeds=embeds, allowed_mentions=allowed_mentions + ) + except discord.NotFound: + # Channel was deleted between restore and send; try to recreate once + try: + ow_map: dict = {} + for role_id, perm_values in (self.snooze_data.get("overwrites", []) or []): + target = guild.get_role(role_id) or guild.get_member(role_id) + if target is None: + continue + ow_map[target] = discord.PermissionOverwrite(**perm_values) + channel = await guild.create_text_channel( + name=(self.snooze_data.get("name") or f"thread-{self.id}"), + category=orig_category, + overwrites=ow_map or None, + position=self.snooze_data.get("position"), + topic=self.snooze_data.get("topic"), + slowmode_delay=self.snooze_data.get("slowmode_delay") or 0, + nsfw=bool(self.snooze_data.get("nsfw")), + reason="Thread unsnoozed/restored (recreated after NotFound)", + ) + self._channel = channel + return await channel.send( + content=content, embeds=embeds, allowed_mentions=allowed_mentions + ) + except Exception: + logger.error("Failed to recreate channel during unsnooze send.", exc_info=True) + return None + # Strictly restore the log_key from snooze_data (never create a new one) self.log_key = self.snooze_data.get("log_key") @@ -439,7 +473,9 @@ async def restore_from_snooze(self): ) except Exception: pass - await channel.send(embeds=embeds, allowed_mentions=discord.AllowedMentions.none()) + await _safe_send_to_channel( + embeds=embeds, allowed_mentions=discord.AllowedMentions.none() + ) else: # Build a non-empty message; include attachment URLs if no content header = f"**{username} ({user_id})**" @@ -449,13 +485,15 @@ async def restore_from_snooze(self): formatted = header + "\n" + "\n".join(attachments) else: formatted = header - await channel.send(formatted, allowed_mentions=discord.AllowedMentions.none()) + await _safe_send_to_channel( + content=formatted, allowed_mentions=discord.AllowedMentions.none() + ) else: # Recipient message: include attachment URLs if content is empty content_to_send = ( content if content else ("\n".join(attachments) if attachments else None) ) - await channel.send( + await _safe_send_to_channel( content=content_to_send, embeds=embeds or None, allowed_mentions=discord.AllowedMentions.none(), @@ -490,7 +528,9 @@ async def restore_from_snooze(self): notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." if notify_channel == "thread": - await channel.send(notify_text, allowed_mentions=discord.AllowedMentions.none()) + await _safe_send_to_channel( + content=notify_text, allowed_mentions=discord.AllowedMentions.none() + ) else: ch = self.bot.get_channel(int(notify_channel)) if ch: @@ -882,11 +922,13 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, tasks = [self.bot.config.update()] if self.bot.log_channel is not None and self.channel is not None: - if self.bot.config["show_log_url_button"]: + # Only create a URL button if we actually have a valid log_url + view = None + if self.bot.config.get("show_log_url_button") and log_url: view = discord.ui.View() - view.add_item(discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url)) - else: - view = None + view.add_item( + discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url) + ) tasks.append(self.bot.log_channel.send(embed=embed, view=view)) # Thread closed message From 542d9f6713501e1012a74fb40e19009a43426318 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 9 Oct 2025 22:16:38 +0200 Subject: [PATCH 16/39] black formatting --- core/thread.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/core/thread.py b/core/thread.py index 300f6c95a5..aef835dd2b 100644 --- a/core/thread.py +++ b/core/thread.py @@ -390,18 +390,17 @@ async def restore_from_snooze(self): except Exception: logger.error("Failed to recreate thread channel during unsnooze.", exc_info=True) return False + # Helper to safely send to thread channel, recreating once if deleted async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=None): nonlocal channel try: - return await channel.send( - content=content, embeds=embeds, allowed_mentions=allowed_mentions - ) + return await channel.send(content=content, embeds=embeds, allowed_mentions=allowed_mentions) except discord.NotFound: # Channel was deleted between restore and send; try to recreate once try: ow_map: dict = {} - for role_id, perm_values in (self.snooze_data.get("overwrites", []) or []): + for role_id, perm_values in self.snooze_data.get("overwrites", []) or []: target = guild.get_role(role_id) or guild.get_member(role_id) if target is None: continue @@ -528,9 +527,7 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." if notify_channel == "thread": - await _safe_send_to_channel( - content=notify_text, allowed_mentions=discord.AllowedMentions.none() - ) + await _safe_send_to_channel(content=notify_text, allowed_mentions=discord.AllowedMentions.none()) else: ch = self.bot.get_channel(int(notify_channel)) if ch: @@ -926,9 +923,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, view = None if self.bot.config.get("show_log_url_button") and log_url: view = discord.ui.View() - view.add_item( - discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url) - ) + view.add_item(discord.ui.Button(label="Log link", url=log_url, style=discord.ButtonStyle.url)) tasks.append(self.bot.log_channel.send(embed=embed, view=view)) # Thread closed message From 3ffea2cebfa1770ffe97ef02ffd75e976efbe659 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 10 Oct 2025 17:36:53 +0200 Subject: [PATCH 17/39] Unsnooze: prefix username (user_id) for plain-text replay messages --- core/thread.py | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/core/thread.py b/core/thread.py index aef835dd2b..e72d23a7af 100644 --- a/core/thread.py +++ b/core/thread.py @@ -476,27 +476,41 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N embeds=embeds, allowed_mentions=discord.AllowedMentions.none() ) else: - # Build a non-empty message; include attachment URLs if no content + # Plain-text path (no embeds): prefix with username and user id header = f"**{username} ({user_id})**" - if content: - formatted = f"{header}: {content}" - elif attachments: - formatted = header + "\n" + "\n".join(attachments) - else: - formatted = header + body = content or "" + if attachments and not body: + # no content; include attachment URLs on new lines + body = "\n".join(attachments) + formatted = f"{header}: {body}" if body else header await _safe_send_to_channel( content=formatted, allowed_mentions=discord.AllowedMentions.none() ) else: # Recipient message: include attachment URLs if content is empty - content_to_send = ( - content if content else ("\n".join(attachments) if attachments else None) - ) - await _safe_send_to_channel( - content=content_to_send, - embeds=embeds or None, - allowed_mentions=discord.AllowedMentions.none(), + # When no embeds, prefix plain text with username and user id + username = ( + msg.get("author_name") + or (getattr(author, "name", None) if author else None) + or "Unknown" ) + user_id = msg.get("author_id") + if embeds: + await _safe_send_to_channel( + content=None, + embeds=embeds or None, + allowed_mentions=discord.AllowedMentions.none(), + ) + else: + header = f"**{username} ({user_id})**" + body = content or "" + if attachments and not body: + body = "\n".join(attachments) + formatted = f"{header}: {body}" if body else header + await _safe_send_to_channel( + content=formatted, + allowed_mentions=discord.AllowedMentions.none(), + ) self.snoozed = False # Store snooze_data for notification before clearing snooze_data_for_notify = self.snooze_data From 5e7802e4dabd98b3577eeabbdf3cf24d6d280c6e Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 15 Oct 2025 20:53:31 +0200 Subject: [PATCH 18/39] feat: command queue during unsnooze process. feat(config): `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown). --- CHANGELOG.md | 1 + bot.py | 14 +++- core/config.py | 1 + core/config_help.json | 13 ++++ core/thread.py | 152 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 179 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c54e4c5c6..68bc21c49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Configuration Options: * `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. diff --git a/bot.py b/bot.py index d29846f033..77213711dc 100644 --- a/bot.py +++ b/bot.py @@ -591,7 +591,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"], @@ -1393,6 +1393,18 @@ 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: + pass + continue + await self.invoke(ctx) continue diff --git a/core/config.py b/core/config.py index faabadf6c9..39c3d204c2 100644 --- a/core/config.py +++ b/core/config.py @@ -146,6 +146,7 @@ class ConfigManager: # attachments persistence for delete-behavior snooze "snooze_store_attachments": False, # when True, store image attachments as base64 in snooze_data "snooze_attachment_max_bytes": 4_194_304, # 4 MiB per attachment cap to avoid Mongo 16MB limit + "unsnooze_history_limit": None, # Limit number of messages replayed when unsnoozing (None = all messages) } private_keys = { diff --git a/core/config_help.json b/core/config_help.json index 8b7b3e9bc6..5f2d75284c 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1369,5 +1369,18 @@ "This cap helps prevent hitting MongoDB's 16MB per-document limit when storing large attachments.", "Non-image files are not stored as base64 and will be preserved as their original URLs if available." ] + }, + "unsnooze_history_limit": { + "default": "None (all messages replayed)", + "description": "Limits the number of messages replayed when a thread is unsnoozed. When set, only the last N messages will be displayed in the restored channel.", + "examples": [ + "`{prefix}config set unsnooze_history_limit 50`", + "`{prefix}config set unsnooze_history_limit 100`" + ], + "notes": [ + "All messages remain stored in the database regardless of this limit.", + "Set to None or delete this config to replay all messages when unsnoozing.", + "See also: `snooze_behavior`, `unsnooze_text`." + ] } } \ No newline at end of file diff --git a/core/thread.py b/core/thread.py index e72d23a7af..4c47e6cad9 100644 --- a/core/thread.py +++ b/core/thread.py @@ -71,6 +71,9 @@ def __init__( self.snoozed = False # True if thread is snoozed self.snooze_data = None # Dict with channel/category/position/messages for restoration self.log_key = None # Ensure log_key always exists + # --- UNSNOOZE COMMAND QUEUE --- + self._unsnoozing = False # True while restore_from_snooze is running + self._command_queue = [] # Queue of (ctx, command) tuples; close commands always last def __repr__(self): return f'Thread(recipient="{self.recipient or self.id}", channel={self.channel.id}, other_recipients={len(self._other_recipients)})' @@ -312,12 +315,16 @@ async def restore_from_snooze(self): - If channel was moved (move behavior), move back to original category and position. Mark as not snoozed and clear snooze data. """ + # Mark that unsnooze is in progress + self._unsnoozing = True + if not self.snooze_data or not isinstance(self.snooze_data, dict): import logging logging.warning( f"[UNSNOOZE] Tried to restore thread {self.id} but snooze_data is None or not a dict." ) + self._unsnoozing = False return False # Cache some fields we need later (before we potentially clear snooze_data) @@ -428,7 +435,87 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N # Replay messages only if we re-created the channel (delete behavior or move fallback) if behavior != "move" or (behavior == "move" and not self.snooze_data.get("moved", False)): - for msg in self.snooze_data.get("messages", []): + # Get history limit from config (0 or None = show all) + history_limit = self.bot.config.get("unsnooze_history_limit") + all_messages = self.snooze_data.get("messages", []) + + # Separate genesis, notes, and regular messages + genesis_msg = None + notes = [] + regular_messages = [] + + for msg in all_messages: + msg_type = msg.get("type") + # Check if it's the genesis message (has Roles field) + if msg.get("embeds"): + for embed_dict in msg.get("embeds", []): + if embed_dict.get("fields"): + for field in embed_dict.get("fields", []): + if field.get("name") == "Roles": + genesis_msg = msg + break + if genesis_msg: + break + # Check if it's a note + if msg_type == "mod_only": + notes.append(msg) + elif genesis_msg != msg: + regular_messages.append(msg) + + # Apply limit if set + limited = False + if history_limit: + try: + history_limit = int(history_limit) + if history_limit > 0 and len(regular_messages) > history_limit: + regular_messages = regular_messages[-history_limit:] + limited = True + except (ValueError, TypeError): + pass + + # Replay genesis first + if genesis_msg: + msg = genesis_msg + try: + author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( + msg["author_id"] + ) + except discord.NotFound: + author = None + embeds = [discord.Embed.from_dict(e) for e in msg.get("embeds", []) if e] + if embeds: + await _safe_send_to_channel( + embeds=embeds, allowed_mentions=discord.AllowedMentions.none() + ) + + # Send history limit notification after genesis + if limited: + 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 ''}/{self.log_key}" + if self.log_key + else None + ) + + limit_embed = discord.Embed( + color=0xFFA500, + title="⚠️ History Limited", + description=f"Only showing the last **{history_limit}** messages due to the `unsnooze_history_limit` setting.", + ) + if log_url: + limit_embed.description += f"\n\n[View full history in logs]({log_url})" + await _safe_send_to_channel( + embeds=[limit_embed], allowed_mentions=discord.AllowedMentions.none() + ) + + # Build list of remaining messages to show + messages_to_show = [] + messages_to_show.extend(notes) + messages_to_show.extend(regular_messages) + + for msg in messages_to_show: try: author = self.bot.get_user(msg["author_id"]) or await self.bot.get_or_fetch_user( msg["author_id"] @@ -556,6 +643,16 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N if snoozed_by or snooze_command: info = f"Snoozed by: {snoozed_by or 'Unknown'} | Command: {snooze_command or '?snooze'}" await channel.send(info, allowed_mentions=discord.AllowedMentions.none()) + + # Ensure channel is set before processing commands + self._channel = channel + + # Mark unsnooze as complete + self._unsnoozing = False + + # Process queued commands + await self._process_command_queue() + return True @classmethod @@ -1910,6 +2007,59 @@ async def remove_users(self, users: typing.List[typing.Union[discord.Member, dis await self.channel.edit(topic=topic) await self._update_users_genesis() + async def queue_command(self, ctx, command) -> bool: + """ + Queue a command to be executed after unsnooze completes. + Close commands are automatically moved to the end of the queue. + Returns True if command was queued, False if it should execute immediately. + """ + if self._unsnoozing: + command_name = command.qualified_name if command else "" + + # If it's a close command, always add to end + if command_name == "close": + self._command_queue.append((ctx, command)) + else: + # For non-close commands, insert before any close commands + close_index = None + for i, (_, cmd) in enumerate(self._command_queue): + if cmd and cmd.qualified_name == "close": + close_index = i + break + + if close_index is not None: + self._command_queue.insert(close_index, (ctx, command)) + else: + self._command_queue.append((ctx, command)) + + return True + return False + + async def _process_command_queue(self) -> None: + """ + Process all queued commands after unsnooze completes. + Close commands are always last, so processing stops naturally after close. + """ + if not self._command_queue: + return + + logger.info(f"Processing {len(self._command_queue)} queued commands for thread {self.id}") + + # Process commands in order + while self._command_queue: + ctx, command = self._command_queue.pop(0) + try: + command_name = command.qualified_name if command else "" + await self.bot.invoke(ctx) + + # If close command was executed, stop (it's always last anyway) + if command_name == "close": + logger.info(f"Close command executed, queue processing complete") + break + + except Exception as e: + logger.error(f"Error processing queued command: {e}", exc_info=True) + class ThreadManager: """Class that handles storing, finding and creating Modmail threads.""" From b0776da7659751a8fccc25f9ed50a57553a5a21b Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 16 Oct 2025 16:42:31 +0200 Subject: [PATCH 19/39] fix: contact while snooze returned as invalid channel --- bot.py | 13 +++++++++++++ cogs/modmail.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index 77213711dc..628d266907 100644 --- a/bot.py +++ b/bot.py @@ -1599,6 +1599,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) diff --git a/cogs/modmail.py b/cogs/modmail.py index 5537914f85..550db203e8 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1584,6 +1584,25 @@ 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 + await ctx.send(f"ℹ️ You had a snoozed thread. Unsnoozing now...") + await existing_thread.restore_from_snooze() + self.bot.threads.cache[existing_thread.id] = existing_thread + 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]") @@ -1642,9 +1661,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}" @@ -1658,6 +1682,17 @@ 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: + await ctx.send(f"ℹ️ {u.mention} had a snoozed thread. Unsnoozing now...") + await thread.restore_from_snooze() + self.bot.threads.cache[thread.id] = thread + # 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 = [] @@ -1674,7 +1709,6 @@ async def contact( await ctx.send(embed=embed, delete_after=10) if not users: - # end return creator = ctx.author if manual_trigger else users[0] From 2b3a32c4096e90dc21947484dbbc27a01263a05f Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 21:25:27 +0200 Subject: [PATCH 20/39] Update thread.py --- core/thread.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/thread.py b/core/thread.py index 4c47e6cad9..80aaff10f3 100644 --- a/core/thread.py +++ b/core/thread.py @@ -315,6 +315,11 @@ async def restore_from_snooze(self): - If channel was moved (move behavior), move back to original category and position. Mark as not snoozed and clear snooze data. """ + # Prevent concurrent unsnooze operations + if self._unsnoozing: + logger.warning(f"Unsnooze already in progress for thread {self.id}, skipping duplicate call") + return False + # Mark that unsnooze is in progress self._unsnoozing = True From 76f12f7c558cb11fc30c6da1bdfaaa6bace62729 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 21:32:39 +0200 Subject: [PATCH 21/39] fix: snooze timing --- CHANGELOG.md | 2 +- cogs/modmail.py | 58 ++++++++++++++++++++++++------------------------- core/config.py | 2 +- core/thread.py | 2 +- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bc21c49c..8ddaaa0238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Commands: * `clearsnoozed`: Clears all snoozed items. Configuration Options: -* `max_snooze_time`: Sets the maximum duration for snooze. +* `default_snooze_time`: 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. diff --git a/cogs/modmail.py b/cogs/modmail.py index 550db203e8..aa9893bd15 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -2322,7 +2322,7 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): - 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: max_snooze_time, snooze_title, snooze_text + Uses config: default_snooze_time, snooze_title, snooze_text """ thread = ctx.thread if thread.snoozed: @@ -2331,20 +2331,20 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): return from core.time import ShortTime - max_snooze = self.bot.config.get("max_snooze_time") - if max_snooze is None: - max_snooze = 604800 + default_snooze = self.bot.config.get("default_snooze_time") + if default_snooze is None: + default_snooze = 604800 else: try: - max_snooze = int((ShortTime(str(max_snooze)).dt - ShortTime("0s").dt).total_seconds()) + default_snooze = int((ShortTime(str(default_snooze)).dt - ShortTime("0s").dt).total_seconds()) except Exception: - max_snooze = 604800 + default_snooze = 604800 if duration: snooze_for = int((duration.dt - duration.now).total_seconds()) - if snooze_for > max_snooze: - snooze_for = max_snooze + if snooze_for > default_snooze: + 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() @@ -2425,11 +2425,18 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): except Exception: pass - # 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", @@ -2557,24 +2564,17 @@ async def snooze_auto_unsnooze_task(self): 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) + 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): + pass + await asyncio.sleep(10) async def process_dm_modmail(self, message: discord.Message) -> None: # ... existing code ... diff --git a/core/config.py b/core/config.py index 39c3d204c2..f220810b46 100644 --- a/core/config.py +++ b/core/config.py @@ -135,7 +135,7 @@ class ConfigManager: "thread_min_characters_response": "Your message is too short to create a thread. Please provide more details.", "thread_min_characters_footer": "Minimum {min_characters} characters required.", # --- SNOOZE FEATURE CONFIG --- - "max_snooze_time": 604800, # in seconds, default 7 days + "default_snooze_time": 604800, # in seconds, default 7 days "snooze_title": "Thread Snoozed", "snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.", "unsnooze_text": "This thread has been unsnoozed and restored.", diff --git a/core/thread.py b/core/thread.py index 80aaff10f3..02b30a0365 100644 --- a/core/thread.py +++ b/core/thread.py @@ -319,7 +319,7 @@ async def restore_from_snooze(self): if self._unsnoozing: logger.warning(f"Unsnooze already in progress for thread {self.id}, skipping duplicate call") return False - + # Mark that unsnooze is in progress self._unsnoozing = True From b6d74317198467f85e9827f6202cc32c8f819a95 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 21:36:51 +0200 Subject: [PATCH 22/39] change: rename default snooze time config --- core/config_help.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/config_help.json b/core/config_help.json index 5f2d75284c..06b63c95b4 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1266,12 +1266,12 @@ "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_response`." ] }, - "max_snooze_time": { + "default_snooze_time": { "default": "604800 (7 days in seconds)", - "description": "The maximum duration in seconds that a thread can be snoozed. When a thread is snoozed, it is temporarily hidden until the user replies or a moderator unsnoozes it.", + "description": "The default duration in seconds that a thread will be snoozed when no duration is specified. When a thread is snoozed, it is temporarily hidden until the user replies or a moderator unsnoozes it.", "examples": [ - "`{prefix}config set max_snooze_time 86400` (1 day)", - "`{prefix}config set max_snooze_time 1209600` (14 days)" + "`{prefix}config set default_snooze_time 86400` (1 day)", + "`{prefix}config set default_snooze_time 1209600` (14 days)" ], "notes": [ "The value must be specified in seconds.", @@ -1285,7 +1285,7 @@ "`{prefix}config set snooze_title Thread Paused`" ], "notes": [ - "See also: `snooze_text`, `unsnooze_title`, `max_snooze_time`." + "See also: `snooze_text`, `unsnooze_title`, `default_snooze_time`." ] }, "snooze_text": { @@ -1296,7 +1296,7 @@ ], "notes": [ "Discord flavoured markdown is fully supported in `snooze_text`.", - "See also: `snooze_title`, `unsnooze_text`, `max_snooze_time`." + "See also: `snooze_title`, `unsnooze_text`, `default_snooze_time`." ] }, "unsnooze_text": { @@ -1307,7 +1307,7 @@ ], "notes": [ "Discord flavoured markdown is fully supported in `unsnooze_text`.", - "See also: `snooze_text`, `unsnooze_notify_channel`, `max_snooze_time`." + "See also: `snooze_text`, `unsnooze_notify_channel`, `default_snooze_time`." ] }, "unsnooze_notify_channel": { @@ -1320,7 +1320,7 @@ "notes": [ "If set to \"thread\", the notification will be sent in the thread channel itself.", "If set to a channel ID, the notification will be sent to that specific channel.", - "See also: `unsnooze_text`, `max_snooze_time`." + "See also: `unsnooze_text`, `default_snooze_time`." ] }, "snooze_behavior": { From b60ddbebe80ac781e127040774f5cf8995f0e38c Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 22:40:23 +0200 Subject: [PATCH 23/39] fix: parsing --- cogs/modmail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index aa9893bd15..ed2d65a66e 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -2336,8 +2336,8 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): default_snooze = 604800 else: try: - default_snooze = int((ShortTime(str(default_snooze)).dt - ShortTime("0s").dt).total_seconds()) - except Exception: + default_snooze = int(default_snooze) + except (ValueError, TypeError): default_snooze = 604800 if duration: snooze_for = int((duration.dt - duration.now).total_seconds()) From 15e63910377e2c57af07e42c8319731b7425df38 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 22:51:38 +0200 Subject: [PATCH 24/39] fix: cache for snooze timer --- cogs/modmail.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/cogs/modmail.py b/cogs/modmail.py index ed2d65a66e..176b8e58dd 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -29,6 +29,52 @@ 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") + thread_id = int(thread_data.get("recipient.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: + thread_id = int(thread_data.get("recipient.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: + pass + 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.""" From 07a0a773bae3f08f8e88a1b5c86e1e7cd8cb08df Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 17 Oct 2025 22:57:49 +0200 Subject: [PATCH 25/39] fix: Properly accessing nested data --- cogs/modmail.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 176b8e58dd..62c50c520a 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -49,7 +49,10 @@ async def auto_unsnooze_task(self): to_unsnooze = [] for thread_data in list(self._snoozed_cache): snooze_until = thread_data.get("snooze_until") - thread_id = int(thread_data.get("recipient.id")) + 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) @@ -58,7 +61,10 @@ async def auto_unsnooze_task(self): if now >= dt: to_unsnooze.append(thread_data) for thread_data in to_unsnooze: - thread_id = int(thread_data.get("recipient.id")) + 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 ) From 1654c261267f11bf66288d9116d50fe1a8a2d790 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 20 Oct 2025 16:46:42 +0200 Subject: [PATCH 26/39] rename: default_snooze_time -> snooze_default_duration --- CHANGELOG.md | 2 +- cogs/modmail.py | 4 ++-- core/config.py | 2 +- core/config_help.json | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddaaa0238..3e9b153fd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ Commands: * `clearsnoozed`: Clears all snoozed items. Configuration Options: -* `default_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. diff --git a/cogs/modmail.py b/cogs/modmail.py index 62c50c520a..2588120db0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -2374,7 +2374,7 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): - 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: default_snooze_time, snooze_title, snooze_text + Uses config: snooze_default_duration, snooze_title, snooze_text """ thread = ctx.thread if thread.snoozed: @@ -2383,7 +2383,7 @@ async def snooze(self, ctx, *, duration: UserFriendlyTime = None): return from core.time import ShortTime - default_snooze = self.bot.config.get("default_snooze_time") + default_snooze = self.bot.config.get("snooze_default_duration") if default_snooze is None: default_snooze = 604800 else: diff --git a/core/config.py b/core/config.py index f220810b46..442f0ad7d4 100644 --- a/core/config.py +++ b/core/config.py @@ -135,7 +135,7 @@ class ConfigManager: "thread_min_characters_response": "Your message is too short to create a thread. Please provide more details.", "thread_min_characters_footer": "Minimum {min_characters} characters required.", # --- SNOOZE FEATURE CONFIG --- - "default_snooze_time": 604800, # in seconds, default 7 days + "snooze_default_duration": 604800, # in seconds, default 7 days "snooze_title": "Thread Snoozed", "snooze_text": "This thread has been snoozed. The channel will be restored when the user replies or a moderator unsnoozes it.", "unsnooze_text": "This thread has been unsnoozed and restored.", diff --git a/core/config_help.json b/core/config_help.json index 06b63c95b4..88cc7a43c0 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1266,12 +1266,12 @@ "See also: `thread_min_characters`, `thread_min_characters_title`, `thread_min_characters_response`." ] }, - "default_snooze_time": { + "snooze_default_duration": { "default": "604800 (7 days in seconds)", "description": "The default duration in seconds that a thread will be snoozed when no duration is specified. When a thread is snoozed, it is temporarily hidden until the user replies or a moderator unsnoozes it.", "examples": [ - "`{prefix}config set default_snooze_time 86400` (1 day)", - "`{prefix}config set default_snooze_time 1209600` (14 days)" + "`{prefix}config set snooze_default_duration 86400` (1 day)", + "`{prefix}config set snooze_default_duration 1209600` (14 days)" ], "notes": [ "The value must be specified in seconds.", @@ -1285,7 +1285,7 @@ "`{prefix}config set snooze_title Thread Paused`" ], "notes": [ - "See also: `snooze_text`, `unsnooze_title`, `default_snooze_time`." + "See also: `snooze_text`, `unsnooze_title`, `snooze_default_duration`." ] }, "snooze_text": { @@ -1296,7 +1296,7 @@ ], "notes": [ "Discord flavoured markdown is fully supported in `snooze_text`.", - "See also: `snooze_title`, `unsnooze_text`, `default_snooze_time`." + "See also: `snooze_title`, `unsnooze_text`, `snooze_default_duration`." ] }, "unsnooze_text": { @@ -1307,7 +1307,7 @@ ], "notes": [ "Discord flavoured markdown is fully supported in `unsnooze_text`.", - "See also: `snooze_text`, `unsnooze_notify_channel`, `default_snooze_time`." + "See also: `snooze_text`, `unsnooze_notify_channel`, `snooze_default_duration`." ] }, "unsnooze_notify_channel": { @@ -1320,7 +1320,7 @@ "notes": [ "If set to \"thread\", the notification will be sent in the thread channel itself.", "If set to a channel ID, the notification will be sent to that specific channel.", - "See also: `unsnooze_text`, `default_snooze_time`." + "See also: `unsnooze_text`, `snooze_default_duration`." ] }, "snooze_behavior": { From e3f9f736a40fa7b8449624af02580c8c1c32d426 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 20 Oct 2025 16:56:28 +0200 Subject: [PATCH 27/39] improve unsnooze notify --- core/thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/thread.py b/core/thread.py index 02b30a0365..fdf5f8d9e1 100644 --- a/core/thread.py +++ b/core/thread.py @@ -638,7 +638,7 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N ch = self.bot.get_channel(int(notify_channel)) if ch: await ch.send( - f"Thread for user <@{self.id}> has been unsnoozed and restored.", + f"⏰ Thread for user <@{self.id}> has been unsnoozed and restored in {channel.mention}", allowed_mentions=discord.AllowedMentions.none(), ) # Show who ran the snooze command and the command used From c680f671f73d787a437f647aa775251abf3c36c1 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 20 Oct 2025 17:12:00 +0200 Subject: [PATCH 28/39] fix: id extraction for clean database. --- core/config.py | 4 ++++ core/thread.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/config.py b/core/config.py index 442f0ad7d4..1909e11e35 100644 --- a/core/config.py +++ b/core/config.py @@ -397,6 +397,10 @@ async def set(self, key: str, item: typing.Any, convert=True) -> None: if not convert: return self.__setitem__(key, item) + if "channel" in key or "category" in key: + if isinstance(item, str) and item not in {"thread", "NONE"}: + item = item.strip("<#>") + if key in self.colors: try: hex_ = str(item) diff --git a/core/thread.py b/core/thread.py index fdf5f8d9e1..0dba447f68 100644 --- a/core/thread.py +++ b/core/thread.py @@ -635,7 +635,9 @@ async def _safe_send_to_channel(*, content=None, embeds=None, allowed_mentions=N if notify_channel == "thread": await _safe_send_to_channel(content=notify_text, allowed_mentions=discord.AllowedMentions.none()) else: - ch = self.bot.get_channel(int(notify_channel)) + # Extract channel ID from mention format <#123> or use raw ID + channel_id = str(notify_channel).strip("<#>") + ch = self.bot.get_channel(int(channel_id)) if ch: await ch.send( f"⏰ Thread for user <@{self.id}> has been unsnoozed and restored in {channel.mention}", From eaf7cfd7a7141841d622c47616a6d1150e4713c6 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 20 Oct 2025 17:17:43 +0200 Subject: [PATCH 29/39] improve: support for user-friendly time input for snooze_default_duration --- core/config.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/core/config.py b/core/config.py index 1909e11e35..804970b735 100644 --- a/core/config.py +++ b/core/config.py @@ -207,6 +207,8 @@ class ConfigManager: time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown", "log_expiration"} + duration_seconds = {"snooze_default_duration"} + booleans = { "use_user_id_channel_name", "use_timestamp_channel_name", @@ -369,6 +371,14 @@ def get(self, key: str, *, convert: bool = True) -> typing.Any: logger.warning("Invalid %s %s.", key, value) value = self.remove(key) + elif key in self.duration_seconds: + if not isinstance(value, int): + try: + value = int(value) + except (ValueError, TypeError): + logger.warning("Invalid %s %s.", key, value) + value = self.remove(key) + elif key in self.force_str: # Temporary: as we saved in int previously, leading to int32 overflow, # this is transitioning IDs to strings @@ -453,6 +463,25 @@ async def set(self, key: str, item: typing.Any, convert=True) -> None: except ValueError: raise InvalidConfigError("Must be a yes/no value.") + elif key in self.duration_seconds: + if isinstance(item, int): + return self.__setitem__(key, item) + try: + converter = UserFriendlyTime() + time = await converter.convert(None, str(item), now=discord.utils.utcnow()) + if time.arg: + raise ValueError + except BadArgument as exc: + raise InvalidConfigError(*exc.args) + except Exception as e: + logger.debug(e) + raise InvalidConfigError( + "Unrecognized time, please use a duration like '5 days' or '2 hours'." + ) + now = discord.utils.utcnow() + duration_seconds = int((time.dt - now).total_seconds()) + return self.__setitem__(key, duration_seconds) + elif key in self.enums: if isinstance(item, self.enums[key]): # value is an enum type From 18947c8cad2c7e67f5ea5f4bdac60ed559d3969b Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 20 Oct 2025 17:21:27 +0200 Subject: [PATCH 30/39] reflect config help snooze_default_duration for userfriendly time --- core/config_help.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/config_help.json b/core/config_help.json index 88cc7a43c0..c8213a5032 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -1268,13 +1268,14 @@ }, "snooze_default_duration": { "default": "604800 (7 days in seconds)", - "description": "The default duration in seconds that a thread will be snoozed when no duration is specified. When a thread is snoozed, it is temporarily hidden until the user replies or a moderator unsnoozes it.", + "description": "The default duration that a thread will be snoozed when no duration is specified. When a thread is snoozed, it is temporarily hidden until the user replies or a moderator unsnoozes it.", "examples": [ - "`{prefix}config set snooze_default_duration 86400` (1 day)", - "`{prefix}config set snooze_default_duration 1209600` (14 days)" + "`{prefix}config set snooze_default_duration 7 days`", + "`{prefix}config set snooze_default_duration 2 hours`", + "`{prefix}config set snooze_default_duration 86400` (raw seconds also accepted)" ], "notes": [ - "The value must be specified in seconds.", + "Accepts user-friendly time like '5 days', '2 hours', or raw seconds.", "See also: `snooze_title`, `snooze_text`, `unsnooze_text`, `unsnooze_notify_channel`." ] }, From 71bf9702659dbf7ab78709ffd76db912fa87109d Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 23 Oct 2025 16:11:55 +0200 Subject: [PATCH 31/39] fix: anonreply showing None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes a bug where, if no `anon_username` is set and the moderator has no roles, the `anon_username` is not showing as `None` anymore, and will show as intended. The logic now works as follows: - If a config anon_username is set β†’ use it - Else, if a mod_tag is set β†’ use it - Else, if the moderator has a top role β†’ use that - Else β†’ use "Anonymous" --- cogs/modmail.py | 14 ++++++-------- core/thread.py | 7 +++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 2588120db0..1123088a35 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1121,10 +1121,9 @@ 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"])) - name = self.bot.config["anon_username"] - if name is None: - name = tag + top_role = get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) + tag = str(top_role) if top_role else None + name = self.bot.config["anon_username"] or tag or "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) @@ -1212,10 +1211,9 @@ 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"])) - name = self.bot.config["anon_username"] - if name is None: - name = tag + top_role = get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) + tag = str(top_role) if top_role else None + name = self.bot.config["anon_username"] or tag or "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) diff --git a/core/thread.py b/core/thread.py index 0dba447f68..7f7ae636ae 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1652,10 +1652,9 @@ async def send( # Anonymously sending to the user. tag = self.bot.config["mod_tag"] if tag is None: - tag = str(get_top_role(author, self.bot.config["use_hoisted_top_role"])) - name = self.bot.config["anon_username"] - if name is None: - name = tag + top_role = get_top_role(author, self.bot.config["use_hoisted_top_role"]) + tag = str(top_role) if top_role else None + name = self.bot.config["anon_username"] or tag or "Anonymous" avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=self.bot.guild, size=128) From a6bef2e66a4b737f46c90532684964688d1fe5da Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 7 Nov 2025 19:46:23 +0100 Subject: [PATCH 32/39] black formatting --- cogs/modmail.py | 14 ++++++++------ core/thread.py | 7 ++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cogs/modmail.py b/cogs/modmail.py index 1123088a35..99ced08778 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1121,9 +1121,10 @@ async def anonadduser(self, ctx, *users_arg: Union[discord.Member, discord.Role, tag = self.bot.config["mod_tag"] if tag is None: - top_role = get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) - tag = str(top_role) if top_role else None - name = self.bot.config["anon_username"] or tag or "Anonymous" + 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 = "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) @@ -1211,9 +1212,10 @@ async def anonremoveuser(self, ctx, *users_arg: Union[discord.Member, discord.Ro tag = self.bot.config["mod_tag"] if tag is None: - top_role = get_top_role(ctx.author, self.bot.config["use_hoisted_top_role"]) - tag = str(top_role) if top_role else None - name = self.bot.config["anon_username"] or tag or "Anonymous" + 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 = "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) diff --git a/core/thread.py b/core/thread.py index 7f7ae636ae..3cd16d054e 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1652,9 +1652,10 @@ async def send( # Anonymously sending to the user. tag = self.bot.config["mod_tag"] if tag is None: - top_role = get_top_role(author, self.bot.config["use_hoisted_top_role"]) - tag = str(top_role) if top_role else None - name = self.bot.config["anon_username"] or tag or "Anonymous" + tag = str(get_top_role(author, self.bot.config["use_hoisted_top_role"])) + name = self.bot.config["anon_username"] + if name is None: + name = "Anonymous" avatar_url = self.bot.config["anon_avatar_url"] if avatar_url is None: avatar_url = self.bot.get_guild_icon(guild=self.bot.guild, size=128) From e54c5939d51bd80cff8ca986e49c1eb3d9f14cb8 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Fri, 7 Nov 2025 21:59:41 +0100 Subject: [PATCH 33/39] feat: thread creation menu Credits to Sebkuip(https://github.com/sebkuip) for the original idea and populair plugin.(advanced-menu) This now is a core feature. --- CHANGELOG.md | 17 + bot.py | 5 +- cogs/modmail.py | 13 +- cogs/threadmenu.py | 788 ++++++++++++++++++++++++++++++++++++++++++ core/config.py | 16 + core/config_help.json | 103 ++++++ core/thread.py | 337 ++++++++++++++++-- requirements.txt | 5 +- 8 files changed, 1251 insertions(+), 33 deletions(-) create mode 100644 cogs/threadmenu.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9b153fd4..af83152156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,26 @@ Configuration Options: * `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. + * `threadmenu config `: Adjust global settings. + * 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 diff --git a/bot.py b/bot.py index 628d266907..51badd1956 100644 --- a/bot.py +++ b/bot.py @@ -84,7 +84,7 @@ 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 @@ -1115,6 +1115,9 @@ def __init__(self, original_message, ref_message): 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( diff --git a/cogs/modmail.py b/cogs/modmail.py index 99ced08778..1210f95d74 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -523,21 +523,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) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py new file mode 100644 index 0000000000..425fc89603 --- /dev/null +++ b/cogs/threadmenu.py @@ -0,0 +1,788 @@ +import json +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.", + } + + 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.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.group(name="config", invoke_without_command=True) + async def threadmenu_config(self, ctx): + """Thread-creation menu config settings (core).""" + await ctx.send_help(ctx.command) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="get") + async def threadmenu_config_get(self, ctx): + """Get current core thread-creation menu config.""" + conf = self._get_conf() + embed = discord.Embed( + title="Thread-creation menu config (Core)", + description="The current config for the thread menu.", + color=discord.Color.blurple(), + ) + embed.add_field(name="Enabled", value=conf["enabled"]) + embed.add_field(name="Timeout", value=conf["timeout"]) + embed.add_field(name="Close on timeout", value=conf["close_on_timeout"]) + embed.add_field(name="Embed text", value=conf["embed_text"], inline=False) + embed.add_field(name="Dropdown placeholder", value=conf["dropdown_placeholder"], inline=False) + await ctx.send(embed=embed) + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="timeout") + async def threadmenu_config_timeout(self, ctx, timeout: int): + """Set the menu interaction timeout in seconds. + + After this period of inactivity the view times out; if + `close_on_timeout` is true the menu message is removed. + """ + if timeout < 1: + return await ctx.send("Timeout must be greater than 1.") + conf = self._get_conf() + conf["timeout"] = timeout + await self._save_conf(conf) + await ctx.send("Timeout set.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="close_on_timeout") + async def threadmenu_config_close_on_timeout(self, ctx, close_on_timeout: bool): + """Toggle deleting the menu message when it times out.""" + conf = self._get_conf() + conf["close_on_timeout"] = close_on_timeout + await self._save_conf(conf) + await ctx.send("Done.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="embed_text") + async def threadmenu_config_embed_text(self, ctx, *, embed_text: str): + """Set the embed body text shown above the select menu.""" + conf = self._get_conf() + conf["embed_text"] = embed_text + await self._save_conf(conf) + await ctx.send("Done.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="dropdown_placeholder") + async def threadmenu_config_dropdown_placeholder(self, ctx, *, dropdown_placeholder: str): + """Set the placeholder text inside the dropdown before selection.""" + conf = self._get_conf() + conf["dropdown_placeholder"] = dropdown_placeholder + await self._save_conf(conf) + await ctx.send("Done.") + + @checks.has_permissions(PermissionLevel.ADMINISTRATOR) + @threadmenu_config.command(name="anonymous_menu") + async def threadmenu_config_anonymous_menu(self, ctx, option: bool): + """Toggle whether the menu is posted anonymously (no user mention).""" + conf = self._get_conf() + conf["anonymous_menu"] = option + await self._save_conf(conf) + await ctx.send("Done.") + + @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