diff --git a/README.md b/README.md index b0d62e5c..49b41fc2 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,8 @@ related feature: Turn into #help post example +The author of a message and people with the “Manage Messages” permission can +also delete moved messages using an entry in the context menu. [bot-repo]: https://github.com/ghostty-org/discord-bot [discord-docs]: https://discord.com/developers/applications diff --git a/app/components/docs.py b/app/components/docs.py index 2426dcc6..0fe995c0 100644 --- a/app/components/docs.py +++ b/app/components/docs.py @@ -129,7 +129,7 @@ async def docs( ): await interaction.response.send_message(get_docs_link(section, page)) return - webhook = await get_or_create_webhook("Ghostty Moderator", interaction.channel) + webhook = await get_or_create_webhook(interaction.channel) await webhook.send( f"{message}\n{get_docs_link(section, page)}", username=interaction.user.display_name, diff --git a/app/components/move_message.py b/app/components/move_message.py index 524b8b08..730fb8c4 100644 --- a/app/components/move_message.py +++ b/app/components/move_message.py @@ -5,6 +5,8 @@ from app.setup import bot, config from app.utils import ( GuildTextChannel, + get_moved_message, + get_moved_message_author_id, get_or_create_webhook, is_dm, is_helper, @@ -51,7 +53,7 @@ async def select_channel( ) assert isinstance(webhook_channel, discord.TextChannel | discord.ForumChannel) - webhook = await get_or_create_webhook("Ghostty Moderator", webhook_channel) + webhook = await get_or_create_webhook(webhook_channel) await move_message_via_webhook( webhook, self.message, self.executor, thread=thread ) @@ -102,7 +104,7 @@ async def on_submit(self, interaction: discord.Interaction) -> None: ) await interaction.response.defer(ephemeral=True) - webhook = await get_or_create_webhook("Ghostty Moderator", help_channel) + webhook = await get_or_create_webhook(help_channel) msg = await move_message_via_webhook( webhook, self._message, @@ -199,3 +201,40 @@ async def turn_into_help_post( return await interaction.response.send_modal(HelpPostTitle(message)) + + +@bot.tree.context_menu(name="Delete moved message") +@discord.app_commands.guild_only() +async def delete_moved_message( + interaction: discord.Interaction, message: discord.Message +) -> None: + assert not is_dm(interaction.user) + + if (webhook_message := await get_moved_message(message)) is None: + await interaction.response.send_message( + "This message cannot be deleted.", ephemeral=True + ) + return + + if ( + webhook_message is discord.utils.MISSING + or (author_id := get_moved_message_author_id(webhook_message)) is None + ): + await interaction.response.send_message( + "This message is not a moved message.", ephemeral=True + ) + return + + if not ( + interaction.user.id == author_id + or message.channel.permissions_for(interaction.user).manage_messages + ): + await interaction.response.send_message( + "You are either not the author, or do not have the required " + "permissions to delete messages.", + ephemeral=True, + ) + return + + await message.delete() + await interaction.response.send_message("Message deleted.", ephemeral=True) diff --git a/app/components/zig_codeblocks.py b/app/components/zig_codeblocks.py index b10d6a79..65a9a406 100644 --- a/app/components/zig_codeblocks.py +++ b/app/components/zig_codeblocks.py @@ -132,9 +132,11 @@ async def replace( ) assert isinstance(webhook_channel, discord.TextChannel | discord.ForumChannel) - webhook = await get_or_create_webhook("Ghostty Moderator", webhook_channel) + webhook = await get_or_create_webhook(webhook_channel) self._message.content = self._replaced_message_content - await move_message_via_webhook(webhook, self._message, thread=thread) + await move_message_via_webhook( + webhook, self._message, thread=thread, include_move_marks=False + ) async def _prepare_reply( diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 7a5590d0..719af002 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -21,6 +21,8 @@ dynamic_timestamp, format_or_file, get_ghostty_guild, + get_moved_message, + get_moved_message_author_id, get_or_create_webhook, message_can_be_moved, move_message_via_webhook, @@ -43,6 +45,8 @@ "escape_special", "format_or_file", "get_ghostty_guild", + "get_moved_message", + "get_moved_message_author_id", "get_or_create_webhook", "is_dm", "is_helper", diff --git a/app/utils/webhooks.py b/app/utils/webhooks.py index c8755023..2bc5b771 100644 --- a/app/utils/webhooks.py +++ b/app/utils/webhooks.py @@ -20,6 +20,8 @@ _EMOJI_REGEX = re.compile(r"<(a?):(\w+):(\d+)>", re.ASCII) +_SNOWFLAKE_REGEX = re.compile(r"<(\D{0,2})(\d+)>", re.ASCII) + # A list of image formats supported by Discord, in the form of their file # extension (including the leading dot). SUPPORTED_IMAGE_FORMATS = frozenset({".avif", ".gif", ".jpeg", ".jpg", ".png", ".webp"}) @@ -304,8 +306,16 @@ def dynamic_timestamp(dt: dt.datetime, fmt: str | None = None) -> str: class _SubText: + # WARNING: get_moved_message_author_id() makes a lot of assumptions about + # the structure of the subtext; be careful when editing this as + # invalidating the previous expected structure can break editing and + # deletion of messages moved before the change was merged or ALLOW THE + # WRONG PERSON TO EDIT OR DELETE A MOVED MESSAGE, *even if* that function + # is updated to match the new structure, as this subtext change won't + # retroactively apply to previously moved messages on Discord's servers. reactions: str timestamp: str + author: str move_hint: str skipped: str poll_error: str @@ -319,6 +329,7 @@ def __init__( self.msg_data = msg_data self._format_reactions() self._format_timestamp() + self.author = f"Authored by {msg_data.author.mention}" assert isinstance(self.msg_data.channel, GuildTextChannel) self.move_hint = ( f"Moved from {self.msg_data.channel.mention} by {executor.mention}" @@ -361,8 +372,13 @@ def format_skipped(skipped: int) -> str: return f"Skipped {skipped} large attachment{'s' * (skipped != 1)}" def format(self) -> str: - context = ( + original_message_info = ( + self.author, + " on " if self.author and self.timestamp else "", self.timestamp, + ) + context = ( + "".join(original_message_info), self.move_hint, self.skipped, self.poll_error, @@ -378,7 +394,7 @@ def _sub_join(*strs: str) -> str: async def get_or_create_webhook( - name: str, channel: discord.TextChannel | discord.ForumChannel + channel: discord.TextChannel | discord.ForumChannel, name: str = "Ghostty Moderator" ) -> discord.Webhook: webhooks = await channel.webhooks() for webhook in webhooks: @@ -395,13 +411,14 @@ def message_can_be_moved(message: discord.Message) -> bool: return message.type in NON_SYSTEM_MESSAGE_TYPES -async def move_message_via_webhook( +async def move_message_via_webhook( # noqa: PLR0913 webhook: discord.Webhook, message: discord.Message, executor: discord.Member | None = None, *, thread: discord.abc.Snowflake = discord.utils.MISSING, thread_name: str = discord.utils.MISSING, + include_move_marks: bool = True, ) -> discord.WebhookMessage: """ WARNING: it is the caller's responsibility to check message_can_be_moved() @@ -445,11 +462,12 @@ async def move_message_via_webhook( poll = message.poll # The if expression is to skip the poll ended message if there was no poll. - subtext = _SubText( - msg_data, executor, poll if message.poll is not None else None - ).format() + s = _SubText(msg_data, executor, poll if message.poll is not None else None) + subtext = s.format() if include_move_marks else s.format_simple() content, file = format_or_file( _format_interaction(message), + # WARNING: the subtext must always be on the very last line for + # get_moved_message_author_id() to function. template=f"{{}}\n{subtext}", transform=_convert_nitro_emojis, ) @@ -491,3 +509,86 @@ def format_or_file( BytesIO(message.encode()), filename="content.md" ) return full_message, None + + +async def get_moved_message( + message: discord.Message, *, webhook_name: str = "Ghostty Moderator" +) -> discord.WebhookMessage | None: + """ + Returns None if it could not be acquired, and discord.utils.MISSING if the + provided message is not a moved message. + """ + if message.webhook_id is None or isinstance( + message.channel, + # These types can't even have a webhook. + discord.DMChannel | discord.GroupChannel | discord.PartialMessageable, + ): + return discord.utils.MISSING + + if isinstance(message.channel, discord.Thread): + thread = message.channel + if (channel := thread.parent) is None: + return None + else: + channel = message.channel + thread = discord.utils.MISSING + + for webhook in await channel.webhooks(): + if webhook.id == message.webhook_id: + break + else: + return discord.utils.MISSING + if webhook.name != webhook_name: + # More heuristics to determine if a webhook message is a moved message. + return discord.utils.MISSING + + try: + return await webhook.fetch_message(message.id, thread=thread) + except discord.Forbidden: + return None + except discord.NotFound: + return discord.utils.MISSING + + +def _find_snowflake(content: str, type_: str) -> tuple[int, int] | tuple[None, None]: + """ + WARNING: this function does not account for Markdown features such as code + blocks that may disarm a snowflake. + """ + # NOTE: while this function could just return tuple[int, int] | None, that + # makes it less convenient to destructure the return value. + snowflake = _SNOWFLAKE_REGEX.search(content) + if snowflake is None or snowflake[1] != type_: + return None, None + return int(snowflake[2]), snowflake.span()[0] + + +def get_moved_message_author_id(message: discord.WebhookMessage) -> int | None: + # NOTE: this function takes a discord.WebhookMessage instead of + # a discord.Message to force the caller to ensure that the requested + # message is actually from a webhook. + + # HACK: as far as I know, Discord does not provide any way to attach + # a hidden number to a webhook message, nor does it provide a way to link + # a webhook message to a user. Thus, this information is extracted from the + # subtext of moved messages. + try: + subtext = message.content.splitlines()[-1] + except IndexError: + return None + # Heuristics to determine if a message is really a moved message. + if not subtext.startswith("-# "): + return None + # One other thing that could be checked is whether content.splitlines() is + # at least two elements long; that would backfire when moved media or + # forwards is passed through this function, however, as those move messages + # don't contain anything except the subtext in their `Message.content`. + + # If we have a channel mention, the executor is present; discard that part + # so that the executor is not accidentally picked as the author. + _, pos = _find_snowflake(subtext, "#") + if pos is not None: + subtext = subtext[:pos] + + snowflake, _ = _find_snowflake(subtext, "@") + return snowflake diff --git a/tests/test_message_moving.py b/tests/test_message_moving.py new file mode 100644 index 00000000..b47b2640 --- /dev/null +++ b/tests/test_message_moving.py @@ -0,0 +1,123 @@ +from types import SimpleNamespace +from typing import TYPE_CHECKING, cast + +import pytest + +from app.utils.webhooks import ( + _find_snowflake, # pyright: ignore [reportPrivateUsage] + get_moved_message_author_id, +) + +if TYPE_CHECKING: + import discord + + +@pytest.mark.parametrize( + ("content", "type_", "result"), + [ + ("<@1234123>", "@", (1234123, 0)), + ("foo <@1234123>", "@", (1234123, 4)), + ("foo <#1234123>", "@", (None, None)), + ("foo <#1234123>", "#", (1234123, 4)), + ("foo <*1234123>", "*", (1234123, 4)), + ("lorem ipsum <*1234123>", "*", (1234123, 12)), + ("lorem ipsum <*1234123 <#128381723>", "#", (128381723, 22)), + ("lorem ipsum <#1234123 <#128381723>", "#", (128381723, 22)), + ("join vc @ <#!12749128401294>!!", "#", (None, None)), + ("join vc @ <#!12749128401294>", "#!", (12749128401294, 10)), + ("join vc @ <#!12749128401294>", "", (None, None)), + ("join vc @ <12749128401294> :D", "", (12749128401294, 10)), + ("join vc @ <#!12749128401294>", "@", (None, None)), + ( + f"the quick brown fox <@{'7294857392283743' * 16}> jumps over the lazy dog", + "@", + (int("7294857392283743" * 16), 20), + ), + ("<@<@1234869>", "@", (1234869, 2)), + ("<@>", "@", (None, None)), + ("<>", "", (None, None)), + ("", "", (None, None)), + ("hi", "", (None, None)), + ("", "@", (None, None)), + # *Technically* not a false positive, but Discord won't treat it as + # special, so it's a false positive in the context that this function + # is used in. This would have to be handled by the caller, and won't be + # as this is deemed "too difficult" for a corner case that wouldn't + # even materialize in practice because the subtext will never contain + # code blocks with snowflakes contained within. + ("`<@192849172497>`", "@", (192849172497, 1)), + ("```<@192849172497>```", "@", (192849172497, 3)), + ], +) +def test_find_snowflake( + content: str, type_: str, result: tuple[int | None, int | None] +) -> None: + assert _find_snowflake(content, type_) == result + + +@pytest.mark.parametrize( + ("content", "result"), + [ + ( + "a\n-# Authored by <@665120188047556609> • " + "Moved from <#1281624935558807678> by <@665120188047556609>", + 665120188047556609, + ), + ( + "Scanned 1 open posts in <#1305317376346296321>.\n" + "-# Authored by <@1323096214735945738> on • " + "Moved from <#1324364626225266758> by <@665120188047556609>", + 1323096214735945738, + ), + ( + "edit\n-# Authored by <@665120188047556609> on " + "(edited at ) • Moved from <#1281624935558807678> " + "by <@665120188047556609>", + 665120188047556609, + ), + ("a\n -# Moved from <#1281624935558807678> by <@665120188047556609>", None), + ( + "Scanned 0 open posts in <#1305317376346296321>.\n-# • " + "Moved from <#1324364626225266758> by <@665120188047556609>", + None, + ), + ( + "-# (content attached)\n-# Authored by <@665120188047556609> • " + "Moved from <#1281624935558807678> by <@665120188047556609>", + 665120188047556609, + ), + ( + "-# (content attached)\n-# Moved from " + "<#1281624935558807678> by <@665120188047556609>", + None, + ), + ("test", None), + ("", None), + ("-# Moved from <#1281624935558807678> by <@665120188047556609>", None), + ("-# Authored by <@665120188047556609>", 665120188047556609), + ("Authored by <@665120188047556609>", None), + ("<@665120188047556609>", None), + ("-#<@665120188047556609>", None), + ("<@665120188047556609 go to <#1294988140645453834>", None), + ( + "-# <@252206453878685697> what are you doing in <#1337443701403815999> 👀\n" + "-# it's not ||[redacted]|| is it...?", + None, + ), + # False positives that are not going to be handled. + ( + "-# <@252206453878685697> what are you doing in <#1337443701403815999> 👀", + 252206453878685697, + ), + ("-# <@665120188047556609> look at this!", 665120188047556609), + ("-# <@665120188047556609>", 665120188047556609), + ("-# Oops <@665120188047556609>", 665120188047556609), + ("-# Moved by <@665120188047556609>", 665120188047556609), + # See the comment in test_find_snowflake(). + ("-# Moved by `<@665120188047556609>`", 665120188047556609), + ("-# Authored by ```<@665120188047556609>```", 665120188047556609), + ], +) +def test_get_moved_message_author_id(content: str, result: int | None) -> None: + fake_message = cast("discord.WebhookMessage", SimpleNamespace(content=content)) + assert get_moved_message_author_id(fake_message) == result