Skip to content

Allow deleting moved messages #219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ related feature:

<img src="https://github.com/user-attachments/assets/9943a31c-3b0e-4606-99a0-5182ce114b87" alt="Turn into #help post example" width="70%">

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
Expand Down
2 changes: 1 addition & 1 deletion app/components/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 41 additions & 2 deletions app/components/move_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
6 changes: 4 additions & 2 deletions app/components/zig_codeblocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions app/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
113 changes: 107 additions & 6 deletions app/utils/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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
Expand All @@ -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}"
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Loading