From d6187e45601519942b606ddbdbf321017d14e974 Mon Sep 17 00:00:00 2001 From: Risu <79110363+risu729@users.noreply.github.com> Date: Mon, 1 Jan 2024 12:38:00 +0900 Subject: [PATCH] feat: suppress default embeds if the bot sends alternative (#14) --- bun.lockb | Bin 41012 -> 42176 bytes package.json | 1 + src/embeds.ts | 159 +++++++++++++++++++++++++++++++++----------------- src/main.ts | 19 +++--- 4 files changed, 118 insertions(+), 61 deletions(-) diff --git a/bun.lockb b/bun.lockb index e27094ff67046d47260dfc0313caaf2c40a062db..0ddb7b64d5cf4e299f4c4bf00914f8009289645e 100755 GIT binary patch delta 7460 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 delta 6718 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 DT~GoW diff --git a/package.json b/package.json index 0105f12..4448ff2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@googleapis/drive": "8.4.0", + "compare-urls": "4.0.0", "discord.js": "14.14.1" }, "devDependencies": { diff --git a/src/embeds.ts b/src/embeds.ts index 971cafc..5834e5b 100644 --- a/src/embeds.ts +++ b/src/embeds.ts @@ -1,7 +1,8 @@ import { drive_v3 } from "@googleapis/drive"; import { deepMatch, sleep } from "bun"; +// @ts-expect-error: no types +import compareUrls from "compare-urls"; import { - BaseMessageOptions, EmbedBuilder, Message, MessageCreateOptions, @@ -18,16 +19,19 @@ import { appendInvisible, decodeAppendedInvisible } from "./util"; /** * Extract Google Drive file IDs from a string. * @param content string to extract file IDs from - * @returns extracted file IDs + * @returns array of URLs and file IDs */ -const extractFileIds = (content: string): string[] => { +const extractFileIds = (content: string): { url: string; fileId: string }[] => { // file ID is the path segment after d (files), e (forms), or folders // ref: https://github.com/spamscanner/url-regex-safe/blob/6c1e2c3b5557709633a2cc971d599469ea395061/src/index.js#L80 // ref: https://stackoverflow.com/questions/16840038/easiest-way-to-get-file-id-from-url-on-google-apps-script const regex = /https?:\/\/(?:drive|docs)\.google\.com\/[^\s'"\)]+\/(?:d|e|folders)\/([-\w]{25,})(?:\/[^\s'"\)]*[^\s"\)'.?!])?/g; - // biome-ignore lint/style/noNonNullAssertion: the first matching group is always defined if the regex matches - return [...content.matchAll(regex)].map(([, id]) => id!); + return [...content.matchAll(regex)].map(([url, id]) => ({ + url, + // biome-ignore lint/style/noNonNullAssertion: the first matching group is always defined if the regex matches + fileId: id!, + })); }; /** @@ -77,13 +81,10 @@ const retrieveOldEmbedsMessage = async ( * @param message source message * @returns embeds message, or undefined if no embeds are created */ -const createEmbedsMessage = async ({ - content, - id: sourceId, -}: Message): Promise< - (MessageCreateOptions & MessageEditOptions) | undefined -> => { - const fileIds = extractFileIds(content); +const createEmbedsMessage = async ( + fileIds: string[], + sourceId: string, +): Promise<(MessageCreateOptions & MessageEditOptions) | undefined> => { const files = await Promise.all( fileIds.map((id) => driveClient.files @@ -141,6 +142,28 @@ const createEmbedsMessage = async ({ }; }; +/** + * Suppress default embeds of Google Drive links in a message. + * @param message message to suppress embeds in + * @param fileUrls URLs of files to suppress embeds of + */ +const suppressEmbeds = async (message: Message, fileUrls: string[]) => { + const embedsUrls = message.embeds.map(({ url }) => url); + const shouldSuppress = + !!embedsUrls.length && + embedsUrls.every( + // return false if null, which means the embed is not a link + (embedUrl) => + embedUrl && fileUrls.some((fileUrl) => compareUrls(fileUrl, embedUrl)), + ); + + // do not send a request if no change is needed + if (message.flags.has(MessageFlags.SuppressEmbeds) === shouldSuppress) { + return; + } + await message.suppressEmbeds(shouldSuppress); +}; + /** * Update the embeds message of a source message. * @param sourceMessage source message @@ -148,60 +171,90 @@ const createEmbedsMessage = async ({ */ export const updateEmbedsMessage = async ( sourceMessage: Message, - newlyCreated = false, + options: + | { [k: string]: never } + | { + isNewlyCreated: boolean; + } + | { + isEmbedsSuppressed: boolean; + } = {}, ) => { + const isNewlyCreated = "isNewlyCreated" in options && options.isNewlyCreated; + const isEmbedsSuppressed = + "isEmbedsSuppressed" in options && options.isEmbedsSuppressed; + + const fileIds = extractFileIds(sourceMessage.content); const [oldEmbedsMessage, newEmbedsMessage] = await Promise.all([ // skip retrieving old embeds message if the source message is newly created - // retry twice because the old embeds might not be sent yet when the source is updated in quick succession - newlyCreated ? undefined : retrieveOldEmbedsMessage(sourceMessage, 2), - createEmbedsMessage(sourceMessage), + isNewlyCreated + ? undefined + : // retry twice because the old embeds might not be sent yet when the source is updated in quick succession + retrieveOldEmbedsMessage(sourceMessage, 2), + createEmbedsMessage( + fileIds.map(({ fileId }) => fileId), + sourceMessage.id, + ), ]); - if (!oldEmbedsMessage) { + // use try-finally to suppress embeds even if an error is thrown + try { + if (!oldEmbedsMessage) { + if (!newEmbedsMessage) { + return; + } + + await sourceMessage.channel.send(newEmbedsMessage); + return; + } + if (!newEmbedsMessage) { + await oldEmbedsMessage.delete(); return; } - await sourceMessage.channel.send(newEmbedsMessage); - return; - } + if ( + oldEmbedsMessage.embeds?.length === newEmbedsMessage.embeds?.length && + oldEmbedsMessage.embeds?.every(({ data: oldEmbedData }, i) => { + const newEmbed = newEmbedsMessage.embeds?.[i]; + if (!newEmbed) { + return false; + } + const newEmbedData = isJSONEncodable(newEmbed) + ? newEmbed.toJSON() + : newEmbed; - if (!newEmbedsMessage) { - await oldEmbedsMessage.delete(); - return; - } + // do not use Embed#equals because it compares timestamps just as strings + return ( + new Date(oldEmbedData.timestamp ?? 0).getTime() === + new Date(newEmbedData.timestamp ?? 0).getTime() && + // oldEmbedData includes some extra properties like `type` or `content_scan_version` + deepMatch( + Object.fromEntries( + Object.entries(newEmbedData).filter( + ([key]) => key !== "timestamp", + ), + ), + oldEmbedData, + ) + ); + }) + ) { + // do not edit if the embeds are the same to avoid `(edited)` in the message + return; + } - if ( - oldEmbedsMessage.embeds?.length === newEmbedsMessage.embeds?.length && - oldEmbedsMessage.embeds?.every(({ data: oldEmbedData }, i) => { - const newEmbed = newEmbedsMessage.embeds?.[i]; - if (!newEmbed) { - return false; - } - const newEmbedData = isJSONEncodable(newEmbed) - ? newEmbed.toJSON() - : newEmbed; - - // do not use Embed#equals because it compares timestamps just as strings - return ( - new Date(oldEmbedData.timestamp ?? 0).getTime() === - new Date(newEmbedData.timestamp ?? 0).getTime() && - // oldEmbedData includes some extra properties like `type` or `content_scan_version` - deepMatch( - Object.fromEntries( - Object.entries(newEmbedData).filter(([key]) => key !== "timestamp"), - ), - oldEmbedData, - ) - ); - }) - ) { - // do not edit if the embeds are the same to avoid `(edited)` in the message + await oldEmbedsMessage.edit(newEmbedsMessage); return; + } finally { + // skip when embeds are suppressed in the source message to avoid infinite recursion + if (!isEmbedsSuppressed) { + await suppressEmbeds( + sourceMessage, + fileIds.map(({ url }) => url), + ); + } } - - await oldEmbedsMessage.edit(newEmbedsMessage); - return; }; /** diff --git a/src/main.ts b/src/main.ts index c222357..4552a2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { Events, GatewayIntentBits, Message, + MessageFlags, PartialMessage, Partials, PermissionFlagsBits, @@ -110,23 +111,25 @@ discordClient.on(Events.MessageCreate, async (message) => { if (!isValidRequest(message)) { return; } - await updateEmbedsMessage(message, true); + await updateEmbedsMessage(message, { isNewlyCreated: true }); }); -discordClient.on(Events.MessageUpdate, async (_, newMessage) => { +discordClient.on(Events.MessageUpdate, async (oldMessage, newMessage) => { if (!isValidRequest(newMessage)) { return; } - // ignore embeds only updates events, which are triggered immediately after MessageCreate - if (newMessage.editedTimestamp === null) { - return; - } // retrieve the full message to get the content - const fullMessage = newMessage.partial + const fullNewMessage = newMessage.partial ? await newMessage.fetch() : newMessage; - await updateEmbedsMessage(fullMessage); + await updateEmbedsMessage(fullNewMessage, { + isEmbedsSuppressed: + // do not treat the event as suppressed if the old message is partial + !( + oldMessage.partial || oldMessage.flags.has(MessageFlags.SuppressEmbeds) + ) && fullNewMessage.flags.has(MessageFlags.SuppressEmbeds), + }); }); discordClient.on(Events.MessageDelete, async (message) => {