From c26af976c550264c4de5f51d1dbe7d63f67686bd 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 01/11] 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 a7ff289180dfbda7908a80d7af3d2074776f1820 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 02/11] 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 6e75c448f24390bfd27990fbd33729e9295099cf 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 03/11] 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 cfedecb24cfac541270b0fd1b6c59d2176a89c89 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 04/11] 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 d2b504236181740d8103c5ed9cca4c12d63b52cc 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 05/11] 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 6e094af903f155fab29ca54210888443340454af 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 06/11] 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 ++ plugins/registry.json | 9 -- requirements.txt | 9 +- 6 files changed, 108 insertions(+), 217 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/plugins/registry.json b/plugins/registry.json index 4079001a50..506df880bd 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", 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 3c3608fb2cc875c21b59e4875fbba3cda8c0897a 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 07/11] 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 ab458f5b09499fe009359a550c1f170d46215cc2 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 08/11] 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 ad801b117ca4ab9b2474190dc8669879aa8bcd47 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 09/11] 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 040195d91db7b66bec605dd47afc8265c291ed9f 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 10/11] 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 45a6cb9c71..7395e12b37 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1334,117 +1334,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: @@ -1456,6 +1457,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), @@ -1465,9 +1470,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) From 6a6023e894e4531971197f7ac6a6bd90f5f700f3 Mon Sep 17 00:00:00 2001 From: "Martin B." <55140357+martinbndr@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:55:39 +0100 Subject: [PATCH 11/11] Replace claim plugin 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 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/registry.json b/plugins/registry.json index 506df880bd..0bddd17533 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -63,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",