diff --git a/README.md b/README.md
index b0d62e5c..49b41fc2 100644
--- a/README.md
+++ b/README.md
@@ -324,6 +324,8 @@ related feature:
+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