From 7322e99c3510eb648b811d99b5cf13229ce3653c Mon Sep 17 00:00:00 2001 From: Taku <45324516+Taaku18@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:05:01 -0800 Subject: [PATCH 01/16] Update gitignore --- .gitignore | 175 ++++++++++++++++++++--------------------------------- 1 file changed, 65 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index 635e3160e8..e165a06432 100644 --- a/.gitignore +++ b/.gitignore @@ -1,93 +1,29 @@ +#### Modmail +# Plugins +plugins/* +!plugins/registry.json +!plugins/@local + +# Config files +config.json +.env + +# Data +temp/ + +### Python # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - # pyenv .python-version -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -98,43 +34,62 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject +#### PyCharm +.idea/ -# mkdocs documentation -/site +#### VS Code +.vscode/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +### Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db -# Pyre type checker -.pyre/ +# Dump file +*.stackdump -# PyCharm -.idea/ +# Folder config file +[Dd]esktop.ini -# MacOS -.DS_Store +# Recycle Bin used on file shares +$RECYCLE.BIN/ -# VS Code -.vscode/ +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp -# Node -package-lock.json -node_modules/ +# Windows shortcuts +*.lnk -# Modmail -plugins/* -!plugins/registry.json -!plugins/@local -config.json -temp/ -test.py -stack.yml -.github/workflows/build_docker.yml \ No newline at end of file +### macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk From c3edf86357b483ec88a4b96796c1132796f85525 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 21 Dec 2025 23:38:51 +0100 Subject: [PATCH 02/16] fix: breaking threadmenu fixes. This PR solves the following: - thread creation menu null pointer bugs - false cancelled cache entry - If a menu times out, and the user send multiiple messages in the meantime the user would not be able to create a new thread with previous code. --- core/clients.py | 72 +++++++++++++++++++++------------ core/models.py | 4 ++ core/thread.py | 103 +++++++++++++++++++++++++++++++++--------------- 3 files changed, 122 insertions(+), 57 deletions(-) diff --git a/core/clients.py b/core/clients.py index 90f09b3b48..eae92c2e03 100644 --- a/core/clients.py +++ b/core/clients.py @@ -661,32 +661,52 @@ async def append_log( channel_id: str = "", type_: str = "thread_message", ) -> dict: - channel_id = str(channel_id) or str(message.channel.id) - message_id = str(message_id) or str(message.id) - - data = { - "timestamp": str(message.created_at), - "message_id": message_id, - "author": { - "id": str(message.author.id), - "name": message.author.name, - "discriminator": message.author.discriminator, - "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, - "mod": not isinstance(message.channel, DMChannel), - }, - "content": message.content, - "type": type_, - "attachments": [ - { - "id": a.id, - "filename": a.filename, - "is_image": a.width is not None, - "size": a.size, - "url": a.url, - } - for a in message.attachments - ], - } + channel_id = str(channel_id) or (str(message.channel.id) if message else "") + message_id = str(message_id) or (str(message.id) if message else "") + + if message: + data = { + "timestamp": str(message.created_at), + "message_id": message_id, + "author": { + "id": str(message.author.id), + "name": message.author.name, + "discriminator": message.author.discriminator, + "avatar_url": ( + message.author.display_avatar.url if message.author.display_avatar else None + ), + "mod": not isinstance(message.channel, DMChannel), + }, + "content": message.content, + "type": type_, + "attachments": [ + { + "id": a.id, + "filename": a.filename, + "is_image": a.width is not None, + "size": a.size, + "url": a.url, + } + for a in message.attachments + ], + } + else: + # Fallback for when message is None but we still want to log something (e.g. system note) + # This requires at least some manual data to be useful. + data = { + "timestamp": str(discord.utils.utcnow()), + "message_id": message_id or "0", + "author": { + "id": "0", + "name": "System", + "discriminator": "0000", + "avatar_url": None, + "mod": True, + }, + "content": "System Message (No Content)", + "type": type_, + "attachments": [], + } return await self.logs.find_one_and_update( {"channel_id": channel_id}, diff --git a/core/models.py b/core/models.py index 5f36f12181..c7d1e79599 100644 --- a/core/models.py +++ b/core/models.py @@ -438,6 +438,10 @@ def __init__(self, message): self._message = message def __getattr__(self, name: str): + if self._message is None: + # If we're wrapping None, we can't delegate attributes. + # This mimics behavior where the attribute doesn't exist. + raise AttributeError(f"'DummyMessage' object has no attribute '{name}' (wrapped message is None)") return getattr(self._message, name) def __bool__(self): diff --git a/core/thread.py b/core/thread.py index 45a6cb9c71..83c4282d80 100644 --- a/core/thread.py +++ b/core/thread.py @@ -145,6 +145,7 @@ def cancelled(self) -> bool: def cancelled(self, flag: bool): self._cancelled = flag if flag: + self._ready_event.set() for i in self.wait_tasks: i.cancel() @@ -1781,6 +1782,13 @@ async def send( reply commands to avoid mutating the original message object. """ # Handle notes with Discord-like system message format - return early + if message is None: + # Safeguard against None messages (e.g. from menu interactions without a source message) + if not note and not from_mod and not thread_creation: + # If we're just trying to log/relay a user message and there is none, existing behavior + # suggests we might skip or error. Logging a warning and returning is safer than crashing. + return + if note: destination = destination or self.channel content = message.content or "[No content]" @@ -1835,7 +1843,8 @@ async def send( await self.wait_until_ready() if not from_mod and not note: - self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) + if self.channel: + self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) destination = destination or self.channel @@ -2558,6 +2567,10 @@ async def create( # checks for existing thread in cache thread = self.cache.get(recipient.id) if thread: + # If there's a pending menu, return the existing thread to avoid creating duplicates + if getattr(thread, "_pending_menu", False): + logger.debug("Thread for %s has pending menu, returning existing thread.", recipient) + return thread try: await thread.wait_until_ready() except asyncio.CancelledError: @@ -2566,8 +2579,8 @@ async def create( label = f"{recipient} ({recipient.id})" except Exception: label = f"User ({getattr(recipient, 'id', 'unknown')})" - logger.warning("Thread for %s cancelled, abort creating.", label) - return thread + self.cache.pop(recipient.id, None) + thread = None else: if thread.channel and self.bot.get_channel(thread.channel.id): logger.warning("Found an existing thread for %s, abort creating.", recipient) @@ -2915,35 +2928,36 @@ async def callback(self, interaction: discord.Interaction): setattr(self.outer_thread, "_pending_menu", False) return # Forward the user's initial DM to the thread channel - try: - await self.outer_thread.send(message) - except Exception: - logger.error( - "Failed to relay initial message after menu selection", - exc_info=True, - ) - else: - # React to the user's DM with the 'sent' emoji + if message: try: - ( - sent_emoji, - _, - ) = await self.outer_thread.bot.retrieve_emoji() - await self.outer_thread.bot.add_reaction(message, sent_emoji) - except Exception as e: - logger.debug( - "Failed to add sent reaction to user's DM: %s", - e, + await self.outer_thread.send(message) + except Exception: + logger.error( + "Failed to relay initial message after menu selection", + exc_info=True, + ) + else: + # React to the user's DM with the 'sent' emoji + try: + ( + sent_emoji, + _, + ) = await self.outer_thread.bot.retrieve_emoji() + await self.outer_thread.bot.add_reaction(message, sent_emoji) + except Exception as e: + logger.debug( + "Failed to add sent reaction to user's DM: %s", + e, + ) + # Dispatch thread_reply event for parity + self.outer_thread.bot.dispatch( + "thread_reply", + self.outer_thread, + False, + message, + False, + False, ) - # Dispatch thread_reply event for parity - self.outer_thread.bot.dispatch( - "thread_reply", - self.outer_thread, - False, - message, - False, - False, - ) # Clear pending flag setattr(self.outer_thread, "_pending_menu", False) except Exception: @@ -2964,7 +2978,34 @@ async def callback(self, interaction: discord.Interaction): # Create a synthetic message object that makes the bot appear # as the author for menu-invoked command replies so the user # selecting the option is not shown as a "mod" sender. - synthetic = DummyMessage(copy.copy(message)) + if message: + synthetic = DummyMessage(copy.copy(message)) + else: + # Fallback if no message exists (e.g. self-created thread via menu) + # We use the interaction's message or construct a minimal dummy + base_msg = getattr(interaction, "message", None) or self.menu_msg + synthetic = ( + DummyMessage(copy.copy(base_msg)) if base_msg else DummyMessage(None) + ) + # Ensure minimal attributes for Context if still missing (DummyMessage handles some, but we need more for commands) + if not synthetic._message: + # Identify a valid channel + ch = self.outer_thread.channel + if not ch: + # If channel isn't ready, we can't really invoke a command in it. + continue + + from unittest.mock import MagicMock + + # Create a mock message strictly for command invocation context + mock_msg = MagicMock(spec=discord.Message) + mock_msg.id = 0 + mock_msg.channel = ch + mock_msg.guild = self.outer_thread.bot.modmail_guild + mock_msg.content = self.outer_thread.bot.prefix + al + mock_msg.author = self.outer_thread.bot.user + synthetic = DummyMessage(mock_msg) + try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user From 78aec104479109cb333e1d8bb80885f23a8764b0 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 24 Dec 2025 16:50:32 +0100 Subject: [PATCH 03/16] fix: thread_auto_close bug. This resolves an issue introduced in: https://github.com/modmail-dev/Modmail/pull/3423 Autocloses would fail. --- core/thread.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/core/thread.py b/core/thread.py index 83c4282d80..35b0cba6a0 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1056,7 +1056,10 @@ async def close( """Close a thread now or after a set time in seconds""" # restarts the after timer - await self.cancel_closure(auto_close) + await self.cancel_closure( + auto_close, + mark_auto_close_cancelled=not auto_close, + ) if after > 0: # TODO: Add somewhere to clean up broken closures @@ -1100,7 +1103,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, logger.error("Thread already closed: %s.", e) return - await self.cancel_closure(all=True) + await self.cancel_closure(all=True, mark_auto_close_cancelled=False) # Cancel auto closing the thread if closed by any means. @@ -1274,18 +1277,32 @@ async def _disable_dm_creation_menu(self) -> None: except Exception as inner_e: logger.debug("Failed removing view from DM menu message: %s", inner_e) - async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None: + async def cancel_closure( + self, + auto_close: bool = False, + all: bool = False, + *, + mark_auto_close_cancelled: bool = True, + ) -> None: if self.close_task is not None and (not auto_close or all): self.close_task.cancel() self.close_task = None if self.auto_close_task is not None and (auto_close or all): self.auto_close_task.cancel() self.auto_close_task = None - self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled - - to_update = self.bot.config["closures"].pop(str(self.id), None) - if to_update is not None: - await self.bot.config.update() + if mark_auto_close_cancelled: + self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled + + closure_key = str(self.id) + existing = self.bot.config["closures"].get(closure_key) + if existing is not None: + existing_is_auto = bool(existing.get("auto_close", False)) + should_remove = ( + all or (auto_close and existing_is_auto) or ((not auto_close) and (not existing_is_auto)) + ) + if should_remove: + self.bot.config["closures"].pop(closure_key, None) + await self.bot.config.update() async def _restart_close_timer(self): """ @@ -1821,11 +1838,14 @@ async def send( return await destination.send(embed=embed) if not note and from_mod: - # Only restart auto-close if it wasn't explicitly cancelled + # Only restart auto-close if it wasn't explicitly cancelled. + # Auto-close is driven by the last moderator reply. if not self.auto_close_cancelled: self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close elif not note and not from_mod: - await self.cancel_closure(all=True) + # If the user replied last, the thread should not auto-close. + # Cancel any pending auto-close without marking it as an explicit cancellation. + await self.cancel_closure(auto_close=True, mark_auto_close_cancelled=False) if self.close_task is not None: # cancel closing if a thread message is sent. From ab863db0410be47ad50bcaadea387b4a774c6458 Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:03:48 +0100 Subject: [PATCH 04/16] Replace claim plugin (#3437) Replaces the claim plugin by fourjr to my claim plugin due to being fundamentally broken as of the current time. It has been created few support issues already that were not successfull to use the plugin. --- plugins/registry.json | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/plugins/registry.json b/plugins/registry.json index 4079001a50..ee6b17b792 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1,13 +1,4 @@ { - "advanced-menu": { - "repository": "sebkuip/mm-plugins", - "branch": "master", - "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", - "bot_version": "v4.0.0", - "title": "Advanced menu", - "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", - "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" - }, "announcement": { "repository": "Jerrie-Aries/modmail-plugins", "branch": "master", @@ -72,13 +63,13 @@ "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" }, "claim": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Allows supporters to claim thread by sending ?claim in the thread channel", - "bot_version": "4.0.0", + "repository": "martinbndr/kyb3r-modmail-plugins", + "branch": "master", + "description": "Adds claim functionality to your modmail bot.", + "bot_version": "4.2.1", "title": "Claim Thread", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + "icon_url": "https://i.ibb.co/dsPjgKLj/87249157.png", + "thumbnail_url": "https://i.ibb.co/dsPjgKLj/87249157.png" }, "emote-manager": { "repository": "fourjr/modmail-plugins", @@ -134,4 +125,4 @@ "icon_url": "https://i.imgur.com/A1auJ95.png", "thumbnail_url": "https://i.imgur.com/A1auJ95.png" } -} +} \ No newline at end of file From fdbef3d4cb2197c834828c6c1cce6f62ebadf432 Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Mon, 1 Dec 2025 00:10:00 +0100 Subject: [PATCH 05/16] Feat: Renaming of snippets and aliases (#3383) This adds two commands for renaming snippets and aliases for easier name editing. --- cogs/modmail.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ cogs/utility.py | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/cogs/modmail.py b/cogs/modmail.py index b0e38ed9e0..0e39da920c 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -480,6 +480,61 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) + @snippet.command(name="rename") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_rename(self, ctx, name: str.lower, *, value): + """ + Rename a snippet. + + To rename a multi-word snippet name, use quotes: ``` + {prefix}snippet rename "two word" this is a new two word snippet. + ``` + """ + if name in self.bot.snippets: + if self.bot.get_command(value): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{value}`.", + ) + return await ctx.send(embed=embed) + elif value in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Snippet `{value}` already exists.", + ) + return await ctx.send(embed=embed) + + if value in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"An alias that shares the same name exists: `{value}`.", + ) + return await ctx.send(embed=embed) + + if len(value) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Snippet names cannot be longer than 120 characters.", + ) + return await ctx.send(embed=embed) + old_snippet_value = self.bot.snippets[name] + self.bot.snippets.pop(name) + self.bot.snippets[value] = old_snippet_value + await self.bot.config.update() + + embed = discord.Embed( + title="Renamed snippet", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) + else: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + await ctx.send(embed=embed) + @commands.command(usage=" [options]") @checks.has_permissions(PermissionLevel.MODERATOR) @checks.thread_only() diff --git a/cogs/utility.py b/cogs/utility.py index c420ee7979..5b4de12ee9 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1275,6 +1275,58 @@ async def alias_edit(self, ctx, name: str.lower, *, value): embed = await self.make_alias(name, value, "Edited") return await ctx.send(embed=embed) + @alias.command(name="rename") + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_rename(self, ctx, name: str.lower, *, value): + """ + Rename an alias. + """ + if name not in self.bot.aliases: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + + embed = None + if self.bot.get_command(value): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{value}`.", + ) + + elif value in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Another alias with the same name already exists: `{value}`.", + ) + + elif value in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A snippet with the same name already exists: `{value}`.", + ) + + elif len(value) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Alias names cannot be longer than 120 characters.", + ) + + if embed is None: + old_alias_value = self.bot.aliases[name] + self.bot.aliases.pop(name) + self.bot.aliases[value] = old_alias_value + await self.bot.config.update() + + embed = discord.Embed( + title="Alias renamed", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) + return await ctx.send(embed=embed) + @commands.group(aliases=["perms"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) async def permissions(self, ctx): From cea1800f600b03aba55686fe3851af8b55f6e579 Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:42:27 +0100 Subject: [PATCH 06/16] Fixes config_help notes (#3407) Fixes config_help notes variables inside the `thread_close_response` and `thread_self_close_response`. --- core/config_help.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/config_help.json b/core/config_help.json index b5832935c7..fedf9279ed 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -533,7 +533,7 @@ "notes": [ "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{logkey}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." ] @@ -547,7 +547,7 @@ "notes": [ "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{logkey}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_self_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." ] From 0aae3f6d5507d4bdb301828f25b1081965de9cce Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:49:10 +0100 Subject: [PATCH 07/16] Git Repository check for bot update (#3406) * Git Repository check for bot update Adds a check mechanism for the `?update` command and the autoupdate task to ensure the bot has been installed via git before trying to update it. * Fix typo in update command --------- Co-authored-by: Sebastian <61157793+sebkuip@users.noreply.github.com> --- bot.py | 33 +++++++++++++++++++++++++++++++++ cogs/utility.py | 29 +++++++++++++---------------- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/bot.py b/bot.py index 6176ac5824..9f3de008a1 100644 --- a/bot.py +++ b/bot.py @@ -794,6 +794,33 @@ def check_manual_blocked(self, author: discord.Member) -> bool: logger.debug("User blocked, user %s.", author.name) return False + def check_local_git(self) -> bool: + """ + Checks if the bot is installed via git. + """ + valid_local_git = False + git_folder_path = os.path.join(".git") + + # Check if the .git folder exists and is a directory + if os.path.exists(git_folder_path) and os.path.isdir(git_folder_path): + required_files = ["config", "HEAD"] + required_dirs = ["refs", "objects"] + + # Verify required files exist + for file in required_files: + if not os.path.isfile(os.path.join(git_folder_path, file)): + return valid_local_git + + # Verify required directories exist + for directory in required_dirs: + if not os.path.isdir(os.path.join(git_folder_path, directory)): + return valid_local_git + + # If all checks pass, set valid_local_git to True + valid_local_git = True + + return valid_local_git + async def _process_blocked(self, message): _, blocked_emoji = await self.retrieve_emoji() if await self.is_blocked(message.author, channel=message.channel, send_message=True): @@ -2160,6 +2187,12 @@ async def before_autoupdate(self): self.autoupdate.cancel() return + if not self.check_local_git(): + logger.warning("Bot not installed via git.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + @tasks.loop(hours=1, reconnect=False) async def log_expiry(self): log_expire_after = self.config.get("log_expiration") diff --git a/cogs/utility.py b/cogs/utility.py index 5b4de12ee9..d14aa97baa 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -2134,11 +2134,7 @@ async def update(self, ctx, *, flag: str = ""): data = await self.bot.api.get_user_info() if data: user = data["user"] - embed.set_author( - name=user["username"], - icon_url=user["avatar_url"] if user["avatar_url"] else None, - url=user["url"], - ) + embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: error = None @@ -2177,7 +2173,7 @@ async def update(self, ctx, *, flag: str = ""): embed.set_author( name=user["username"] + " - Updating bot", - icon_url=user["avatar_url"] if user["avatar_url"] else None, + icon_url=user["avatar_url"], url=user["url"], ) @@ -2195,13 +2191,18 @@ async def update(self, ctx, *, flag: str = ""): color=self.bot.main_color, ) embed.set_footer(text="Force update") - embed.set_author( - name=user["username"], - icon_url=user["avatar_url"] if user["avatar_url"] else None, - url=user["url"], - ) + embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: + if self.bot.check_local_git() is False: + embed = discord.Embed( + title="Update Command Unavailable", + description="The bot cannot be updated due to not being installed via git." + "You need to manually update the bot according to your hosting method." + "If you face any issues please donยดt hesitate to contact modmail support.", + color=discord.Color.red(), + ) + return await ctx.send(embed=embed) command = "git pull" proc = await asyncio.create_subprocess_shell( command, @@ -2214,11 +2215,7 @@ async def update(self, ctx, *, flag: str = ""): res = res.decode("utf-8").rstrip() if err and not res: - embed = discord.Embed( - title="Update failed", - description=err, - color=self.bot.error_color, - ) + embed = discord.Embed(title="Update failed", description=err, color=self.bot.error_color) await ctx.send(embed=embed) elif res != "Already up to date.": From 16f2ba6b53503baf0963689194bd2a92ac95ca7e Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:31:51 +0100 Subject: [PATCH 08/16] Fix Plugin Help (#3322) * Updates Plugin Wiki Link As the github repo wiki got moved to the own docs page this link needs to be updated. I will update it accordingly if docs may change later. * Fix @local/name doc * Chnaged Plugin Help Link for #3322 Plugin Help link got moved again into a new page of the docs. --------- Co-authored-by: Sebastian <61157793+sebkuip@users.noreply.github.com> --- cogs/plugins.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cogs/plugins.py b/cogs/plugins.py index aa4ad5a65c..a5cece7ab6 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -113,7 +113,7 @@ class Plugins(commands.Cog): These addons could have a range of features from moderation to simply making your life as a moderator easier! Learn how to create a plugin yourself here: - https://github.com/modmail-dev/modmail/wiki/Plugins + https://docs.modmail.dev/usage-guide/plugins """ def __init__(self, bot): @@ -332,7 +332,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): embed = discord.Embed( description="Invalid plugin name, double check the plugin name " "or use one of the following formats: " - "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", + "username/repo/plugin-name, username/repo/plugin-name@branch, @local/plugin-name.", color=self.bot.error_color, ) await ctx.send(embed=embed) @@ -357,7 +357,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) - or `local/name` for local plugins. + or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) @@ -444,7 +444,7 @@ async def plugins_remove(self, ctx, *, plugin_name: str): Remove an installed plugin of the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name) if plugin is None: @@ -526,7 +526,7 @@ async def plugins_update(self, ctx, *, plugin_name: str = None): Update a plugin for the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. To update all plugins, do `{prefix}plugins update`. """ From ea29d8f0ebcd133c9f16c440fb3eed0d0dc71f8f Mon Sep 17 00:00:00 2001 From: Sebastian <61157793+sebkuip@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:09:45 +0100 Subject: [PATCH 09/16] Added submenu functionality to threadmenu. Fixes #3403 (#3404) * Threadmenu now supports submenus * Fix a small issue with path not resetting after main menu. * Fix copilot suggestions * Black formatting * Fix undeclared vars * threadmenu: submenu navigation fixes Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> * threadmenu: submenu navigation fixes Refactor thread creation menu handling to improve path management and submenu navigation. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> * Fix formatting according to black --------- Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Co-authored-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- cogs/threadmenu.py | 3 ++ core/thread.py | 108 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py index 7f9e193844..0e225d527f 100644 --- a/cogs/threadmenu.py +++ b/cogs/threadmenu.py @@ -178,6 +178,9 @@ def typecheck(m): if label.lower() == "cancel": return await ctx.send("Cancelled.") + if label.lower() == "main menu": + return await ctx.send("You cannot use that label.") + if sanitized_label in conf["options"]: await ctx.send("That option already exists. Use `threadmenu edit` to edit it.") return diff --git a/core/thread.py b/core/thread.py index bf77180f8c..b3a9bf35a5 100644 --- a/core/thread.py +++ b/core/thread.py @@ -849,11 +849,9 @@ async def send_genesis_message(): if getattr(self, "_selected_thread_creation_menu_option", None) and self.bot.config.get( "thread_creation_menu_selection_log" ): - opt = self._selected_thread_creation_menu_option + path = self._selected_thread_creation_menu_option try: - log_txt = f"Selected menu option: {opt.get('label')} ({opt.get('type')})" - if opt.get("type") == "command": - log_txt += f" -> {opt.get('callback')}" + log_txt = f"Selected menu path: {' -> '.join(path)}" await channel.send(embed=discord.Embed(description=log_txt, color=self.bot.mod_color)) except Exception: logger.warning( @@ -2659,29 +2657,44 @@ async def create( placeholder = "Select an option to contact the staff team." timeout = 20 - options = self.bot.config.get("thread_creation_menu_options") or {} - submenus = self.bot.config.get("thread_creation_menu_submenus") or {} - # Minimal inline view implementation (avoid importing plugin code) thread.ready = False # not ready yet class _ThreadCreationMenuSelect(discord.ui.Select): - def __init__(self, outer_thread: Thread): + def __init__( + self, + bot, + outer_thread: Thread, + option_data: dict, + menu_msg: discord.Message, + path: list, + is_home: bool = True, + ): + self.bot = bot self.outer_thread = outer_thread - opts = [ + self.option_data = option_data + self.menu_msg = menu_msg + self.path = path + options = [ discord.SelectOption( label=o["label"], description=o["description"], emoji=o["emoji"], ) - for o in options.values() + for o in option_data.values() ] + if not is_home: + options.append( + discord.SelectOption( + label="main menu", description="Return to the main menu", emoji="๐Ÿ " + ) + ) super().__init__( placeholder=placeholder, min_values=1, max_values=1, - options=opts, + options=options, ) async def callback(self, interaction: discord.Interaction): @@ -2696,8 +2709,45 @@ async def callback(self, interaction: discord.Interaction): chosen_label = self.values[0] # Resolve option key key = chosen_label.lower().replace(" ", "_") - selected = options.get(key) - self.outer_thread._selected_thread_creation_menu_option = selected + if key == "main_menu": + option_data = self.bot.config.get("thread_creation_menu_options") or {} + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + option_data, + self.menu_msg, + path=[], + is_home=True, + ) + return await self.menu_msg.edit(view=new_view) + selected: dict = self.option_data.get(key, {}) + next_path = [*self.path, chosen_label] + if selected.get("type", "command") == "submenu": + submenu_data = self.bot.config.get("thread_creation_menu_submenus") or {} + submenu_key = selected.get("callback", key) + option_data = submenu_data.get(submenu_key, {}) + if not option_data: + home_options = self.bot.config.get("thread_creation_menu_options") or {} + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + home_options, + self.menu_msg, + path=[], + is_home=True, + ) + return await self.menu_msg.edit(view=new_view) + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + option_data, + self.menu_msg, + path=next_path, + is_home=False, + ) + return await self.menu_msg.edit(view=new_view) + + self.outer_thread._selected_thread_creation_menu_option = next_path # Reflect the selection in the original DM by editing the embed/body try: msg = getattr(interaction, "message", None) @@ -2936,10 +2986,30 @@ async def callback(self, interaction: discord.Interaction): ctx_.command.checks = old_checks class _ThreadCreationMenuView(discord.ui.View): - def __init__(self, outer_thread: Thread): + def __init__( + self, + bot, + outer_thread: Thread, + option_data: dict, + menu_msg: discord.Message, + path: list, + is_home: bool = True, + ): super().__init__(timeout=timeout) self.outer_thread = outer_thread - self.add_item(_ThreadCreationMenuSelect(outer_thread)) + self.path = path + self.menu_msg = menu_msg + self.option_data = option_data + self.add_item( + _ThreadCreationMenuSelect( + bot, + outer_thread, + option_data=option_data, + menu_msg=menu_msg, + path=self.path, + is_home=is_home, + ) + ) async def on_timeout(self): # Timeout -> abort thread creation @@ -3061,8 +3131,12 @@ async def on_timeout(self): embed.set_thumbnail(url=embed_thumb) except Exception as e: logger.debug("Thumbnail set failed (ignored): %s", e) - menu_view = _ThreadCreationMenuView(thread) - menu_msg = await recipient.send(embed=embed, view=menu_view) + menu_msg = await recipient.send(embed=embed) + option_data = self.bot.config.get("thread_creation_menu_options") or {} + menu_view = _ThreadCreationMenuView( + self.bot, thread, option_data, menu_msg, path=[], is_home=True + ) + menu_msg = await menu_msg.edit(view=menu_view) # mark thread as pending menu selection thread._pending_menu = True # Explicitly attach the message to the view for safety in callbacks From 2fbd7e52ae902927dee392b7d5f321426ecd8e39 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:39:43 +0100 Subject: [PATCH 10/16] Fix: Blackformatting, workflows. Add snooze/unsnooze events. (#3412) * improvements changelog.md * remove advancedmenu plugin * fix: hide privatekey from changelog This is for internal use only. * black formatting * feat: dispatch event for snoozing/unsnoozing. This allows plugin developers to create feature on snoozing/unsnoozing. * bump pipfile * Update Pipfile.lock * black formatting * sync with pipfile. --------- Co-authored-by: Sebastian <61157793+sebkuip@users.noreply.github.com> --- CHANGELOG.md | 66 +++++++++++--- Pipfile | 17 ++-- Pipfile.lock | 218 +++++++++-------------------------------------- core/thread.py | 6 ++ requirements.txt | 9 +- 5 files changed, 108 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 536680ad2a..2b7a7e28ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,27 +9,65 @@ however, insignificant breaking changes do not guarantee a major version bump, s # v4.2.1 ### Added + +**New Configuration Options:** * `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-creation menu: Adds an interactive select step before a thread channel is created. - * Commands: - * `threadmenu toggle`: Enable/disable the menu. - * `threadmenu show`: List current top-level options. - * `threadmenu option add`: Interactive wizard to create an option. - * `threadmenu option edit/remove/show`: Manage or inspect an existing option. - * `threadmenu submenu create/delete/list/show`: Manage submenus. - * `threadmenu submenu option add/edit/remove`: Manage options inside a submenu. - * Configuration / Behavior: - * Per-option `category` targeting when creating a thread; falls back to `main_category_id` if invalid/missing. - * Optional selection logging (`thread_creation_menu_selection_log`) posts the chosen option in the new thread. - * Anonymous prompt support (`thread_creation_menu_anonymous_menu`). +* `snooze_store_attachments`: When enabled, image attachments are stored as base64 when snoozing with delete behavior, allowing them to be re-uploaded on unsnooze. +* `snooze_attachment_max_bytes`: Maximum size per attachment to store as base64 (default: 4 MiB). +* `thread_creation_menu_timeout`: Timeout duration for user interaction with the menu (default: 30 seconds). +* `thread_creation_menu_close_on_timeout`: Silently abort thread creation if user doesn't select an option. +* `thread_creation_menu_anonymous_menu`: Anonymize the initial menu prompt relayed to staff. +* `thread_creation_menu_embed_text`: Text shown in the embed above the selection dropdown. +* `thread_creation_menu_dropdown_placeholder`: Placeholder text in the dropdown before selection. +* `thread_creation_menu_selection_log`: Log the chosen menu option in the newly created thread channel. +* `thread_creation_menu_precreate_channel`: Create thread channel immediately upon first DM even if menu is enabled. +* `thread_creation_menu_embed_title`: Optional title for the thread-creation menu embed. +* `thread_creation_menu_embed_footer`: Optional footer text for the menu embed. +* `thread_creation_menu_embed_footer_icon_url`: Optional URL for the footer icon. +* `thread_creation_menu_embed_thumbnail_url`: Optional thumbnail image URL. +* `thread_creation_menu_embed_image_url`: Optional large hero image URL for the menu embed. +* `thread_creation_menu_embed_large_image`: Promote thumbnail to large hero image if no separate image URL is set. +* `thread_creation_menu_embed_color`: Color for the menu embed's side strip. + +**Thread-Creation Menu Feature:** +* Full thread-creation menu system with interactive select menus: + * `?threadmenu toggle`: Enable/disable the menu globally. + * `?threadmenu show`: List current top-level options. + * `?threadmenu option add`: Interactive wizard to create an option. + * `?threadmenu option edit/remove/show`: Manage or inspect existing options. + * `?threadmenu submenu create/delete/list/show`: Manage submenus (nested menu levels). + * `?threadmenu submenu option add/edit/remove`: Manage options inside submenus. + * `?threadmenu dump_config`: Export current configuration to a file. + * `?threadmenu load_config`: Import configuration from a file. + * `?threadmenu reset`: Reset all thread-creation menu settings to defaults. +* Per-option category targeting: Each menu option can specify a target category where threads are created. +* Submenu support: Create up to 25 main-level options, each with up to 24 nested options. +* Optional selection logging: Log which menu option was chosen in the newly created thread channel. +* Anonymous menu support: Hide original prompt author context from staff when menu is anonymized. +* Category fallback: If an option's category is invalid/missing, creation falls back to `main_category_id`. + +**Snooze Enhancements:** +* Attachment persistence for delete-behavior snoozing: Image attachments can now be stored as base64 data. +* Enhanced unsnooze functionality with configurable message replay limits. +* Auto-unsnooze task continuously monitors and automatically unsnoozes threads when duration expires. ### Changed -- Renamed `max_snooze_time` to `snooze_default_duration`. The old config will be invalidated. +- Renamed `max_snooze_time` to `snooze_default_duration` (accepts seconds or human-readable time like "7 days"). - 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`. +- Thread-creation menu options and submenu options now support per-option `category` targeting. +- Category selection in menu option wizards allows specifying ID, name, or mention format. +- Snoozed thread restoration now respects `unsnooze_history_limit` (if set) to replay only the last N messages. +- Enhanced auto-unsnooze task monitors and automatically unsnoozes threads when their snooze duration expires. +- Snoozed threads can now be moved to a dedicated category instead of being deleted (via `snooze_behavior: move`). + +### Fixed + +- Corrected behavior when snooze channel count reaches the 49-channel limit in move-based snoozing. +- Improved category resolution in threadmenu wizards (handles ID, name, and mention formats reliably). +- Enhanced thread state restoration after unsnoozing to properly re-add all recipients. # v4.2.0 diff --git a/Pipfile b/Pipfile index daa0e60698..6feb42454e 100644 --- a/Pipfile +++ b/Pipfile @@ -7,28 +7,29 @@ verify_ssl = true bandit = ">=1.7.5" black = "==23.11.0" pylint = "==3.0.2" -tomli = "==2.2.1" # Needed for black on Python < 3.11 +tomli = "==2.2.1" [packages] aiohttp = "==3.13.2" -async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} # Required by aiohttp -typing-extensions = ">=4.12.2" # Required by aiohttp +async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} +typing-extensions = "==4.15.0" colorama = "==0.4.6" "discord.py" = {version = "==2.6.3", extras = ["speed"]} emoji = "==2.8.0" isodate = "==0.6.1" motor = "==3.7.1" -natural = "==0.2.0" # Why is this needed? +natural = "==0.2.0" packaging = "==23.2" parsedatetime = "==2.6" -dnspython = ">=2.8,<3" # Required by pymongo -pymongo = ">=4.9,<5" # Required by motor +dnspython = "==2.8.0" +pymongo = "==4.15.3" python-dateutil = "==2.8.2" python-dotenv = "==1.0.0" -uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} +uvloop = {version = "==0.22.1", markers = "sys_platform != 'win32'"} lottie = {version = "==0.7.2", extras = ["pdf"]} -setuptools = "*" # Needed for lottie +setuptools = "==80.9.0" requests = "==2.31.0" +orjson = "==3.11.4" [scripts] bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index 39cd6c33e2..011514b29b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b9e47a4bb95c39f0d11eeffe03c9229ef1751eec0e412c1a9b4c1f6dc47ed754" + "sha256": "6dc9fd3ca0aa2c413384ee16afb30290a840f6755cbf0bf828d0661171604db4" }, "pipfile-spec": 6, "requires": {}, @@ -14,14 +14,6 @@ ] }, "default": { - "aiodns": { - "hashes": [ - "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310", - "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5" - ], - "markers": "python_version >= '3.9'", - "version": "==3.5.0" - }, "aiohappyeyeballs": { "hashes": [ "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", @@ -181,61 +173,6 @@ "markers": "python_version >= '3.9'", "version": "==25.4.0" }, - "audioop-lts": { - "hashes": [ - "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", - "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", - "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", - "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", - "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", - "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", - "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", - "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", - "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", - "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", - "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", - "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", - "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", - "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", - "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", - "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", - "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", - "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", - "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", - "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", - "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", - "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", - "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", - "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", - "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", - "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", - "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", - "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", - "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", - "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", - "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", - "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", - "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", - "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", - "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", - "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", - "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", - "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", - "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", - "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", - "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", - "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", - "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", - "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", - "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", - "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", - "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", - "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", - "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800" - ], - "markers": "python_version >= '3.13'", - "version": "==0.2.2" - }, "brotli": { "hashes": [ "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", @@ -359,11 +296,11 @@ }, "certifi": { "hashes": [ - "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", - "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", + "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" ], "markers": "python_version >= '3.7'", - "version": "==2025.10.5" + "version": "==2025.11.12" }, "cffi": { "hashes": [ @@ -1048,6 +985,7 @@ "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1", "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff" ], + "index": "pypi", "markers": "python_version >= '3.9'", "version": "==3.11.4" }, @@ -1293,104 +1231,6 @@ "markers": "python_version >= '3.9'", "version": "==0.4.1" }, - "pycares": { - "hashes": [ - "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c", - "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84", - "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b", - "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4", - "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf", - "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c", - "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a", - "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266", - "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759", - "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84", - "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642", - "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4", - "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199", - "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21", - "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a", - "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8", - "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6", - "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9", - "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad", - "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00", - "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287", - "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3", - "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3", - "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689", - "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e", - "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad", - "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4", - "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605", - "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e", - "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a", - "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4", - "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34", - "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4", - "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c", - "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26", - "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae", - "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d", - "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47", - "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca", - "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80", - "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa", - "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb", - "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd", - "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766", - "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80", - "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739", - "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197", - "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b", - "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b", - "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461", - "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e", - "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf", - "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b", - "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807", - "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d", - "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83", - "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690", - "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba", - "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566", - "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0", - "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a", - "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", - "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8", - "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36", - "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87", - "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078", - "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240", - "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f", - "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557", - "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed", - "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49", - "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232", - "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401", - "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d", - "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01", - "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0", - "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c", - "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c", - "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5", - "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35", - "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8", - "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28", - "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7", - "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502", - "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85", - "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134", - "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a", - "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2", - "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468", - "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e", - "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d", - "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7" - ], - "markers": "python_version >= '3.9'", - "version": "==4.11.0" - }, "pycparser": { "hashes": [ "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", @@ -1523,11 +1363,11 @@ }, "tinycss2": { "hashes": [ - "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", - "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" + "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", + "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957" ], - "markers": "python_version >= '3.8'", - "version": "==1.4.0" + "markers": "python_version >= '3.10'", + "version": "==1.5.1" }, "typing-extensions": { "hashes": [ @@ -1861,12 +1701,12 @@ }, "bandit": { "hashes": [ - "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0", - "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b" + "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce", + "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868" ], "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.8.6" + "markers": "python_version >= '3.10'", + "version": "==1.9.2" }, "black": { "hashes": [ @@ -1895,11 +1735,20 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", + "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.3.1" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" }, "dill": { "hashes": [ @@ -2080,11 +1929,11 @@ }, "stevedore": { "hashes": [ - "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", - "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73" + "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", + "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945" ], - "markers": "python_version >= '3.9'", - "version": "==5.5.0" + "markers": "python_version >= '3.10'", + "version": "==5.6.0" }, "tomli": { "hashes": [ @@ -2132,6 +1981,15 @@ ], "markers": "python_version >= '3.8'", "version": "==0.13.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } } } diff --git a/core/thread.py b/core/thread.py index b3a9bf35a5..09263b197d 100644 --- a/core/thread.py +++ b/core/thread.py @@ -259,6 +259,9 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): logging.info(f"[SNOOZE] DB update result: {result.modified_count}") + # Dispatch thread_snoozed event for plugins + self.bot.dispatch("thread_snoozed", self, moderator, snooze_for) + behavior = behavior_pre if behavior == "move": # Move the channel to the snoozed category (if configured) and optionally apply a prefix @@ -751,6 +754,9 @@ async def _ensure_genesis(force: bool = False): # Mark unsnooze as complete self._unsnoozing = False + # Dispatch thread_unsnoozed event for plugins + self.bot.dispatch("thread_unsnoozed", self) + # Process queued commands await self._process_command_queue() diff --git a/requirements.txt b/requirements.txt index 9c07172039..1120657d07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,13 @@ --i https://pypi.org/simple -aiodns==3.5.0; python_version >= '3.9' +๏ปฟ-i https://pypi.org/simple aiohappyeyeballs==2.6.1; python_version >= '3.9' aiohttp==3.13.2; python_version >= '3.9' aiosignal==1.4.0; python_version >= '3.9' async-timeout==5.0.1; python_version < '3.11' attrs==25.4.0; python_version >= '3.9' -audioop-lts==0.2.2; python_version >= '3.13' brotli==1.2.0 cairocffi==1.7.1; python_version >= '3.8' cairosvg==2.8.2; python_version >= '3.9' -certifi==2025.10.5; python_version >= '3.7' +certifi==2025.11.12; python_version >= '3.7' cffi==2.0.0; python_version >= '3.9' charset-normalizer==3.4.4; python_version >= '3.7' colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' @@ -30,7 +28,6 @@ packaging==23.2; python_version >= '3.7' parsedatetime==2.6 pillow==12.0.0; python_version >= '3.10' propcache==0.4.1; python_version >= '3.9' -pycares==4.11.0; python_version >= '3.9' pycparser==2.23; python_version >= '3.8' pymongo==4.15.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' @@ -38,7 +35,7 @@ python-dotenv==1.0.0; python_version >= '3.8' requests==2.31.0; python_version >= '3.7' setuptools==80.9.0; python_version >= '3.9' 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' +tinycss2==1.5.1; python_version >= '3.10' typing-extensions==4.15.0; python_version >= '3.9' urllib3==2.5.0; python_version >= '3.9' uvloop==0.22.1; sys_platform != 'win32' From 9833766fdcd5752f625d4cfa95d158c93b30639d Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Sat, 13 Dec 2025 23:55:19 +0100 Subject: [PATCH 11/16] Add threadmenu toggle notice (#3411) * Add threadmenu toggle notice Adds a notice to the `threadmenu toggle` command. It gets displayed if the advancedmenu plugin is part of the bot and checks if its enabled at the same time. useful for users because both would interrupt eachother. * Threadmenu toggle notice link Adds a link to the migration guide for the legacy plugin. --- cogs/threadmenu.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py index 0e225d527f..4f4f1985ae 100644 --- a/cogs/threadmenu.py +++ b/cogs/threadmenu.py @@ -87,6 +87,19 @@ async def threadmenu_toggle(self, ctx): 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'}.") + advancedmenu_plugin = self.bot.get_cog("AdvancedMenu") + if ( + advancedmenu_plugin + and hasattr(advancedmenu_plugin, "config") + and advancedmenu_plugin.config.get("enabled") + and advancedmenu_plugin.config["enabled"] is True + and conf["enabled"] + ): + await ctx.send( + "**Warning:** You are using both the core threadmenu feature and the advancedmenu plugin.\n" + "It is recommended to disable/uninstall the advancedmenu plugin to avoid interruption.\n" + "Migration guide can be found at: " + ) @checks.has_permissions(PermissionLevel.ADMINISTRATOR) @threadmenu.command(name="show") From 367bb3d05cb03f11230096fb1e7c3b03d0f5aa89 Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Sat, 20 Dec 2025 20:38:28 +0100 Subject: [PATCH 12/16] Improvements for alias creation/editing (#3422) Improves the make_alias function. --- cogs/utility.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index d14aa97baa..6047ae1205 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1129,7 +1129,7 @@ async def alias_raw(self, ctx, *, name: str.lower): return await ctx.send(embed=embed) - async def make_alias(self, name, value, action): + async def make_alias(self, name, value, action, ctx): values = utils.parse_alias(value) if not values: embed = discord.Embed( @@ -1176,16 +1176,23 @@ async def make_alias(self, name, value, action): if multiple_alias: embed.description = ( "The command you are attempting to point " - f"to does not exist: `{linked_command}`." + f"to on step {i} does not exist: `{linked_command}`." ) else: embed.description = ( "The command you are attempting to point " - f"to on step {i} does not exist: `{linked_command}`." + f"to does not exist: `{linked_command}`." ) return embed else: + if linked_command == "eval" and not await checks.check_permissions(ctx, "eval"): + embed = discord.Embed( + title="Error", + description="You can only add the `eval` command to an alias if you have permissions for that command.", + color=self.bot.error_color, + ) + return embed save_aliases.append(val) if multiple_alias: embed.add_field(name=f"Step {i}:", value=utils.truncate(val, 1024)) @@ -1240,7 +1247,7 @@ async def alias_add(self, ctx, name: str.lower, *, value): ) if embed is None: - embed = await self.make_alias(name, value, "Added") + embed = await self.make_alias(name, value, "Added", ctx) return await ctx.send(embed=embed) @alias.command(name="remove", aliases=["del", "delete"]) @@ -1272,7 +1279,7 @@ async def alias_edit(self, ctx, name: str.lower, *, value): embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) - embed = await self.make_alias(name, value, "Edited") + embed = await self.make_alias(name, value, "Edited", ctx) return await ctx.send(embed=embed) @alias.command(name="rename") From cb86a023e7a1fe48ea683eb7aecc745c07bc757d Mon Sep 17 00:00:00 2001 From: Martin <55140357+martinbndr@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:43:25 +0100 Subject: [PATCH 13/16] Fixes thread_auto_close execution when disabled. (#3423) * Fixes thread_auto_close execution when disabled. This fixes the issue #3290 which caused threads to be auto-closed even if `thread_auto_close` has been disabled. There was also an issue that closed the thread when the user has responded to mods. The thread should stay open and only auto close when the staff has replied back. * fix: prevent autoclosing when close has been cancelled. This solves the thread from autoclosing if the closure has been cancelled earlier in a thread. * fix: AttributeError / lower mongo calls. I had added a small bugfix aswell for pagination when an invalid config var was given. This happened to occur upon removing the `thread_auto_close` config. --------- Co-authored-by: lorenzo132 Co-authored-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- cogs/utility.py | 33 ++++++++++++++++++++++++++------- core/thread.py | 9 ++++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 6047ae1205..deae14f19e 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -873,14 +873,33 @@ async def config_remove(self, ctx, *, key: str.lower): color=self.bot.main_color, description=f"`{key}` had been reset to default.", ) + + # Cancel exsisting active closures from thread_auto_close due to being disabled. + if key == "thread_auto_close": + closures = self.bot.config["closures"] + for recipient_id, items in tuple(closures.items()): + if items.get("auto_close", False) is True: + self.bot.config["closures"].pop(recipient_id) + thread = await self.bot.threads.find(recipient_id=int(recipient_id)) + if thread: + await thread.cancel_closure(all=True) + else: + self.bot.config["closures"].pop(recipient_id) + # Only update config once after processing all closures + await self.bot.config.update() else: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"{key} is an invalid key.", - ) - valid_keys = [f"`{k}`" for k in sorted(keys)] - embed.add_field(name="Valid keys", value=", ".join(valid_keys)) + embeds = [] + for names in zip_longest(*(iter(sorted(keys)),) * 15): + description = "\n".join(f"`{name}`" for name in takewhile(lambda x: x is not None, names)) + embed = discord.Embed( + title="Error - Invalid Key", + color=self.bot.error_color, + description=f"`{key}` is an invalid key.\n\n**Valid configuration keys:**\n{description}", + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() return await ctx.send(embed=embed) diff --git a/core/thread.py b/core/thread.py index 09263b197d..45a6cb9c71 100644 --- a/core/thread.py +++ b/core/thread.py @@ -67,6 +67,7 @@ def __init__( self.wait_tasks = [] self.close_task = None self.auto_close_task = None + self.auto_close_cancelled = False # Track if auto-close was explicitly cancelled self._cancelled = False self._dm_menu_msg_id = None self._dm_menu_channel_id = None @@ -1078,6 +1079,7 @@ async def close( self.auto_close_task = task else: self.close_task = task + self.auto_close_cancelled = False # Reset flag when manually closing else: await self._close(closer, silent, delete_channel, message) @@ -1278,6 +1280,7 @@ async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> N if self.auto_close_task is not None and (auto_close or all): self.auto_close_task.cancel() self.auto_close_task = None + self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled to_update = self.bot.config["closures"].pop(str(self.id), None) if to_update is not None: @@ -1810,7 +1813,11 @@ async def send( return await destination.send(embed=embed) if not note and from_mod: - self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close + # Only restart auto-close if it wasn't explicitly cancelled + if not self.auto_close_cancelled: + self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close + elif not note and not from_mod: + await self.cancel_closure(all=True) if self.close_task is not None: # cancel closing if a thread message is sent. From 6e656cafcc082f8f7e555ad9a2f671f8df815970 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 21 Dec 2025 23:38:51 +0100 Subject: [PATCH 14/16] fix: breaking threadmenu fixes. This PR solves the following: - thread creation menu null pointer bugs - false cancelled cache entry - If a menu times out, and the user send multiiple messages in the meantime the user would not be able to create a new thread with previous code. --- core/clients.py | 72 +++++++++++++++++++++------------ core/models.py | 4 ++ core/thread.py | 103 +++++++++++++++++++++++++++++++++--------------- 3 files changed, 122 insertions(+), 57 deletions(-) diff --git a/core/clients.py b/core/clients.py index 90f09b3b48..eae92c2e03 100644 --- a/core/clients.py +++ b/core/clients.py @@ -661,32 +661,52 @@ async def append_log( channel_id: str = "", type_: str = "thread_message", ) -> dict: - channel_id = str(channel_id) or str(message.channel.id) - message_id = str(message_id) or str(message.id) - - data = { - "timestamp": str(message.created_at), - "message_id": message_id, - "author": { - "id": str(message.author.id), - "name": message.author.name, - "discriminator": message.author.discriminator, - "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, - "mod": not isinstance(message.channel, DMChannel), - }, - "content": message.content, - "type": type_, - "attachments": [ - { - "id": a.id, - "filename": a.filename, - "is_image": a.width is not None, - "size": a.size, - "url": a.url, - } - for a in message.attachments - ], - } + channel_id = str(channel_id) or (str(message.channel.id) if message else "") + message_id = str(message_id) or (str(message.id) if message else "") + + if message: + data = { + "timestamp": str(message.created_at), + "message_id": message_id, + "author": { + "id": str(message.author.id), + "name": message.author.name, + "discriminator": message.author.discriminator, + "avatar_url": ( + message.author.display_avatar.url if message.author.display_avatar else None + ), + "mod": not isinstance(message.channel, DMChannel), + }, + "content": message.content, + "type": type_, + "attachments": [ + { + "id": a.id, + "filename": a.filename, + "is_image": a.width is not None, + "size": a.size, + "url": a.url, + } + for a in message.attachments + ], + } + else: + # Fallback for when message is None but we still want to log something (e.g. system note) + # This requires at least some manual data to be useful. + data = { + "timestamp": str(discord.utils.utcnow()), + "message_id": message_id or "0", + "author": { + "id": "0", + "name": "System", + "discriminator": "0000", + "avatar_url": None, + "mod": True, + }, + "content": "System Message (No Content)", + "type": type_, + "attachments": [], + } return await self.logs.find_one_and_update( {"channel_id": channel_id}, diff --git a/core/models.py b/core/models.py index 5f36f12181..c7d1e79599 100644 --- a/core/models.py +++ b/core/models.py @@ -438,6 +438,10 @@ def __init__(self, message): self._message = message def __getattr__(self, name: str): + if self._message is None: + # If we're wrapping None, we can't delegate attributes. + # This mimics behavior where the attribute doesn't exist. + raise AttributeError(f"'DummyMessage' object has no attribute '{name}' (wrapped message is None)") return getattr(self._message, name) def __bool__(self): diff --git a/core/thread.py b/core/thread.py index 45a6cb9c71..83c4282d80 100644 --- a/core/thread.py +++ b/core/thread.py @@ -145,6 +145,7 @@ def cancelled(self) -> bool: def cancelled(self, flag: bool): self._cancelled = flag if flag: + self._ready_event.set() for i in self.wait_tasks: i.cancel() @@ -1781,6 +1782,13 @@ async def send( reply commands to avoid mutating the original message object. """ # Handle notes with Discord-like system message format - return early + if message is None: + # Safeguard against None messages (e.g. from menu interactions without a source message) + if not note and not from_mod and not thread_creation: + # If we're just trying to log/relay a user message and there is none, existing behavior + # suggests we might skip or error. Logging a warning and returning is safer than crashing. + return + if note: destination = destination or self.channel content = message.content or "[No content]" @@ -1835,7 +1843,8 @@ async def send( await self.wait_until_ready() if not from_mod and not note: - self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) + if self.channel: + self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) destination = destination or self.channel @@ -2558,6 +2567,10 @@ async def create( # checks for existing thread in cache thread = self.cache.get(recipient.id) if thread: + # If there's a pending menu, return the existing thread to avoid creating duplicates + if getattr(thread, "_pending_menu", False): + logger.debug("Thread for %s has pending menu, returning existing thread.", recipient) + return thread try: await thread.wait_until_ready() except asyncio.CancelledError: @@ -2566,8 +2579,8 @@ async def create( label = f"{recipient} ({recipient.id})" except Exception: label = f"User ({getattr(recipient, 'id', 'unknown')})" - logger.warning("Thread for %s cancelled, abort creating.", label) - return thread + self.cache.pop(recipient.id, None) + thread = None else: if thread.channel and self.bot.get_channel(thread.channel.id): logger.warning("Found an existing thread for %s, abort creating.", recipient) @@ -2915,35 +2928,36 @@ async def callback(self, interaction: discord.Interaction): setattr(self.outer_thread, "_pending_menu", False) return # Forward the user's initial DM to the thread channel - try: - await self.outer_thread.send(message) - except Exception: - logger.error( - "Failed to relay initial message after menu selection", - exc_info=True, - ) - else: - # React to the user's DM with the 'sent' emoji + if message: try: - ( - sent_emoji, - _, - ) = await self.outer_thread.bot.retrieve_emoji() - await self.outer_thread.bot.add_reaction(message, sent_emoji) - except Exception as e: - logger.debug( - "Failed to add sent reaction to user's DM: %s", - e, + await self.outer_thread.send(message) + except Exception: + logger.error( + "Failed to relay initial message after menu selection", + exc_info=True, + ) + else: + # React to the user's DM with the 'sent' emoji + try: + ( + sent_emoji, + _, + ) = await self.outer_thread.bot.retrieve_emoji() + await self.outer_thread.bot.add_reaction(message, sent_emoji) + except Exception as e: + logger.debug( + "Failed to add sent reaction to user's DM: %s", + e, + ) + # Dispatch thread_reply event for parity + self.outer_thread.bot.dispatch( + "thread_reply", + self.outer_thread, + False, + message, + False, + False, ) - # Dispatch thread_reply event for parity - self.outer_thread.bot.dispatch( - "thread_reply", - self.outer_thread, - False, - message, - False, - False, - ) # Clear pending flag setattr(self.outer_thread, "_pending_menu", False) except Exception: @@ -2964,7 +2978,34 @@ async def callback(self, interaction: discord.Interaction): # Create a synthetic message object that makes the bot appear # as the author for menu-invoked command replies so the user # selecting the option is not shown as a "mod" sender. - synthetic = DummyMessage(copy.copy(message)) + if message: + synthetic = DummyMessage(copy.copy(message)) + else: + # Fallback if no message exists (e.g. self-created thread via menu) + # We use the interaction's message or construct a minimal dummy + base_msg = getattr(interaction, "message", None) or self.menu_msg + synthetic = ( + DummyMessage(copy.copy(base_msg)) if base_msg else DummyMessage(None) + ) + # Ensure minimal attributes for Context if still missing (DummyMessage handles some, but we need more for commands) + if not synthetic._message: + # Identify a valid channel + ch = self.outer_thread.channel + if not ch: + # If channel isn't ready, we can't really invoke a command in it. + continue + + from unittest.mock import MagicMock + + # Create a mock message strictly for command invocation context + mock_msg = MagicMock(spec=discord.Message) + mock_msg.id = 0 + mock_msg.channel = ch + mock_msg.guild = self.outer_thread.bot.modmail_guild + mock_msg.content = self.outer_thread.bot.prefix + al + mock_msg.author = self.outer_thread.bot.user + synthetic = DummyMessage(mock_msg) + try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user From f089ba3396aec8ae0ec7749c22f2363d011b3ca1 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 24 Dec 2025 16:50:32 +0100 Subject: [PATCH 15/16] fix: thread_auto_close bug. This resolves an issue introduced in: https://github.com/modmail-dev/Modmail/pull/3423 Autocloses would fail. --- core/thread.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/core/thread.py b/core/thread.py index 83c4282d80..35b0cba6a0 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1056,7 +1056,10 @@ async def close( """Close a thread now or after a set time in seconds""" # restarts the after timer - await self.cancel_closure(auto_close) + await self.cancel_closure( + auto_close, + mark_auto_close_cancelled=not auto_close, + ) if after > 0: # TODO: Add somewhere to clean up broken closures @@ -1100,7 +1103,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, logger.error("Thread already closed: %s.", e) return - await self.cancel_closure(all=True) + await self.cancel_closure(all=True, mark_auto_close_cancelled=False) # Cancel auto closing the thread if closed by any means. @@ -1274,18 +1277,32 @@ async def _disable_dm_creation_menu(self) -> None: except Exception as inner_e: logger.debug("Failed removing view from DM menu message: %s", inner_e) - async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None: + async def cancel_closure( + self, + auto_close: bool = False, + all: bool = False, + *, + mark_auto_close_cancelled: bool = True, + ) -> None: if self.close_task is not None and (not auto_close or all): self.close_task.cancel() self.close_task = None if self.auto_close_task is not None and (auto_close or all): self.auto_close_task.cancel() self.auto_close_task = None - self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled - - to_update = self.bot.config["closures"].pop(str(self.id), None) - if to_update is not None: - await self.bot.config.update() + if mark_auto_close_cancelled: + self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled + + closure_key = str(self.id) + existing = self.bot.config["closures"].get(closure_key) + if existing is not None: + existing_is_auto = bool(existing.get("auto_close", False)) + should_remove = ( + all or (auto_close and existing_is_auto) or ((not auto_close) and (not existing_is_auto)) + ) + if should_remove: + self.bot.config["closures"].pop(closure_key, None) + await self.bot.config.update() async def _restart_close_timer(self): """ @@ -1821,11 +1838,14 @@ async def send( return await destination.send(embed=embed) if not note and from_mod: - # Only restart auto-close if it wasn't explicitly cancelled + # Only restart auto-close if it wasn't explicitly cancelled. + # Auto-close is driven by the last moderator reply. if not self.auto_close_cancelled: self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close elif not note and not from_mod: - await self.cancel_closure(all=True) + # If the user replied last, the thread should not auto-close. + # Cancel any pending auto-close without marking it as an explicit cancellation. + await self.cancel_closure(auto_close=True, mark_auto_close_cancelled=False) if self.close_task is not None: # cancel closing if a thread message is sent. From b68d82a933c028673d1aee5e02457dbf33ffb3b2 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:44:55 +0100 Subject: [PATCH 16/16] fix: plain replies deletion and edits (#3416) * support to edit and delete plain reply messages * fix linting * fix: not rely on mod_color as originally was made. This will avoid crashes when the mod_color get changed. * fix: typeerror / refactor * fix linting * silent unneeded noise --- bot.py | 2 + cogs/modmail.py | 6 +- core/thread.py | 209 +++++++++++++++++++++++++----------------------- 3 files changed, 116 insertions(+), 101 deletions(-) diff --git a/bot.py b/bot.py index 9f3de008a1..a022e99dcf 100644 --- a/bot.py +++ b/bot.py @@ -1913,6 +1913,8 @@ async def on_message_delete(self, message): "DM message not found.", "Malformed thread message.", "Thread message not found.", + "Linked DM message not found.", + "Thread message is an internal message, not a note.", }: logger.debug("Failed to find linked message to delete: %s", e) embed = discord.Embed(description="Failed to delete message.", color=self.error_color) diff --git a/cogs/modmail.py b/cogs/modmail.py index 0e39da920c..cbab46bcb0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -1724,11 +1724,11 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): try: await thread.edit_message(message_id, message) - except ValueError: + except ValueError as e: return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to edit. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) @@ -2274,7 +2274,7 @@ async def delete(self, ctx, message_id: int = None): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to delete. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) diff --git a/core/thread.py b/core/thread.py index 35b0cba6a0..34afe02a46 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1352,117 +1352,118 @@ async def find_linked_messages( message1: discord.Message = None, note: bool = True, ) -> typing.Tuple[discord.Message, typing.List[typing.Optional[discord.Message]]]: - if message1 is not None: - 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.") + if message1 is None: + if 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.") 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.") + # No ID provided - find last message sent by bot + async for msg in self.channel.history(): + if msg.author != self.bot.user: + continue + if not msg.embeds: + continue - 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.") + is_valid_candidate = False + if ( + msg.embeds[0].footer + and msg.embeds[0].footer.text + and msg.embeds[0].footer.text.startswith("[PLAIN]") + ): + is_valid_candidate = True + elif msg.embeds[0].author.url and msg.embeds[0].author.url.split("#")[-1].isdigit(): + is_valid_candidate = True + + if is_valid_candidate: + message1 = msg + break - 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 is None: + raise ValueError("No editable thread message found.") + + is_note = False + 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 message1.embeds[0].footer and "Internal Message" in message1.embeds[0].footer.text: - if not note: - 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 note and is_note: + 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(): - if ( - message1.embeds - and message1.embeds[0].author.url - and message1.embeds[0].color - and ( - message1.embeds[0].color.value == self.bot.mod_color - or (either_direction and message1.embeds[0].color.value == self.bot.recipient_color) - ) - and message1.embeds[0].author.url.split("#")[-1].isdigit() - and message1.author == self.bot.user - ): - break - else: + if not note and is_note: + raise ValueError("Thread message is an internal message, not a note.") + + if is_note: + return message1, None + + is_plain = False + if message1.embeds and message1.embeds[0].footer and message1.embeds[0].footer.text: + if message1.embeds[0].footer.text.startswith("[PLAIN]"): + is_plain = True + + if not is_plain: + # Relaxed mod_color check: only ensure author is bot and has url (which implies it's a relay) + # We rely on author.url existing for Joint ID + if not (message1.embeds and message1.embeds[0].author.url and message1.author == self.bot.user): raise ValueError("Thread message not found.") - try: - joint_id = int(message1.embeds[0].author.url.split("#")[-1]) - except ValueError: - raise ValueError("Malformed thread message.") + try: + joint_id = int(message1.embeds[0].author.url.split("#")[-1]) + except (ValueError, AttributeError, IndexError): + raise ValueError("Malformed thread message.") + else: + joint_id = None + mod_tag = message1.embeds[0].footer.text.replace("[PLAIN]", "", 1).strip() + author_name = message1.embeds[0].author.name + desc = message1.embeds[0].description or "" + prefix = f"**{mod_tag} " if mod_tag else "**" + plain_content_expected = f"{prefix}{author_name}:** {desc}" + creation_time = message1.created_at messages = [message1] - for user in self.recipients: - async for msg in user.history(): - if either_direction: - if msg.id == joint_id: - return message1, msg - if not (msg.embeds and msg.embeds[0].author.url): - continue - try: - if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + if is_plain: + for user in self.recipients: + async for msg in user.history(limit=50, around=creation_time): + if abs((msg.created_at - creation_time).total_seconds()) > 15: + continue + if msg.author != self.bot.user: + continue + if msg.embeds: + continue + + if msg.content == plain_content_expected: messages.append(msg) break - except ValueError: - continue + else: + for user in self.recipients: + async for msg in user.history(): + if either_direction: + if msg.id == joint_id: + messages.append(msg) + break + + if not (msg.embeds and msg.embeds[0].author.url): + continue + try: + if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + messages.append(msg) + break + except (ValueError, IndexError, AttributeError): + continue if len(messages) > 1: return messages - raise ValueError("DM message not found.") + raise ValueError("Linked DM message not found.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: @@ -1474,6 +1475,10 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1 = message1.embeds[0] embed1.description = message + is_plain = False + if embed1.footer and embed1.footer.text and embed1.footer.text.startswith("[PLAIN]"): + is_plain = True + tasks = [ self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1), @@ -1483,9 +1488,17 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> else: for m2 in message2: if m2 is not None: - embed2 = m2.embeds[0] - embed2.description = message - tasks += [m2.edit(embed=embed2)] + if is_plain: + # Reconstruct the plain message format to preserve matching capability + mod_tag = embed1.footer.text.replace("[PLAIN]", "", 1).strip() + author_name = embed1.author.name + prefix = f"**{mod_tag} " if mod_tag else "**" + new_content = f"{prefix}{author_name}:** {message}" + tasks += [m2.edit(content=new_content)] + else: + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks)