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/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..9aaae3b7c1 100644 --- a/core/thread.py +++ b/core/thread.py @@ -1331,117 +1331,122 @@ 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 - ): + 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 and is_note: + return message1, None + + 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 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 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}" + f"Message {message1.id} is not a valid modmail relay message. embeds={bool(message1.embeds)}, author={message1.author}" ) 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.") - # 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 - ): - logger.warning("Message color does not match mod/recipient colors.") - raise ValueError("Thread message not found.") + try: + joint_id = int(message1.embeds[0].author.url.split("#")[-1]) + except (ValueError, AttributeError, IndexError): + raise ValueError("Malformed thread message.") 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: - raise ValueError("Thread message not found.") - - try: - joint_id = int(message1.embeds[0].author.url.split("#")[-1]) - except ValueError: - raise ValueError("Malformed thread message.") + 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: @@ -1453,6 +1458,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 +1471,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)