From bf6cb87579d791d5946cee04cbb093ffa9882914 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 1/6] 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 4c73381ea97951629df3c1a12fc80fcc84ff0039 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 2/6] 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 d3bb27ba334d62a85218b21803835f32ffa3b141 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 3/6] 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 14e8fde54cfdaa0f4d950882138ed652ae54d2f7 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sun, 21 Dec 2025 23:38:51 +0100 Subject: [PATCH 4/6] 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 6814b5d7effcc016ea60637c2dbd2a46f1619917 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Wed, 24 Dec 2025 16:50:32 +0100 Subject: [PATCH 5/6] 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 2b9856dd87ccb74ea98447ae5eef4b8c12d01999 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Thu, 11 Jun 2026 15:24:08 +0200 Subject: [PATCH 6/6] 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."