From f33b06c39d70a3eaefbc31cc994dfb284e3e46af Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 14:59:50 +0100 Subject: [PATCH 1/5] support to edit and delete plain reply messages --- cogs/modmail.py | 6 +-- core/thread.py | 109 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 26 deletions(-) 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 09263b197d..7d0960c223 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,16 +224,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) @@ -1345,11 +1341,17 @@ async def find_linked_messages( 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.") + 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: + logger.debug( + f"Malformed thread message for deletion: embeds={bool(message1.embeds)}, author_url={getattr(message1.embeds[0], 'author', None) and message1.embeds[0].author.url}, author={message1.author}" + ) + # Keep original error string to avoid extra failure embeds in on_message_delete + raise ValueError("Malformed thread message.") elif message_id is not None: try: @@ -1374,8 +1376,12 @@ async def find_linked_messages( return message1, None # else: fall through to relay checks below - # Non-note path (regular relayed messages): require author.url and colors - if not ( + 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 and not ( message1.embeds and message1.embeds[0].author.url and message1.embeds[0].color @@ -1395,8 +1401,10 @@ async def find_linked_messages( # Internal bot-only message treated similarly; keep None sentinel return message1, None - if message1.embeds[0].color.value != self.bot.mod_color and not ( - either_direction and message1.embeds[0].color.value == self.bot.recipient_color + if ( + not is_plain + and 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.") @@ -1404,19 +1412,62 @@ async def find_linked_messages( 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) + ( + 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() + ) + or ( + message1.embeds[0].footer + and message1.embeds[0].footer.text + and message1.embeds[0].footer.text.startswith("[PLAIN]") + ) ) - and message1.embeds[0].author.url.split("#")[-1].isdigit() and message1.author == self.bot.user ): break else: raise ValueError("Thread message not found.") + 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 is_plain: + messages = [message1] + creation_time = message1.created_at + + target_content = message1.embeds[0].description + + 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 target_content and target_content in msg.content: + messages.append(msg) + break + + if len(messages) > 1: + return messages + + raise ValueError("Linked Plain DM message not found.") + try: joint_id = int(message1.embeds[0].author.url.split("#")[-1]) except ValueError: @@ -1453,6 +1504,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), @@ -1462,9 +1517,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: + if ":** " in m2.content: + prefix = m2.content.split(":** ", 1)[0] + ":** " + new_content = f"{prefix}{message}" + tasks += [m2.edit(content=new_content)] + else: + tasks += [m2.edit(content=message)] + else: + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks) From 20af22526e32811931e19f62ee221cb1a7fa687d Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 15:03:16 +0100 Subject: [PATCH 2/5] fix linting --- core/thread.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/thread.py b/core/thread.py index 7d0960c223..913cd2a38e 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,12 +224,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 e620a03365b3ed9967b7f49f419fb02be49e792a Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 23:05:30 +0100 Subject: [PATCH 3/5] fix: not rely on mod_color as originally was made. This will avoid crashes when the mod_color get changed. --- core/thread.py | 149 +++++++++++++++++-------------------------------- 1 file changed, 50 insertions(+), 99 deletions(-) diff --git a/core/thread.py b/core/thread.py index 913cd2a38e..bb184bf8b4 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1385,6 +1385,33 @@ async def find_linked_messages( if message1.embeds[0].footer.text.startswith("[PLAIN]"): is_plain = True + if is_plain: + creation_time = message1.created_at + + 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}" + + messages = [message1] + 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 + + if len(messages) > 1: + return messages + raise ValueError("Linked Plain DM message not found.") + if not is_plain and not ( message1.embeds and message1.embeds[0].author.url @@ -1397,106 +1424,38 @@ async def find_linked_messages( raise ValueError("Thread message not found.") 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.") + 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 ( - not is_plain - and 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() - ) - or ( - message1.embeds[0].footer - and message1.embeds[0].footer.text - and message1.embeds[0].footer.text.startswith("[PLAIN]") - ) - ) - and message1.author == self.bot.user - ): - break - else: - raise ValueError("Thread message not found.") - - 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 is_plain: + try: + joint_id = int(message1.embeds[0].author.url.split("#")[-1]) + except ValueError: + raise ValueError("Malformed thread message.") + messages = [message1] - creation_time = message1.created_at - - target_content = message1.embeds[0].description - 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 + async for msg in user.history(): + if either_direction: + if msg.id == joint_id: + return message1, msg - if msg.author != self.bot.user: + if not (msg.embeds and msg.embeds[0].author.url): continue - - if msg.embeds: + try: + if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + messages.append(msg) + break + except ValueError: continue - if target_content and target_content in msg.content: - messages.append(msg) - break - if len(messages) > 1: return messages - raise ValueError("Linked Plain DM message not found.") - - try: - joint_id = int(message1.embeds[0].author.url.split("#")[-1]) - except ValueError: - raise ValueError("Malformed thread message.") - - 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: - messages.append(msg) - break - except ValueError: - continue - - if len(messages) > 1: - return messages - - raise ValueError("DM message not found.") + raise ValueError("DM message not found.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: @@ -1521,17 +1480,9 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> else: for m2 in message2: if m2 is not None: - if is_plain: - if ":** " in m2.content: - prefix = m2.content.split(":** ", 1)[0] + ":** " - new_content = f"{prefix}{message}" - tasks += [m2.edit(content=new_content)] - else: - tasks += [m2.edit(content=message)] - else: - embed2 = m2.embeds[0] - embed2.description = message - tasks += [m2.edit(embed=embed2)] + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks) From 4e38eaaabfa1aa6bfe0203e282af9fbcee467c62 Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 23:22:58 +0100 Subject: [PATCH 4/5] fix: typeerror / refactor --- bot.py | 1 + core/thread.py | 211 ++++++++++++++++++++++++------------------------- 2 files changed, 104 insertions(+), 108 deletions(-) diff --git a/bot.py b/bot.py index 9f3de008a1..d66e4648aa 100644 --- a/bot.py +++ b/bot.py @@ -1913,6 +1913,7 @@ async def on_message_delete(self, message): "DM message not found.", "Malformed thread message.", "Thread message not found.", + "Linked DM message not found.", }: logger.debug("Failed to find linked message to delete: %s", e) embed = discord.Embed(description="Failed to delete message.", color=self.error_color) diff --git a/core/thread.py b/core/thread.py index bb184bf8b4..c23df553db 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,16 +224,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) @@ -1331,117 +1327,108 @@ 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 - ): - 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 + # 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 - if not is_plain: - 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.") + 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 - elif message_id is not None: - try: - message1 = await self.channel.fetch_message(message_id) - except discord.NotFound: - logger.warning(f"Message ID {message_id} not found in channel history.") - raise ValueError("Thread message not found.") + if 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 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 - - 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 is_plain: - creation_time = message1.created_at - - 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}" - - messages = [message1] - 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 note and is_note: + return message1, None - if msg.content == plain_content_expected: - messages.append(msg) - break - - if len(messages) > 1: - return messages - raise ValueError("Linked Plain DM message not found.") - - if not is_plain and 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 not note and is_note: + logger.warning("Message is an internal message, but note deletion/edit not requested.") + raise ValueError("Thread message is an internal message, not a note.") - if message1.embeds[0].footer and "Internal Message" in message1.embeds[0].footer.text: + 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): logger.warning( - f"Message {message_id} is an internal message, but note deletion not requested." + f"Message {message1.id} is not a valid modmail relay message. embeds={bool(message1.embeds)}, author={message1.author}" ) - raise ValueError("Thread message is an internal message, not a note.") - # Internal bot-only message treated similarly; keep None sentinel - return message1, None + raise ValueError("Thread message not found.") try: joint_id = int(message1.embeds[0].author.url.split("#")[-1]) - except ValueError: + except (ValueError, AttributeError, IndexError): raise ValueError("Malformed thread message.") - - messages = [message1] + 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] + + 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 + else: for user in self.recipients: async for msg in user.history(): if either_direction: if msg.id == joint_id: - return message1, msg + messages.append(msg) + break if not (msg.embeds and msg.embeds[0].author.url): continue @@ -1449,13 +1436,13 @@ async def find_linked_messages( if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: messages.append(msg) break - except ValueError: + except (ValueError, IndexError, AttributeError): continue - if len(messages) > 1: - return messages + 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: @@ -1480,9 +1467,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 420ece08fbeebfde824f0026a2b98a86842052ff Mon Sep 17 00:00:00 2001 From: lorenzo132 Date: Sat, 13 Dec 2025 23:24:59 +0100 Subject: [PATCH 5/5] fix linting --- core/thread.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/thread.py b/core/thread.py index c23df553db..9aaae3b7c1 100644 --- a/core/thread.py +++ b/core/thread.py @@ -224,12 +224,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)