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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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 0df325ddbf263371dcd2c083a7d5821d42591203 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 11/40] 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 da6dff2bde62e7d4f8a949f4af61690923c7e05c Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 15:32:46 +0100 Subject: [PATCH 12/40] feat: bulk override perms for commands --- cogs/utility.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 129 insertions(+), 1 deletion(-) diff --git a/cogs/utility.py b/cogs/utility.py index deae14f19e..0e3039b8de 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1408,7 +1408,7 @@ def _parse_level(name): @permissions.command(name="override") @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str): + async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str = None): """ Change a permission level for a specific command. @@ -1422,8 +1422,16 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name - `{prefix}perms remove override reply` - `{prefix}perms remove override plugin enabled` + You can also override multiple commands at once using: + - `{prefix}perms override bulk` + You can retrieve a single or all command level override(s), see`{prefix}help permissions get`. """ + if command_name == "bulk": + return await self._bulk_override_flow(ctx) + + if level_name is None: + raise commands.MissingRequiredArgument(DummyParam("level_name")) command = self.bot.get_command(command_name) if command is None: @@ -1458,6 +1466,126 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name ) return await ctx.send(embed=embed) + async def _bulk_override_flow(self, ctx): + await ctx.send( + "Please list the commands you want to override. " + "You can list multiple commands separated by spaces or newlines.\n" + "Example: `ban, kick, mod`." + ) + + try: + msg = await self.bot.wait_for( + "message", + check=lambda m: m.author == ctx.author and m.channel == ctx.channel, + timeout=120.0, + ) + except asyncio.TimeoutError: + return await ctx.send("Timed out.") + + raw_commands = msg.content.replace(",", " ").replace("\n", " ").split(" ") + # Filter empty strings from split + raw_commands = [c for c in raw_commands if c.strip()] + + found_commands = [] + invalid_commands = [] + + for cmd_name in raw_commands: + cmd = self.bot.get_command(cmd_name) + if cmd: + found_commands.append(cmd) + else: + invalid_commands.append(cmd_name) + + if invalid_commands: + embed = discord.Embed( + title="Invalid Commands Found", + description=f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n" + "Do you want to continue with the valid commands? (y/n)", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + try: + msg = await self.bot.wait_for( + "message", + check=lambda m: m.author == ctx.author and m.channel == ctx.channel, + timeout=60.0, + ) + if msg.content.lower() not in ("y", "yes"): + return await ctx.send("Aborted.") + except asyncio.TimeoutError: + return await ctx.send("Timed out.") + + if not found_commands: + return await ctx.send("No valid commands provided. Aborting.") + + # Expand subcommands + final_commands = set() + + def add_command_recursive(cmd): + final_commands.add(cmd) + if hasattr(cmd, "commands"): + for sub in cmd.commands: + add_command_recursive(sub) + + for cmd in found_commands: + add_command_recursive(cmd) + + await ctx.send( + f"Found {len(final_commands)} commands (including subcommands).\n" + "What permission level should these commands be set to? (e.g. `Owner`, `Admin`, `Moderator`, `Supporter`, `User`)" + ) + + try: + msg = await self.bot.wait_for( + "message", + check=lambda m: m.author == ctx.author and m.channel == ctx.channel, + timeout=60.0, + ) + except asyncio.TimeoutError: + return await ctx.send("Timed out.") + + level_name = msg.content + level = self._parse_level(level_name) + if level == PermissionLevel.INVALID: + return await ctx.send(f"Invalid permission level: `{level_name}`. Aborting.") + + # Confirmation + command_list_str = ", ".join(f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name)) + + # Truncate if too long for embed description + if len(command_list_str) > 2000: + command_list_str = command_list_str[:1997] + "..." + + embed = discord.Embed( + title="Confirm Bulk Override", + description=f"**Level:** {level.name}\n\n**Commands:**\n{command_list_str}\n\n" + "Type `confirm` to apply these changes or `cancel` to abort.", + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + + try: + msg = await self.bot.wait_for( + "message", + check=lambda m: m.author == ctx.author and m.channel == ctx.channel and m.content.lower() in ("confirm", "cancel"), + timeout=30.0, + ) + except asyncio.TimeoutError: + return await ctx.send("Timed out. No changes applied.") + + if msg.content.lower() == "cancel": + return await ctx.send("Aborted.") + + # Apply changes + count = 0 + for cmd in final_commands: + self.bot.config["override_command_level"][cmd.qualified_name] = level.name + count += 1 + + await self.bot.config.update() + + await ctx.send(f"Successfully updated permissions for {count} commands.") + @permissions.command(name="add", usage="[command/level] [name] [user/role]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_add( From 3ea1b5e71787f2041da4691a18fc212f40198978 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 15:34:38 +0100 Subject: [PATCH 13/40] fix linting --- cogs/utility.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 0e3039b8de..339f1c66e8 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1550,8 +1550,10 @@ def add_command_recursive(cmd): return await ctx.send(f"Invalid permission level: `{level_name}`. Aborting.") # Confirmation - command_list_str = ", ".join(f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name)) - + command_list_str = ", ".join( + f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name) + ) + # Truncate if too long for embed description if len(command_list_str) > 2000: command_list_str = command_list_str[:1997] + "..." @@ -1567,7 +1569,9 @@ def add_command_recursive(cmd): try: msg = await self.bot.wait_for( "message", - check=lambda m: m.author == ctx.author and m.channel == ctx.channel and m.content.lower() in ("confirm", "cancel"), + check=lambda m: m.author == ctx.author + and m.channel == ctx.channel + and m.content.lower() in ("confirm", "cancel"), timeout=30.0, ) except asyncio.TimeoutError: @@ -1581,9 +1585,9 @@ def add_command_recursive(cmd): for cmd in final_commands: self.bot.config["override_command_level"][cmd.qualified_name] = level.name count += 1 - + await self.bot.config.update() - + await ctx.send(f"Successfully updated permissions for {count} commands.") @permissions.command(name="add", usage="[command/level] [name] [user/role]") From 2f0c585570930f670e3c4c865c08cb0230d98d08 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Dec 2025 03:41:06 +0100 Subject: [PATCH 14/40] change reflection upon review --- cogs/utility.py | 83 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 339f1c66e8..a417a8c899 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1467,11 +1467,16 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name return await ctx.send(embed=embed) async def _bulk_override_flow(self, ctx): - await ctx.send( - "Please list the commands you want to override. " - "You can list multiple commands separated by spaces or newlines.\n" - "Example: `ban, kick, mod`." + embed = discord.Embed( + title="Bulk Override", + description=( + "Please list the commands you want to override. " + "You can list multiple commands separated by spaces or newlines.\n" + "Example: `reply, block, unblock`." + ), + color=self.bot.main_color, ) + await ctx.send(embed=embed) try: msg = await self.bot.wait_for( @@ -1479,13 +1484,21 @@ async def _bulk_override_flow(self, ctx): check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=120.0, ) + except asyncio.TimeoutError: - return await ctx.send("Timed out.") + return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) raw_commands = msg.content.replace(",", " ").replace("\n", " ").split(" ") # Filter empty strings from split raw_commands = [c for c in raw_commands if c.strip()] + if self.bot.prefix: + # Strip prefix from commands if present + raw_commands = [ + c[len(self.bot.prefix) :] if c.startswith(self.bot.prefix) else c + for c in raw_commands + ] + found_commands = [] invalid_commands = [] @@ -1511,12 +1524,22 @@ async def _bulk_override_flow(self, ctx): timeout=60.0, ) if msg.content.lower() not in ("y", "yes"): - return await ctx.send("Aborted.") + return await ctx.send( + embed=discord.Embed( + title="Operation Aborted", + description="No changes have been applied.", + color=self.bot.error_color, + ) + ) except asyncio.TimeoutError: - return await ctx.send("Timed out.") + return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) if not found_commands: - return await ctx.send("No valid commands provided. Aborting.") + return await ctx.send( + embed=discord.Embed( + title="Error", description="No valid commands provided. Aborting.", color=self.bot.error_color + ) + ) # Expand subcommands final_commands = set() @@ -1530,10 +1553,15 @@ def add_command_recursive(cmd): for cmd in found_commands: add_command_recursive(cmd) - await ctx.send( - f"Found {len(final_commands)} commands (including subcommands).\n" - "What permission level should these commands be set to? (e.g. `Owner`, `Admin`, `Moderator`, `Supporter`, `User`)" + embed = discord.Embed( + title="Select Permission Level", + description=( + f"Found {len(final_commands)} commands (including subcommands).\n" + "What permission level should these commands be set to? (e.g. `Owner`, `Admin`, `Moderator`, `Supporter`, `User`)" + ), + color=self.bot.main_color, ) + await ctx.send(embed=embed) try: msg = await self.bot.wait_for( @@ -1541,13 +1569,20 @@ def add_command_recursive(cmd): check=lambda m: m.author == ctx.author and m.channel == ctx.channel, timeout=60.0, ) + except asyncio.TimeoutError: - return await ctx.send("Timed out.") + return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) level_name = msg.content level = self._parse_level(level_name) if level == PermissionLevel.INVALID: - return await ctx.send(f"Invalid permission level: `{level_name}`. Aborting.") + return await ctx.send( + embed=discord.Embed( + title="Error", + description=f"Invalid permission level: `{level_name}`. Aborting.", + color=self.bot.error_color, + ) + ) # Confirmation command_list_str = ", ".join( @@ -1575,10 +1610,20 @@ def add_command_recursive(cmd): timeout=30.0, ) except asyncio.TimeoutError: - return await ctx.send("Timed out. No changes applied.") + return await ctx.send( + embed=discord.Embed( + title="Error", description="Timed out. No changes applied.", color=self.bot.error_color + ) + ) if msg.content.lower() == "cancel": - return await ctx.send("Aborted.") + return await ctx.send( + embed=discord.Embed( + title="Operation Aborted", + description="No changes have been applied.", + color=self.bot.error_color, + ) + ) # Apply changes count = 0 @@ -1588,7 +1633,13 @@ def add_command_recursive(cmd): await self.bot.config.update() - await ctx.send(f"Successfully updated permissions for {count} commands.") + await ctx.send( + embed=discord.Embed( + title="Success", + description=f"Successfully updated permissions for {count} commands.", + color=self.bot.main_color, + ) + ) @permissions.command(name="add", usage="[command/level] [name] [user/role]") @checks.has_permissions(PermissionLevel.OWNER) From df15ad84ac2ec6fbd61c50552efd1cece2c5079e Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 14 Dec 2025 03:44:32 +0100 Subject: [PATCH 15/40] linting --- cogs/utility.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index a417a8c899..2ed638d7a6 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1486,7 +1486,9 @@ async def _bulk_override_flow(self, ctx): ) except asyncio.TimeoutError: - return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) + return await ctx.send( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + ) raw_commands = msg.content.replace(",", " ").replace("\n", " ").split(" ") # Filter empty strings from split @@ -1495,8 +1497,7 @@ async def _bulk_override_flow(self, ctx): if self.bot.prefix: # Strip prefix from commands if present raw_commands = [ - c[len(self.bot.prefix) :] if c.startswith(self.bot.prefix) else c - for c in raw_commands + c[len(self.bot.prefix) :] if c.startswith(self.bot.prefix) else c for c in raw_commands ] found_commands = [] @@ -1532,12 +1533,16 @@ async def _bulk_override_flow(self, ctx): ) ) except asyncio.TimeoutError: - return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) + return await ctx.send( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + ) if not found_commands: return await ctx.send( embed=discord.Embed( - title="Error", description="No valid commands provided. Aborting.", color=self.bot.error_color + title="Error", + description="No valid commands provided. Aborting.", + color=self.bot.error_color, ) ) @@ -1571,7 +1576,9 @@ def add_command_recursive(cmd): ) except asyncio.TimeoutError: - return await ctx.send(embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color)) + return await ctx.send( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + ) level_name = msg.content level = self._parse_level(level_name) From 9be3c4b4ca42e338fb596b4e93557eb543208313 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 23:04:32 +0100 Subject: [PATCH 16/40] resolve review. --- cogs/utility.py | 133 +++++++++++++++++++++++------------------------- 1 file changed, 65 insertions(+), 68 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 2ed638d7a6..901f93771c 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1467,12 +1467,13 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name return await ctx.send(embed=embed) async def _bulk_override_flow(self, ctx): + message = None embed = discord.Embed( title="Bulk Override", description=( "Please list the commands you want to override. " "You can list multiple commands separated by spaces or newlines.\n" - "Example: `reply, block, unblock`." + "Example: `reply, block, unblock`.\n" ), color=self.bot.main_color, ) @@ -1496,6 +1497,7 @@ async def _bulk_override_flow(self, ctx): if self.bot.prefix: # Strip prefix from commands if present + # Note: This does not account for mention prefixes. raw_commands = [ c[len(self.bot.prefix) :] if c.startswith(self.bot.prefix) else c for c in raw_commands ] @@ -1514,27 +1516,24 @@ async def _bulk_override_flow(self, ctx): embed = discord.Embed( title="Invalid Commands Found", description=f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n" - "Do you want to continue with the valid commands? (y/n)", + "Do you want to continue with the valid commands?", color=self.bot.error_color, ) - await ctx.send(embed=embed) - try: - msg = await self.bot.wait_for( - "message", - check=lambda m: m.author == ctx.author and m.channel == ctx.channel, - timeout=60.0, - ) - if msg.content.lower() not in ("y", "yes"): - return await ctx.send( - embed=discord.Embed( - title="Operation Aborted", - description="No changes have been applied.", - color=self.bot.error_color, - ) - ) - except asyncio.TimeoutError: - return await ctx.send( - embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + view = discord.ui.View() + view.add_item(utils.AcceptButton(custom_id="continue", emoji="βœ…")) + view.add_item(utils.DenyButton(custom_id="abort", emoji="❌")) + + message = await ctx.send(embed=embed, view=view) + await view.wait() + + if not view.value: + return await message.edit( + embed=discord.Embed( + title="Operation Aborted", + description="No changes have been applied.", + color=self.bot.error_color, + ), + view=None, ) if not found_commands: @@ -1562,90 +1561,88 @@ def add_command_recursive(cmd): title="Select Permission Level", description=( f"Found {len(final_commands)} commands (including subcommands).\n" - "What permission level should these commands be set to? (e.g. `Owner`, `Admin`, `Moderator`, `Supporter`, `User`)" + "What permission level should these commands be set to?" ), color=self.bot.main_color, ) - await ctx.send(embed=embed) - try: - msg = await self.bot.wait_for( - "message", - check=lambda m: m.author == ctx.author and m.channel == ctx.channel, - timeout=60.0, - ) + class LevelSelect(discord.ui.Select): + def __init__(self): + options = [ + discord.SelectOption(label="Owner", value="OWNER"), + discord.SelectOption(label="Administrator", value="ADMINISTRATOR"), + discord.SelectOption(label="Moderator", value="MODERATOR"), + discord.SelectOption(label="Supporter", value="SUPPORTER"), + discord.SelectOption(label="Regular", value="REGULAR"), + ] + super().__init__(placeholder="Select permission level...", options=options) + + async def callback(self, interaction: discord.Interaction): + self.view.value = self.values[0] + self.view.stop() + await interaction.response.defer() + + view = discord.ui.View() + view.add_item(LevelSelect()) + + if message: + await message.edit(embed=embed, view=view) + else: + message = await ctx.send(embed=embed, view=view) + await view.wait() - except asyncio.TimeoutError: - return await ctx.send( - embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + if view.value is None: + return await message.edit( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color), + view=None, ) - level_name = msg.content + level_name = view.value level = self._parse_level(level_name) - if level == PermissionLevel.INVALID: - return await ctx.send( - embed=discord.Embed( - title="Error", - description=f"Invalid permission level: `{level_name}`. Aborting.", - color=self.bot.error_color, - ) - ) # Confirmation command_list_str = ", ".join( f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name) ) - # Truncate if too long for embed description - if len(command_list_str) > 2000: - command_list_str = command_list_str[:1997] + "..." + command_list_str = utils.return_or_truncate(command_list_str, 2048) embed = discord.Embed( title="Confirm Bulk Override", - description=f"**Level:** {level.name}\n\n**Commands:**\n{command_list_str}\n\n" - "Type `confirm` to apply these changes or `cancel` to abort.", + description=f"**Level:** {level.name}\n\n**Commands:**\n{command_list_str}", color=self.bot.main_color, ) - await ctx.send(embed=embed) - try: - msg = await self.bot.wait_for( - "message", - check=lambda m: m.author == ctx.author - and m.channel == ctx.channel - and m.content.lower() in ("confirm", "cancel"), - timeout=30.0, - ) - except asyncio.TimeoutError: - return await ctx.send( - embed=discord.Embed( - title="Error", description="Timed out. No changes applied.", color=self.bot.error_color - ) - ) + view = discord.ui.View() + view.add_item(utils.AcceptButton(custom_id="confirm", emoji="βœ…")) + view.add_item(utils.DenyButton(custom_id="cancel", emoji="❌")) - if msg.content.lower() == "cancel": - return await ctx.send( + await message.edit(embed=embed, view=view) + await view.wait() + + if not view.value: + return await message.edit( embed=discord.Embed( title="Operation Aborted", description="No changes have been applied.", color=self.bot.error_color, - ) + ), + view=None, ) # Apply changes - count = 0 for cmd in final_commands: self.bot.config["override_command_level"][cmd.qualified_name] = level.name - count += 1 await self.bot.config.update() - await ctx.send( + await message.edit( embed=discord.Embed( title="Success", - description=f"Successfully updated permissions for {count} commands.", + description=f"Successfully updated permissions for {len(final_commands)} commands.", color=self.bot.main_color, - ) + ), + view=None, ) @permissions.command(name="add", usage="[command/level] [name] [user/role]") From ef8a5c23613a9567525391e6e5339777764f3685 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 23:16:44 +0100 Subject: [PATCH 17/40] fix: strip mention prefixes - show valid commands before apply. This aswell automatically skips invalid provided commands. --- cogs/utility.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 901f93771c..80ac92e1b6 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1495,12 +1495,17 @@ async def _bulk_override_flow(self, ctx): # Filter empty strings from split raw_commands = [c for c in raw_commands if c.strip()] + # Strip prefix from commands if present + prefixes = [self.bot.prefix, f"<@{self.bot.user.id}>", f"<@!{self.bot.user.id}>"] if self.bot.prefix: - # Strip prefix from commands if present - # Note: This does not account for mention prefixes. - raw_commands = [ - c[len(self.bot.prefix) :] if c.startswith(self.bot.prefix) else c for c in raw_commands - ] + for i, cmd in enumerate(raw_commands): + for p in prefixes: + if cmd.startswith(p): + raw_commands[i] = cmd[len(p) :] + break + + # Filter empty strings again after stripping prefixes + raw_commands = [c for c in raw_commands if c.strip()] found_commands = [] invalid_commands = [] @@ -1513,10 +1518,17 @@ async def _bulk_override_flow(self, ctx): invalid_commands.append(cmd_name) if invalid_commands: + description = f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n" + if found_commands: + found_list = ", ".join(c.qualified_name for c in found_commands) + found_list = utils.return_or_truncate(found_list, 1000) + description += f"The following commands **were** found:\n`{found_list}`\n\n" + + description += "Do you want to continue with the valid commands?" + embed = discord.Embed( title="Invalid Commands Found", - description=f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n" - "Do you want to continue with the valid commands?", + description=description, color=self.bot.error_color, ) view = discord.ui.View() From 7758dd248872d024e4c65d00b90029e76d9f46fd Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 20 Dec 2025 14:29:20 +0100 Subject: [PATCH 18/40] Implement requested change. --- cogs/utility.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 80ac92e1b6..2826523138 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1536,9 +1536,9 @@ async def _bulk_override_flow(self, ctx): view.add_item(utils.DenyButton(custom_id="abort", emoji="❌")) message = await ctx.send(embed=embed, view=view) - await view.wait() + timed_out = await view.wait() - if not view.value: + if timed_out or not view.value: return await message.edit( embed=discord.Embed( title="Operation Aborted", @@ -1601,9 +1601,9 @@ async def callback(self, interaction: discord.Interaction): await message.edit(embed=embed, view=view) else: message = await ctx.send(embed=embed, view=view) - await view.wait() + timed_out = await view.wait() - if view.value is None: + if timed_out or view.value is None: return await message.edit( embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color), view=None, @@ -1630,9 +1630,9 @@ async def callback(self, interaction: discord.Interaction): view.add_item(utils.DenyButton(custom_id="cancel", emoji="❌")) await message.edit(embed=embed, view=view) - await view.wait() + timed_out = await view.wait() - if not view.value: + if timed_out or not view.value: return await message.edit( embed=discord.Embed( title="Operation Aborted", From 72d2b9842e2269f5355c3bab86611ca4e6cd0ddd Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 2 Feb 2026 18:14:47 +0100 Subject: [PATCH 19/40] improve invalid commmand proccesing. --- cogs/utility.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index 2826523138..5fa98e60f3 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1509,16 +1509,33 @@ async def _bulk_override_flow(self, ctx): found_commands = [] invalid_commands = [] + seen_commands = set() + duplicate_count = 0 + + # Commands that should not be bulk-updated for safety reasons + blocked_commands = {"eval"} for cmd_name in raw_commands: cmd = self.bot.get_command(cmd_name) if cmd: - found_commands.append(cmd) + if cmd.qualified_name in blocked_commands: + invalid_commands.append(cmd_name) + elif cmd.qualified_name in seen_commands: + duplicate_count += 1 + else: + seen_commands.add(cmd.qualified_name) + found_commands.append(cmd) else: invalid_commands.append(cmd_name) - if invalid_commands: - description = f"The following commands were not found:\n`{', '.join(invalid_commands)}`\n\n" + if invalid_commands or duplicate_count > 0: + description = "" + if invalid_commands: + description += f"The following commands were not found or are blocked:\n`{', '.join(invalid_commands)}`\n\n" + if duplicate_count > 0: + description += ( + f"Ignoring {duplicate_count} duplicate command{'s' if duplicate_count > 1 else ''}.\n\n" + ) if found_commands: found_list = ", ".join(c.qualified_name for c in found_commands) found_list = utils.return_or_truncate(found_list, 1000) From dec20f851e542e1c6ce4b2847ea9b6beb6447547 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 4 Feb 2026 20:57:52 +0100 Subject: [PATCH 20/40] logging: bulk override --- cogs/utility.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cogs/utility.py b/cogs/utility.py index 5fa98e60f3..bc863a6f45 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1665,6 +1665,13 @@ async def callback(self, interaction: discord.Interaction): await self.bot.config.update() + logger.info( + "Bulk override: set permission level %s for %d commands: %s", + level.name, + len(final_commands), + ", ".join(cmd.qualified_name for cmd in final_commands), + ) + await message.edit( embed=discord.Embed( title="Success", From cad51228967881b01cc10d9509a0d0f5399c4aed Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:06:43 +0100 Subject: [PATCH 21/40] fix: show forwarded message in logviewer. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- core/clients.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/core/clients.py b/core/clients.py index 90f09b3b48..3826f55001 100644 --- a/core/clients.py +++ b/core/clients.py @@ -661,9 +661,18 @@ async def append_log( channel_id: str = "", type_: str = "thread_message", ) -> dict: + from core.utils import extract_forwarded_content + channel_id = str(channel_id) or str(message.channel.id) message_id = str(message_id) or str(message.id) + content = message.content or "" + if forwarded := extract_forwarded_content(message): + if content: + content += "\n" + forwarded + else: + content = forwarded + data = { "timestamp": str(message.created_at), "message_id": message_id, @@ -674,7 +683,7 @@ async def append_log( "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, "mod": not isinstance(message.channel, DMChannel), }, - "content": message.content, + "content": content, "type": type_, "attachments": [ { From 74a29698b30974e3731dd20c3e0dd4fb9d483120 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:07:04 +0100 Subject: [PATCH 22/40] fix: show forwarded message in logviewer. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- core/thread.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/core/thread.py b/core/thread.py index 7395e12b37..9f50998b10 100644 --- a/core/thread.py +++ b/core/thread.py @@ -206,7 +206,10 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "messages": [ { "author_id": m.author.id, - "content": m.content, + "content": ( + (m.content or "") + + (("\n" + extract_forwarded_content(m)) if extract_forwarded_content(m) else "") + ).strip(), "attachments": [a.url for a in m.attachments], "embeds": [e.to_dict() for e in m.embeds], "created_at": m.created_at.isoformat(), @@ -225,16 +228,12 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "author_name": ( getattr(m.embeds[0].author, "name", "").split(" (")[0] if m.embeds and m.embeds[0].author and m.author == self.bot.user - else getattr(m.author, "name", None) - if m.author != self.bot.user - else None + else getattr(m.author, "name", None) if m.author != self.bot.user else None ), "author_avatar": ( getattr(m.embeds[0].author, "icon_url", None) if m.embeds and m.embeds[0].author and m.author == self.bot.user - else m.author.display_avatar.url - if m.author != self.bot.user - else None + else m.author.display_avatar.url if m.author != self.bot.user else None ), } async for m in channel.history(limit=None, oldest_first=True) From 47e30c5b0ac45163c66e5b5183f759c431346ce8 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:07:21 +0100 Subject: [PATCH 23/40] fix: show forwarded message in logviewer. Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- core/utils.py | 76 +++++++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/core/utils.py b/core/utils.py index dc8737ead6..9d3fc160ac 100644 --- a/core/utils.py +++ b/core/utils.py @@ -658,39 +658,49 @@ def extract_forwarded_content(message) -> typing.Optional[str]: try: # Handle multi-forward (message_snapshots) - if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): - if hasattr(message, "message_snapshots") and message.message_snapshots: - forwarded_parts = [] - for snap in message.message_snapshots: - author = getattr(snap, "author", None) - author_name = getattr(author, "name", "Unknown") if author else "Unknown" - snap_content = getattr(snap, "content", "") - - if snap_content: - # Truncate very long messages to prevent spam - if len(snap_content) > 500: - snap_content = snap_content[:497] + "..." - forwarded_parts.append(f"**{author_name}:** {snap_content}") - elif getattr(snap, "embeds", None): - for embed in snap.embeds: - if hasattr(embed, "description") and embed.description: - embed_desc = embed.description - if len(embed_desc) > 300: - embed_desc = embed_desc[:297] + "..." - forwarded_parts.append(f"**{author_name}:** {embed_desc}") - break - elif getattr(snap, "attachments", None): - attachment_info = ", ".join( - [getattr(a, "filename", "Unknown") for a in snap.attachments[:3]] - ) - if len(snap.attachments) > 3: - attachment_info += f" (+{len(snap.attachments) - 3} more)" - forwarded_parts.append(f"**{author_name}:** [Attachments: {attachment_info}]") - else: - forwarded_parts.append(f"**{author_name}:** [No content]") - - if forwarded_parts: - return "\n".join(forwarded_parts) + # Check directly for snapshots as flags.has_snapshot can be unreliable in some versions + if getattr(message, "message_snapshots", None): + forwarded_parts = [] + for snap in message.message_snapshots: + author = getattr(snap, "author", None) + # If author is missing, we can try to rely on the container message context or just omit. + # Since we can't reliably get the original author from snapshot in this state, we focus on content. + + snap_content = getattr(snap, "content", "") + + formatted_part = "πŸ“¨ **Forwarded Message**\n" + + if snap_content: + if len(snap_content) > 500: + snap_content = snap_content[:497] + "..." + formatted_part += "\n".join([f"{line}" for line in snap_content.splitlines()]) + "\n" + + if getattr(snap, "embeds", None): + for embed in snap.embeds: + if hasattr(embed, "description") and embed.description: + embed_desc = embed.description + if len(embed_desc) > 300: + embed_desc = embed_desc[:297] + "..." + formatted_part += ( + "\n".join([f"> {line}" for line in embed_desc.splitlines()]) + "\n" + ) + break # One embed preview is usually enough + + if getattr(snap, "attachments", None): + attachment_info = ", ".join( + [getattr(a, "filename", "Unknown") for a in snap.attachments[:3]] + ) + if len(snap.attachments) > 3: + attachment_info += f" (+{len(snap.attachments) - 3} more)" + formatted_part += f"[Attachments: {attachment_info}]\n" + + # Add source link to the container message since snapshot doesn't have its own public link + formatted_part += f"\n**Source:** {message.jump_url}" + + forwarded_parts.append(formatted_part) + + if forwarded_parts: + return "\n".join(forwarded_parts) # Handle single-message forward elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): From db9b81a26bb2ca27561de81db20597b17664ca19 Mon Sep 17 00:00:00 2001 From: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:13:03 +0100 Subject: [PATCH 24/40] black formatting Signed-off-by: lorenzo132 <50767078+lorenzo132@users.noreply.github.com> --- core/thread.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/thread.py b/core/thread.py index 9f50998b10..99a3f67281 100644 --- a/core/thread.py +++ b/core/thread.py @@ -228,12 +228,16 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "author_name": ( getattr(m.embeds[0].author, "name", "").split(" (")[0] if m.embeds and m.embeds[0].author and m.author == self.bot.user - else getattr(m.author, "name", None) if m.author != self.bot.user else None + else getattr(m.author, "name", None) + if m.author != self.bot.user + else None ), "author_avatar": ( getattr(m.embeds[0].author, "icon_url", None) if m.embeds and m.embeds[0].author and m.author == self.bot.user - else m.author.display_avatar.url if m.author != self.bot.user else None + else m.author.display_avatar.url + if m.author != self.bot.user + else None ), } async for m in channel.history(limit=None, oldest_first=True) From 8b4dfcb88e40ebc39860b8ba521f02739dec5203 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 18:23:43 +0100 Subject: [PATCH 25/40] remove jump_url I believe it is a safer aproach to remove the jumpurl from logviewer, since it shows in the threadchannel already. --- core/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/utils.py b/core/utils.py index 9d3fc160ac..d1f1260c22 100644 --- a/core/utils.py +++ b/core/utils.py @@ -693,10 +693,6 @@ def extract_forwarded_content(message) -> typing.Optional[str]: if len(snap.attachments) > 3: attachment_info += f" (+{len(snap.attachments) - 3} more)" formatted_part += f"[Attachments: {attachment_info}]\n" - - # Add source link to the container message since snapshot doesn't have its own public link - formatted_part += f"\n**Source:** {message.jump_url}" - forwarded_parts.append(formatted_part) if forwarded_parts: From d9c7d157c9962beff3495ddbe61730a4ce458172 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 20:29:23 +0100 Subject: [PATCH 26/40] feat: adds attachment support to forwarded content. --- bot.py | 2 ++ core/thread.py | 6 +++++ core/utils.py | 63 ++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index a022e99dcf..7aa120fdad 100644 --- a/bot.py +++ b/bot.py @@ -1018,6 +1018,8 @@ def __init__(self, original_message, forwarded_content): self.author = original_message.author self.content = forwarded_content self.attachments = [] + for snap in getattr(original_message, "message_snapshots", []): + self.attachments.extend(getattr(snap, "attachments", [])) self.stickers = [] self.created_at = original_message.created_at self.embeds = [] diff --git a/core/thread.py b/core/thread.py index 99a3f67281..3fa886c931 100644 --- a/core/thread.py +++ b/core/thread.py @@ -35,6 +35,7 @@ ConfirmThreadCreationView, DummyParam, extract_forwarded_content, + extract_forwarded_attachments, ) logger = getLogger(__name__) @@ -1970,6 +1971,11 @@ async def send( embed.color = 0x5865F2 # Discord blurple for system messages ext = [(a.url, a.filename, False) for a in message.attachments] + + # Add forwarded message attachments + forwarded_attachments = extract_forwarded_attachments(message) + for url, filename in forwarded_attachments: + ext.append((url, filename, False)) images = [] attachments = [] diff --git a/core/utils.py b/core/utils.py index d1f1260c22..2b544eefdb 100644 --- a/core/utils.py +++ b/core/utils.py @@ -46,6 +46,7 @@ "ConfirmThreadCreationView", "DummyParam", "extract_forwarded_content", + "extract_forwarded_attachments", ] @@ -640,6 +641,36 @@ def __init__(self): self.value = None +def extract_forwarded_attachments(message) -> typing.List[typing.Tuple[str, str]]: + """ + Extract attachment URLs from forwarded messages. + + Parameters + ---------- + message : discord.Message + The message to extract attachments from. + + Returns + ------- + List[Tuple[str, str]] + List of (url, filename) tuples for attachments. + """ + import discord + attachments = [] + try: + if getattr(message, "message_snapshots", None): + for snap in message.message_snapshots: + if getattr(snap, "attachments", None): + for a in snap.attachments: + url = getattr(a, "url", None) + filename = getattr(a, "filename", "Unknown") + if url: + attachments.append((url.split('?')[0], filename)) + except Exception: + pass + return attachments + + def extract_forwarded_content(message) -> typing.Optional[str]: """ Extract forwarded message content from Discord forwarded messages. @@ -687,12 +718,18 @@ def extract_forwarded_content(message) -> typing.Optional[str]: break # One embed preview is usually enough if getattr(snap, "attachments", None): - attachment_info = ", ".join( - [getattr(a, "filename", "Unknown") for a in snap.attachments[:3]] - ) + attachment_links = [] + for a in snap.attachments[:3]: + filename = getattr(a, "filename", "Unknown") + url = getattr(a, "url", None) + if url: + url = url.split('?')[0] + attachment_links.append(f"[{filename}]({url})") + else: + attachment_links.append(filename) if len(snap.attachments) > 3: - attachment_info += f" (+{len(snap.attachments) - 3} more)" - formatted_part += f"[Attachments: {attachment_info}]\n" + attachment_links.append(f"(+{len(snap.attachments) - 3} more)") + formatted_part += f"πŸ“Ž {', '.join(attachment_links)}\n" forwarded_parts.append(formatted_part) if forwarded_parts: @@ -725,12 +762,18 @@ def extract_forwarded_content(message) -> typing.Optional[str]: embed_desc = embed_desc[:297] + "..." return f"**{ref_author_name}:** {embed_desc}" elif getattr(ref_msg, "attachments", None): - attachment_info = ", ".join( - [getattr(a, "filename", "Unknown") for a in ref_msg.attachments[:3]] - ) + attachment_links = [] + for a in ref_msg.attachments[:3]: + filename = getattr(a, "filename", "Unknown") + url = getattr(a, "url", None) + if url: + url = url.split('?')[0] + attachment_links.append(f"[{filename}]({url})") + else: + attachment_links.append(filename) if len(ref_msg.attachments) > 3: - attachment_info += f" (+{len(ref_msg.attachments) - 3} more)" - return f"**{ref_author_name}:** [Attachments: {attachment_info}]" + attachment_links.append(f"(+{len(ref_msg.attachments) - 3} more)") + return f"**{ref_author_name}:** πŸ“Ž {', '.join(attachment_links)}" except Exception as e: # Log and continue; failing to extract a reference preview shouldn't break flow logger.debug("Failed to extract reference preview: %s", e) From 58bb1ee00e56d7d266a1a5d9033233e1ad898743 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 18 Dec 2025 20:31:27 +0100 Subject: [PATCH 27/40] black formatting --- core/thread.py | 2 +- core/utils.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/thread.py b/core/thread.py index 3fa886c931..0a240ee669 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1971,7 +1971,7 @@ async def send( embed.color = 0x5865F2 # Discord blurple for system messages ext = [(a.url, a.filename, False) for a in message.attachments] - + # Add forwarded message attachments forwarded_attachments = extract_forwarded_attachments(message) for url, filename in forwarded_attachments: diff --git a/core/utils.py b/core/utils.py index 2b544eefdb..5079917740 100644 --- a/core/utils.py +++ b/core/utils.py @@ -656,6 +656,7 @@ def extract_forwarded_attachments(message) -> typing.List[typing.Tuple[str, str] List of (url, filename) tuples for attachments. """ import discord + attachments = [] try: if getattr(message, "message_snapshots", None): @@ -665,7 +666,7 @@ def extract_forwarded_attachments(message) -> typing.List[typing.Tuple[str, str] url = getattr(a, "url", None) filename = getattr(a, "filename", "Unknown") if url: - attachments.append((url.split('?')[0], filename)) + attachments.append((url.split("?")[0], filename)) except Exception: pass return attachments @@ -723,7 +724,7 @@ def extract_forwarded_content(message) -> typing.Optional[str]: filename = getattr(a, "filename", "Unknown") url = getattr(a, "url", None) if url: - url = url.split('?')[0] + url = url.split("?")[0] attachment_links.append(f"[{filename}]({url})") else: attachment_links.append(filename) @@ -767,7 +768,7 @@ def extract_forwarded_content(message) -> typing.Optional[str]: filename = getattr(a, "filename", "Unknown") url = getattr(a, "url", None) if url: - url = url.split('?')[0] + url = url.split("?")[0] attachment_links.append(f"[{filename}]({url})") else: attachment_links.append(filename) From 64e33f4eba30f5712d60b9e0ffefb73283b33142 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 27 Dec 2025 03:56:52 +0100 Subject: [PATCH 28/40] use: hassattr(), remove dedunant package --- core/utils.py | 53 +++++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/core/utils.py b/core/utils.py index 5079917740..8ef56572cf 100644 --- a/core/utils.py +++ b/core/utils.py @@ -655,13 +655,11 @@ def extract_forwarded_attachments(message) -> typing.List[typing.Tuple[str, str] List[Tuple[str, str]] List of (url, filename) tuples for attachments. """ - import discord - attachments = [] try: - if getattr(message, "message_snapshots", None): + if hasattr(message, "message_snapshots") and message.message_snapshots: for snap in message.message_snapshots: - if getattr(snap, "attachments", None): + if hasattr(snap, "attachments") and snap.attachments: for a in snap.attachments: url = getattr(a, "url", None) filename = getattr(a, "filename", "Unknown") @@ -686,15 +684,12 @@ def extract_forwarded_content(message) -> typing.Optional[str]: Optional[str] The extracted forwarded content, or None if not a forwarded message. """ - import discord - try: # Handle multi-forward (message_snapshots) # Check directly for snapshots as flags.has_snapshot can be unreliable in some versions - if getattr(message, "message_snapshots", None): + if hasattr(message, "message_snapshots") and message.message_snapshots: forwarded_parts = [] for snap in message.message_snapshots: - author = getattr(snap, "author", None) # If author is missing, we can try to rely on the container message context or just omit. # Since we can't reliably get the original author from snapshot in this state, we focus on content. @@ -707,7 +702,7 @@ def extract_forwarded_content(message) -> typing.Optional[str]: snap_content = snap_content[:497] + "..." formatted_part += "\n".join([f"{line}" for line in snap_content.splitlines()]) + "\n" - if getattr(snap, "embeds", None): + if hasattr(snap, "embeds") and snap.embeds: for embed in snap.embeds: if hasattr(embed, "description") and embed.description: embed_desc = embed.description @@ -718,26 +713,25 @@ def extract_forwarded_content(message) -> typing.Optional[str]: ) break # One embed preview is usually enough - if getattr(snap, "attachments", None): - attachment_links = [] + if hasattr(snap, "attachments") and snap.attachments: + attachment_urls = [] for a in snap.attachments[:3]: - filename = getattr(a, "filename", "Unknown") url = getattr(a, "url", None) if url: - url = url.split("?")[0] - attachment_links.append(f"[{filename}]({url})") - else: - attachment_links.append(filename) + # Use direct URL for proper embedding in logviewer + attachment_urls.append(url.split("?")[0]) if len(snap.attachments) > 3: - attachment_links.append(f"(+{len(snap.attachments) - 3} more)") - formatted_part += f"πŸ“Ž {', '.join(attachment_links)}\n" + formatted_part += f"πŸ“Ž (+{len(snap.attachments) - 3} more attachments)\n" + # Add URLs on separate lines for proper embedding + for url in attachment_urls: + formatted_part += f"{url}\n" forwarded_parts.append(formatted_part) if forwarded_parts: return "\n".join(forwarded_parts) # Handle single-message forward - elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + elif hasattr(message, "type") and message.type == getattr(discord.MessageType, "forward", None): ref = getattr(message, "reference", None) if ( ref @@ -755,26 +749,27 @@ def extract_forwarded_content(message) -> typing.Optional[str]: if len(ref_content) > 500: ref_content = ref_content[:497] + "..." return f"**{ref_author_name}:** {ref_content}" - elif getattr(ref_msg, "embeds", None): + elif hasattr(ref_msg, "embeds") and ref_msg.embeds: for embed in ref_msg.embeds: if hasattr(embed, "description") and embed.description: embed_desc = embed.description if len(embed_desc) > 300: embed_desc = embed_desc[:297] + "..." return f"**{ref_author_name}:** {embed_desc}" - elif getattr(ref_msg, "attachments", None): - attachment_links = [] + elif hasattr(ref_msg, "attachments") and ref_msg.attachments: + attachment_urls = [] for a in ref_msg.attachments[:3]: - filename = getattr(a, "filename", "Unknown") url = getattr(a, "url", None) if url: - url = url.split("?")[0] - attachment_links.append(f"[{filename}]({url})") - else: - attachment_links.append(filename) + # Use direct URL for proper embedding in logviewer + attachment_urls.append(url.split("?")[0]) + result = f"**{ref_author_name}:** πŸ“Ž\n" if len(ref_msg.attachments) > 3: - attachment_links.append(f"(+{len(ref_msg.attachments) - 3} more)") - return f"**{ref_author_name}:** πŸ“Ž {', '.join(attachment_links)}" + result += f"(+{len(ref_msg.attachments) - 3} more attachments)\n" + # Add URLs on separate lines for proper embedding + for url in attachment_urls: + result += f"{url}\n" + return result except Exception as e: # Log and continue; failing to extract a reference preview shouldn't break flow logger.debug("Failed to extract reference preview: %s", e) From d8e91c03051bb89700a0837be463d2a462d2d201 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 2 Feb 2026 18:05:45 +0100 Subject: [PATCH 29/40] rebase import. --- core/clients.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/clients.py b/core/clients.py index 3826f55001..63649a66e0 100644 --- a/core/clients.py +++ b/core/clients.py @@ -12,6 +12,7 @@ from pymongo.errors import ConfigurationError from core.models import InvalidConfigError, getLogger +from core.utils import extract_forwarded_content logger = getLogger(__name__) @@ -661,7 +662,6 @@ async def append_log( channel_id: str = "", type_: str = "thread_message", ) -> dict: - from core.utils import extract_forwarded_content channel_id = str(channel_id) or str(message.channel.id) message_id = str(message_id) or str(message.id) From c31c024bec30a8f1f02a7b125a9617b1ddb50115 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Mon, 2 Feb 2026 18:10:48 +0100 Subject: [PATCH 30/40] fix: linting --- core/clients.py | 1 - 1 file changed, 1 deletion(-) diff --git a/core/clients.py b/core/clients.py index 63649a66e0..164166168d 100644 --- a/core/clients.py +++ b/core/clients.py @@ -662,7 +662,6 @@ 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) From 70c8b5685a8b4bb0d60eb5a0ed96277f408bb442 Mon Sep 17 00:00:00 2001 From: "Martin B." <55140357+martinbndr@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:19:44 +0100 Subject: [PATCH 31/40] Fix threadmenu command execution This commit fixes the command execution for the threadmenu that caused commands to be sent to the recipient DM instead of the thread channel. --- core/thread.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/thread.py b/core/thread.py index 0a240ee669..e0aea1338a 100644 --- a/core/thread.py +++ b/core/thread.py @@ -2986,7 +2986,7 @@ 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)) + synthetic = DummyMessage(copy.copy(self.outer_thread._genesis_message)) try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user @@ -3338,7 +3338,7 @@ async def callback(self, interaction: discord.Interaction): ctxs = [] for al in normalize_alias(alias): view_ = StringView(self.outer_thread.bot.prefix + al) - synthetic = DummyMessage(copy.copy(message)) + synthetic = DummyMessage(copy.copy(self.outer_thread._genesis_message)) try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user From 5ec2f117eb76070f7306db6fcc51f2d0c88fc447 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 32/40] 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 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/registry.json b/plugins/registry.json index 506df880bd..ee6b17b792 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", @@ -125,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 01d6c6c37b91a59256424cdb9269886c63f6a8d1 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 33/40] Feat: Renaming of snippets and aliases (#3383) This adds two commands for renaming snippets and aliases for easier name editing. --- cogs/utility.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cogs/utility.py b/cogs/utility.py index bc863a6f45..a20abcb276 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1353,6 +1353,58 @@ async def alias_rename(self, ctx, name: str.lower, *, value): ) 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 bd9d80a61bac0dcbb9cbd1f5f6865456344f43a5 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 34/40] Improvements for alias creation/editing (#3422) Improves the make_alias function. --- cogs/utility.py | 52 ------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/cogs/utility.py b/cogs/utility.py index a20abcb276..bc863a6f45 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -1353,58 +1353,6 @@ async def alias_rename(self, ctx, name: str.lower, *, value): ) 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 88e717146978c208aa306edfdf6e552310947761 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 21 Dec 2025 23:38:51 +0100 Subject: [PATCH 35/40] 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 | 86 ++++++++++++++++++++++++++++++------------------- core/models.py | 4 +++ core/thread.py | 74 +++++++++++++++++++++++++----------------- 3 files changed, 101 insertions(+), 63 deletions(-) diff --git a/core/clients.py b/core/clients.py index 164166168d..ff0d3ff8cd 100644 --- a/core/clients.py +++ b/core/clients.py @@ -662,39 +662,59 @@ 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) - - content = message.content or "" - if forwarded := extract_forwarded_content(message): - if content: - content += "\n" + forwarded - else: - content = forwarded - - 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": 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: + content = message.content or "" + if forwarded := extract_forwarded_content(message): + if content: + content += "\n" + forwarded + else: + content = forwarded + + 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": 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 e0aea1338a..d71cd49d4a 100644 --- a/core/thread.py +++ b/core/thread.py @@ -146,6 +146,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() @@ -1798,6 +1799,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]" @@ -1852,7 +1860,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 @@ -2580,6 +2589,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: @@ -2588,8 +2601,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) @@ -2937,35 +2950,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: From 2c7019f5d2fb08033204476b12d906724eb3a287 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 24 Dec 2025 16:50:32 +0100 Subject: [PATCH 36/40] 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 d71cd49d4a..0b45145056 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1060,7 +1060,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 @@ -1104,7 +1107,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. @@ -1278,18 +1281,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): """ @@ -1838,11 +1855,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 4ab9055c3398cae1597763e7fb91e78d4c778fe9 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 11 Jun 2026 15:24:08 +0200 Subject: [PATCH 37/40] Resolve git requests. --- core/thread.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/core/thread.py b/core/thread.py index 0b45145056..a01bdbeff7 100644 --- a/core/thread.py +++ b/core/thread.py @@ -261,9 +261,7 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): {"channel_id": str(self.channel.id)}, {"$set": {"snoozed": True, "snooze_data": self.snooze_data}}, ) - import logging - - logging.info(f"[SNOOZE] DB update result: {result.modified_count}") + logger.info("[SNOOZE] DB update result: %s", result.modified_count) # Dispatch thread_snoozed event for plugins self.bot.dispatch("thread_snoozed", self, moderator, snooze_for) @@ -729,9 +727,7 @@ async def _ensure_genesis(force: bool = False): "$unset": {"snoozed": "", "snooze_data": ""}, }, ) - import logging - - logging.info(f"[UNSNOOZE] DB update result: {result.modified_count}") + logger.info("[UNSNOOZE] DB update result: %s", result.modified_count) # Notify in the configured channel notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." From d3163dd223bf2aad4d6a68fccc980e94d18fb459 Mon Sep 17 00:00:00 2001 From: "Martin B." <55140357+martinbndr@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:40:50 +0100 Subject: [PATCH 38/40] Plugin registry checks upon boot Plugins will be checked upon start if they are not in the registry and REGISTRY_PLUGINS_ONLY is set to Yes they will be removed. Also plugins are allowed to be removed regardless of being in the registry or not. --- cogs/plugins.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/cogs/plugins.py b/cogs/plugins.py index a5cece7ab6..22fc55eb7b 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -153,6 +153,14 @@ async def initial_load_plugins(self): logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) self.bot.config["plugins"].append(str(plugin)) + if self.bot.config.get("registry_plugins_only") and not plugin_name in self.registry: + self.bot.config["plugins"].remove(plugin_name) + logger.info( + "Loading of plugin %s was skipped and the plugin was removed because it is not present in the registry.", + plugin_name, + ) + continue + try: await self.download_plugin(plugin) await self.load_plugin(plugin) @@ -281,7 +289,7 @@ async def unload_plugin(self, plugin: Plugin) -> None: if module == ext_parent or module.startswith(ext_parent + "."): del sys.modules[module] - async def parse_user_input(self, ctx, plugin_name, check_version=False): + async def parse_user_input(self, ctx, plugin_name, check_version=False, check_registry=True): if not self.bot.config["enable_plugins"]: embed = discord.Embed( description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", @@ -318,14 +326,15 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): plugin = Plugin(user, repo, plugin_name, branch) else: - if self.bot.config.get("registry_plugins_only"): - embed = discord.Embed( - description="This plugin is not in the registry. To install this plugin, " - "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", - color=self.bot.error_color, - ) - await ctx.send(embed=embed) - return + if check_registry: + if self.bot.config.get("registry_plugins_only"): + embed = discord.Embed( + description="This plugin is not in the registry. To install this plugin, " + "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return try: plugin = Plugin.from_string(plugin_name) except InvalidPluginError: @@ -337,7 +346,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): ) await ctx.send(embed=embed) return - return plugin + return plugin @commands.group(aliases=["plugin"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) @@ -446,7 +455,7 @@ async def plugins_remove(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. """ - plugin = await self.parse_user_input(ctx, plugin_name) + plugin = await self.parse_user_input(ctx, plugin_name, check_registry=False) if plugin is None: return From 1da6d46f5b757f0f8f4e9d225e992097a9d5ca2b Mon Sep 17 00:00:00 2001 From: "Martin B." <55140357+martinbndr@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:19:06 +0100 Subject: [PATCH 39/40] Improve If-Statement readability Fixes the readability of the if statements (combined together with and condition) --- cogs/plugins.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cogs/plugins.py b/cogs/plugins.py index 22fc55eb7b..310b2f06c9 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -326,15 +326,14 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False, check_re plugin = Plugin(user, repo, plugin_name, branch) else: - if check_registry: - if self.bot.config.get("registry_plugins_only"): - embed = discord.Embed( - description="This plugin is not in the registry. To install this plugin, " - "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", - color=self.bot.error_color, - ) - await ctx.send(embed=embed) - return + if check_registry and self.bot.config.get("registry_plugins_only"): + embed = discord.Embed( + description="This plugin is not in the registry. To install this plugin, " + "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", + color=self.bot.error_color, + ) + await ctx.send(embed=embed) + return try: plugin = Plugin.from_string(plugin_name) except InvalidPluginError: From 5d4175ec5017f90ef9f6b521ee2db04b80fabd50 Mon Sep 17 00:00:00 2001 From: Prabhat Date: Thu, 18 Jun 2026 21:51:35 +0530 Subject: [PATCH 40/40] fix: handle Forbidden when sending confirm-thread-creation DM (#3442) --- CHANGELOG.md | 5 +++++ core/thread.py | 29 +++++++++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7a7e28ea..f1c8d12311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. +# Unreleased + +### Fixed +* Confirm thread creation (react to contact) no longer leaves a thread stuck in a "not ready" cache state when the recipient has DMs disabled. The bot now catches `discord.Forbidden` when sending the confirmation prompt, cancels the thread, and clears the cache entry immediately instead of requiring a bot restart. (#3442) + # v4.2.1 ### Added diff --git a/core/thread.py b/core/thread.py index a01bdbeff7..8bc83f7324 100644 --- a/core/thread.py +++ b/core/thread.py @@ -2664,14 +2664,27 @@ async def create( self.bot.config["confirm_thread_creation_deny"], ) ) - confirm = await destination.send( - embed=discord.Embed( - title=self.bot.config["confirm_thread_creation_title"], - description=self.bot.config["confirm_thread_response"], - color=self.bot.main_color, - ), - view=view, - ) + try: + confirm = await destination.send( + embed=discord.Embed( + title=self.bot.config["confirm_thread_creation_title"], + description=self.bot.config["confirm_thread_response"], + color=self.bot.main_color, + ), + view=view, + ) + except discord.Forbidden: + # Recipient has DMs disabled (or otherwise unreachable). Without this, + # the thread would stay stuck in cache in a "not ready" state until a + # bot restart, since view.wait() below would never run. + logger.warning( + "Could not send confirm-thread-creation message to %s, DMs are likely disabled.", + recipient, + ) + thread.cancelled = True + del self.cache[recipient.id] + return thread + await view.wait() if view.value is None: thread.cancelled = True