From fea3c679f89eb78b8f8c600b260a01d4f98bb798 Mon Sep 17 00:00:00 2001 From: rosethornbush <31735267+rosethornbush@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:44:05 -0700 Subject: [PATCH] fix(bot): update embeds after flag edits --- apps/bot/src/commands/embed.ts | 4 +- apps/bot/src/lib/handleUrls.ts | 106 ++++++++++++++++++++---- apps/bot/src/lib/messageCache.ts | 28 ++++++- apps/bot/src/lib/utils.ts | 5 +- apps/bot/src/listeners/messageCreate.ts | 44 +--------- apps/bot/src/listeners/messageUpdate.ts | 47 +++++++++-- 6 files changed, 160 insertions(+), 74 deletions(-) diff --git a/apps/bot/src/commands/embed.ts b/apps/bot/src/commands/embed.ts index f123e61..3203d85 100644 --- a/apps/bot/src/commands/embed.ts +++ b/apps/bot/src/commands/embed.ts @@ -71,7 +71,7 @@ export class EmbedCommand extends Command { await handleUrls( extractURLs(msg.content).map(({ url }) => ({ url })), interaction, - "context_menu", + { source: "context_menu" }, ); } @@ -88,7 +88,7 @@ export class EmbedCommand extends Command { force: interaction.options.getBoolean("force") ?? false, })), interaction, - "command", + { source: "command" }, ); } } diff --git a/apps/bot/src/lib/handleUrls.ts b/apps/bot/src/lib/handleUrls.ts index 6a6cfbc..c6bd961 100644 --- a/apps/bot/src/lib/handleUrls.ts +++ b/apps/bot/src/lib/handleUrls.ts @@ -24,6 +24,7 @@ import { recordError, span, } from "./observability"; +import { extractURLs, isSpoiler } from "./utils"; type EmbedSource = "message" | "command" | "context_menu"; type EmbedInteraction = Command.ChatInputCommandInteraction | Command.ContextMenuCommandInteraction; @@ -35,6 +36,51 @@ export interface EmbedURLRequest { force?: boolean; } +interface HandleUrlsOptions { + source?: EmbedSource; + updateTargets?: ReadonlyMap; +} + +export function parseMessageURLs(content: string) { + const urls: EmbedURLRequest[] = []; + let suppressNativeEmbeds = true; + + for (const match of extractURLs(content)) { + const before = content.slice(0, match.index); + const after = content.slice(match.endIndex); + + if (before.endsWith("<") && after.startsWith(">")) continue; + + if (before.endsWith("~")) { + suppressNativeEmbeds = false; + continue; + } + + const flags: Partial = { + Spoiler: isSpoiler(match.url, content), + }; + let force = false; + + if (before.endsWith("?@")) { + flags.SourceOnly = true; + force = true; + } else if (before.endsWith("?!")) { + flags.MediaOnly = true; + force = true; + } else if (before.endsWith("@")) { + flags.SourceOnly = true; + } else if (before.endsWith("!")) { + flags.MediaOnly = true; + } else if (before.endsWith("?")) { + force = true; + } + + urls.push({ url: match.url, flags, force }); + } + + return { urls, suppressNativeEmbeds }; +} + function canReactToMessage(msg: Message) { if (!msg.channel) return false; if (!msg.inGuild()) return true; @@ -48,7 +94,7 @@ function canReactToMessage(msg: Message) { export async function handleUrls( urls: EmbedURLRequest[], interactionOrMessage: EmbedInteraction | Message, - source?: EmbedSource, + options: HandleUrlsOptions = {}, ) { let msg: Message | null = null; let interaction: EmbedInteraction | null = null; @@ -59,7 +105,7 @@ export async function handleUrls( interaction = interactionOrMessage; } - const embedSource = source ?? (msg ? "message" : "command"); + const embedSource = options.source ?? (msg ? "message" : "command"); let sentEmbed = false; let sentFailureReaction = false; const reactToFailure = async () => { @@ -107,12 +153,19 @@ export async function handleUrls( const matches = ( await Promise.all( - urls.map(async (request) => { + urls.map(async (request, requestIndex) => { const match = await matchURL(request.url); - return match ? { ...request, ...match } : null; + return match ? { ...request, ...match, requestIndex } : null; }), ) - ).filter((m) => m !== null); + ).filter( + (match) => + match !== null && (!options.updateTargets || options.updateTargets.has(match.requestIndex)), + ); + + if (msg && options.updateTargets && matches.length !== options.updateTargets.size) { + await reactToFailure(); + } if (interaction && matches.length === 0) { const requestId = `${embedSource}:${interaction.id}`; @@ -132,15 +185,19 @@ export async function handleUrls( return sentEmbed; } - for (const [i, { platform, id, flags, force }] of matches.entries()) { + for (const [i, { platform, id, flags, force, requestIndex }] of matches.entries()) { const startedAt = Date.now(); - const requestId = msg ? `message:${msg.id}:${i}` : `${embedSource}:${interaction!.id}:${i}`; + const requestId = msg + ? `message:${msg.id}:${requestIndex}` + : `${embedSource}:${interaction!.id}:${requestIndex}`; + const targetBotMessageId = options.updateTargets?.get(requestIndex); const logContext: Record = { request_id: requestId, source: embedSource, platform, post_id: id, force: force ?? false, + operation: targetBotMessageId ? "update" : "create", outcome: "success", status_code: 200, ...(msg @@ -297,26 +354,34 @@ export async function handleUrls( try { const botMessage = await span( - "discord.send", + targetBotMessageId ? "discord.edit" : "discord.send", { ...spanAttributes, "discord.guild_id": String(logContext.guild_id), "discord.channel_id": String(logContext.channel_id), }, async (sendSpan) => { - const message = msg - ? i > 0 && msg.channel.isSendable() - ? await msg.channel.send(response) - : await msg.reply(response) - : i === 0 - ? await interaction!.editReply(response) - : await interaction!.followUp(response); + let message: Message; + if (msg && targetBotMessageId) { + message = await msg.channel.messages.fetch(targetBotMessageId); + await message.edit(response); + } else if (msg) { + message = + i > 0 && msg.channel.isSendable() + ? await msg.channel.send(response) + : await msg.reply(response); + } else { + message = + i === 0 + ? await interaction!.editReply(response) + : await interaction!.followUp(response); + } sendSpan.setAttribute("discord.bot_message_id", message.id); return message; }, ); logContext.bot_message_id = botMessage.id; - if (msg) { + if (msg && !targetBotMessageId) { await span( "message_cache.save", { @@ -325,11 +390,16 @@ export async function handleUrls( "discord.bot_message_id": botMessage.id, }, async () => { - await container.messageCache.save(msg.id, botMessage.id, msg.author.id); + await container.messageCache.save( + msg.id, + botMessage.id, + msg.author.id, + requestIndex, + ); }, ); } - botEmbedsCreated.add(1, metricContext); + if (!targetBotMessageId) botEmbedsCreated.add(1, metricContext); sentEmbed = true; } catch (error) { recordError(requestSpan, error); diff --git a/apps/bot/src/lib/messageCache.ts b/apps/bot/src/lib/messageCache.ts index 654bc56..54ed622 100644 --- a/apps/bot/src/lib/messageCache.ts +++ b/apps/bot/src/lib/messageCache.ts @@ -6,6 +6,7 @@ const CACHE_URL = process.env.CACHE_URL ?? "redis://localhost:6379"; interface SourceMessageCache { botMessageIds: string[]; + botMessageIndexes?: Record; } export class MessageCache { @@ -24,20 +25,29 @@ export class MessageCache { return new MessageCache(client as RedisClientType); } - public async save(sourceMessageId: string, botMessageId: string, authorId: string) { + public async save( + sourceMessageId: string, + botMessageId: string, + authorId: string, + requestIndex: number, + ) { const messageKey = this.getSourceMessageKey(sourceMessageId); const authorKey = this.getBotMessageAuthorKey(botMessageId); const sourceKey = this.getBotMessageSourceKey(botMessageId); const existing = await this.getSourceMessage(sourceMessageId); const botMessageIds = existing?.botMessageIds ?? []; + const botMessageIndexes = existing?.botMessageIndexes ?? {}; if (!botMessageIds.includes(botMessageId)) { botMessageIds.push(botMessageId); } + botMessageIndexes[botMessageId] = requestIndex; await this.client .multi() - .set(messageKey, JSON.stringify({ botMessageIds }), { EX: MESSAGE_CACHE_TTL_SECONDS }) + .set(messageKey, JSON.stringify({ botMessageIds, botMessageIndexes }), { + EX: MESSAGE_CACHE_TTL_SECONDS, + }) .set(authorKey, authorId, { EX: MESSAGE_CACHE_TTL_SECONDS }) .set(sourceKey, sourceMessageId, { EX: MESSAGE_CACHE_TTL_SECONDS }) .exec(); @@ -61,6 +71,8 @@ export class MessageCache { const sourceMessage = await this.getSourceMessage(sourceMessageId); const botMessageIds = sourceMessage?.botMessageIds.filter((id) => id !== botMessageId) ?? []; + const botMessageIndexes = sourceMessage?.botMessageIndexes ?? {}; + delete botMessageIndexes[botMessageId]; const transaction = this.client.multi().del(keys); if (botMessageIds.length === 0) { @@ -68,7 +80,7 @@ export class MessageCache { } else { transaction.set( this.getSourceMessageKey(sourceMessageId), - JSON.stringify({ botMessageIds }), + JSON.stringify({ botMessageIds, botMessageIndexes }), { EX: MESSAGE_CACHE_TTL_SECONDS, }, @@ -83,6 +95,16 @@ export class MessageCache { return sourceMessage?.botMessageIds ?? []; } + public async getBotMessages(sourceMessageId: string) { + const sourceMessage = await this.getSourceMessage(sourceMessageId); + if (!sourceMessage) return []; + + return sourceMessage.botMessageIds.map((id) => ({ + id, + requestIndex: sourceMessage.botMessageIndexes?.[id], + })); + } + public async deleteSourceMessage(sourceMessageId: string) { const botMessageIds = await this.getBotMessageIds(sourceMessageId); const keys = [ diff --git a/apps/bot/src/lib/utils.ts b/apps/bot/src/lib/utils.ts index 9e2eb78..319c31f 100644 --- a/apps/bot/src/lib/utils.ts +++ b/apps/bot/src/lib/utils.ts @@ -10,10 +10,11 @@ export interface URLMatch { export function extractURLs(content: string): URLMatch[] { return [...content.matchAll(URL_RE)].map((match) => { const index = match.index ?? 0; + const url = match[0].endsWith("||") ? match[0].slice(0, -2) : match[0]; return { - url: match[0], + url, index, - endIndex: index + match[0].length, + endIndex: index + url.length, }; }); } diff --git a/apps/bot/src/listeners/messageCreate.ts b/apps/bot/src/listeners/messageCreate.ts index af3ff94..6bb67de 100644 --- a/apps/bot/src/listeners/messageCreate.ts +++ b/apps/bot/src/listeners/messageCreate.ts @@ -1,49 +1,7 @@ import { Events, Listener } from "@sapphire/framework"; import { MessageFlags, type Message } from "discord.js"; -import type { EmbedFlags } from "../lib/builder"; -import { handleUrls, type EmbedURLRequest } from "../lib/handleUrls"; -import { extractURLs, isSpoiler } from "../lib/utils"; - -function parseMessageURLs(content: string) { - const urls: EmbedURLRequest[] = []; - let suppressNativeEmbeds = true; - - for (const match of extractURLs(content)) { - const before = content.slice(0, match.index); - const after = content.slice(match.endIndex); - - if (before.endsWith("<") && after.startsWith(">")) continue; - - if (before.endsWith("~")) { - suppressNativeEmbeds = false; - continue; - } - - const flags: Partial = { - Spoiler: isSpoiler(match.url, content), - }; - let force = false; - - if (before.endsWith("?@")) { - flags.SourceOnly = true; - force = true; - } else if (before.endsWith("?!")) { - flags.MediaOnly = true; - force = true; - } else if (before.endsWith("@")) { - flags.SourceOnly = true; - } else if (before.endsWith("!")) { - flags.MediaOnly = true; - } else if (before.endsWith("?")) { - force = true; - } - - urls.push({ url: match.url, flags, force }); - } - - return { urls, suppressNativeEmbeds }; -} +import { handleUrls, parseMessageURLs } from "../lib/handleUrls"; export class MessageCreateListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { diff --git a/apps/bot/src/listeners/messageUpdate.ts b/apps/bot/src/listeners/messageUpdate.ts index 0d3d881..d64f23a 100644 --- a/apps/bot/src/listeners/messageUpdate.ts +++ b/apps/bot/src/listeners/messageUpdate.ts @@ -1,6 +1,17 @@ import { Events, Listener } from "@sapphire/framework"; import { MessageFlags, type Message, type PartialMessage } from "discord.js"; +import { handleUrls, parseMessageURLs, type EmbedURLRequest } from "../lib/handleUrls"; + +function hasSameOptions(left: EmbedURLRequest, right: EmbedURLRequest) { + return ( + left.force === right.force && + left.flags?.MediaOnly === right.flags?.MediaOnly && + left.flags?.SourceOnly === right.flags?.SourceOnly && + left.flags?.Spoiler === right.flags?.Spoiler + ); +} + export class MessageUpdateListener extends Listener { public constructor(context: Listener.LoaderContext, options: Listener.Options) { super(context, { @@ -10,17 +21,41 @@ export class MessageUpdateListener extends Listener } public async run(oldMessage: Message | PartialMessage, newMessage: Message | PartialMessage) { - if (oldMessage.embeds.length > 0) return; - if (newMessage.embeds.length === 0) return; - if (newMessage.flags.has(MessageFlags.SuppressEmbeds)) return; - const message = newMessage.partial ? await newMessage.fetch() : newMessage; if (message.author?.bot) return; if (message.author?.id === this.container.client.id) return; - const botMessageIds = await this.container.messageCache.getBotMessageIds(message.id); - if (botMessageIds.length === 0) return; + const botMessages = await this.container.messageCache.getBotMessages(message.id); + if (botMessages.length === 0) return; + if (typeof oldMessage.content === "string" && oldMessage.content !== message.content) { + const oldUrls = parseMessageURLs(oldMessage.content).urls; + const newUrls = parseMessageURLs(message.content).urls; + const sameUrls = + oldUrls.length === newUrls.length && + oldUrls.every((request, index) => request.url === newUrls[index]?.url); + + if (sameUrls) { + const updateTargets = new Map(); + + for (const { id, requestIndex } of botMessages) { + if (requestIndex === undefined) continue; + const oldRequest = oldUrls[requestIndex]; + const newRequest = newUrls[requestIndex]; + if (!oldRequest || !newRequest) continue; + if (hasSameOptions(oldRequest, newRequest)) continue; + updateTargets.set(requestIndex, id); + } + + if (updateTargets.size > 0) { + await handleUrls(newUrls, message, { updateTargets }); + } + } + } + + if (oldMessage.embeds.length > 0) return; + if (message.embeds.length === 0) return; + if (message.flags.has(MessageFlags.SuppressEmbeds)) return; await message.edit({ flags: MessageFlags.SuppressEmbeds }); } }