diff --git a/src/common/events/index.ts b/src/common/events/index.ts index 390008c..f73b3a9 100644 --- a/src/common/events/index.ts +++ b/src/common/events/index.ts @@ -6,6 +6,7 @@ import type { DiscordEvent } from './types.js'; import archiveChannels from '@/features/archive-channels/index.js'; import { tagReceivedEvent } from '@/features/tags/tag-received.js'; import { reactionAddEvent } from '@/features/reactions/index.js'; +import { quoteEvent } from '@/features/quote/index.js'; export const events: DiscordEvent[] = [ readyEvent, @@ -15,4 +16,5 @@ export const events: DiscordEvent[] = [ archiveChannels, tagReceivedEvent, reactionAddEvent, + quoteEvent, ].flat(); diff --git a/src/features/quote/embed.ts b/src/features/quote/embed.ts new file mode 100644 index 0000000..d8929b0 --- /dev/null +++ b/src/features/quote/embed.ts @@ -0,0 +1,250 @@ +import { clampText } from '@/util/text.js'; +import { + ActionRowBuilder, + type APIEmbedField, + ButtonBuilder, + ButtonStyle, + ComponentType, + EmbedBuilder, + type Message, + type MessageActionRowComponentBuilder, + type MessageCreateOptions, + MessageFlags, + TextDisplayBuilder, + type User, +} from 'discord.js'; + +const EMBED_DESC_LIMIT = 4096; +const FIELD_VALUE_LIMIT = 1024; +const JUMP_BUTTON_LABEL = 'Jump to message'; + +type OriginalQuoteInfo = { + authorMention: string; + channelName: string; + jumpLink: string; +}; + +// Captures the pieces of a line we previously generated: +// "<@quotedBy> quoted <@author> from **#channel** [link ↗]()" (V2, has link) +// "<@quotedBy> quoted <@author> from **#channel**" (V1, no link) +const QUOTE_LINE_CAPTURE_REGEX = + /^(?:-#\s)?<@!?\d+>\squoted\s(<@!?\d+>)\sfrom\s\*\*#(.+?)\*\*(?:\s\[link ↗\]\(<(.+?)>\))?$/; + +type ParsedQuoteLine = { + authorMention: string; + channelName: string; + jumpLink?: string; +}; + +const parseQuoteLine = (text: string): ParsedQuoteLine | null => { + const match = QUOTE_LINE_CAPTURE_REGEX.exec(text); + if (!match) { + return null; + } + const [, authorMention, channelName, jumpLink] = match; + return { authorMention, channelName, jumpLink }; +}; + +const buildQuoteLine = ( + quotedBy: User, + info: OriginalQuoteInfo, + includeLink: boolean +): string => + clampText( + includeLink + ? `${quotedBy.toString()} quoted ${info.authorMention} from **#${info.channelName}** [link ↗](<${info.jumpLink}>)` + : `${quotedBy.toString()} quoted ${info.authorMention} from **#${info.channelName}**`, + FIELD_VALUE_LIMIT + ); + +const findExistingJumpButtonUrl = (message: Message): string | null => { + for (const row of message.components) { + if (row.type !== ComponentType.ActionRow) { + continue; + } + for (const component of row.components) { + if ( + component.type === ComponentType.Button && + component.style === ButtonStyle.Link && + component.label === JUMP_BUTTON_LABEL + ) { + return component.url ?? null; + } + } + } + return null; +}; + +export const createQuoteEmbed = ({ + quotedMessage, + quotedBy, +}: { + quotedMessage: Message; + quotedBy: User; +}): MessageCreateOptions | null => { + const channelName = !quotedMessage.channel.isDMBased() + ? quotedMessage.channel.name + : 'Direct Message'; + + // Default: quotedMessage is an original, non-quote message, so it *is* + // the source of truth for author/channel/link. + const freshInfo: OriginalQuoteInfo = { + authorMention: `${quotedMessage.author.toString()}`, + channelName, + jumpLink: quotedMessage.url, + }; + + const isV2 = quotedMessage.flags.has(MessageFlags.IsComponentsV2); + + if (isV2) { + const components = quotedMessage.components.map((component) => + component.toJSON() + ); + + const existingLineIndex = components.findIndex( + (component) => component.type === ComponentType.TextDisplay + ); + const existingContent = + existingLineIndex !== -1 + ? (components[existingLineIndex] as { content: string }).content + : null; + + const parsed = + existingContent !== null ? parseQuoteLine(existingContent) : null; + + const originalInfo: OriginalQuoteInfo = parsed + ? { + authorMention: parsed.authorMention, + channelName: parsed.channelName, + jumpLink: parsed.jumpLink ?? freshInfo.jumpLink, + } + : freshInfo; + + const attributionLine = new TextDisplayBuilder() + .setContent(`-# ${buildQuoteLine(quotedBy, originalInfo, true)}`) + .toJSON(); + + if (existingLineIndex !== -1) { + components[existingLineIndex] = attributionLine; + } else { + components.push(attributionLine); + } + + return { + allowedMentions: { parse: [] }, + components, + flags: MessageFlags.IsComponentsV2, + }; + } + + // Legacy (non-V2 components) + const attachmentUrls = quotedMessage.attachments.map( + (attachment) => attachment.url + ); + const firstImage = quotedMessage.attachments.find((attachment) => + attachment.contentType?.startsWith('image/') + ); + + let embeds = quotedMessage.embeds + .filter((embed) => embed.data.type === 'rich') + .slice(0, 9) // leave room for our wrapper, max 10 embeds/message + .map((embed) => EmbedBuilder.from(embed)); + + // Find an existing "Quoted by" field, if quotedMessage is itself a quote. + let existingField: APIEmbedField | null = null; + for (const embed of embeds) { + const found = embed.data.fields?.find( + (field) => /^quoted by$/i.test(field.name) && parseQuoteLine(field.value) + ); + if (found) { + existingField = found; + break; + } + } + + const parsedField = existingField + ? parseQuoteLine(existingField.value) + : null; + + // Recover link from the existing jump button, if present, otherwise fall back to the parsed field or fresh info. + const originalInfo: OriginalQuoteInfo = parsedField + ? { + authorMention: parsedField.authorMention, + channelName: parsedField.channelName, + jumpLink: + findExistingJumpButtonUrl(quotedMessage) ?? + parsedField.jumpLink ?? + freshInfo.jumpLink, + } + : freshInfo; + + const quotedByField: APIEmbedField = { + name: 'Quoted by', + value: buildQuoteLine(quotedBy, originalInfo, false), + inline: false, + }; + + if (existingField) { + // Already a quote: swap the field's value in place, keep everything + // else (original author/description/image/timestamp) untouched. + existingField.value = quotedByField.value; + } else { + // First-time quote: build the wrapper/annotation. + const authorOptions = { + name: quotedMessage.author.username, + iconURL: quotedMessage.author.displayAvatarURL({ size: 64 }), + }; + + const stampAsQuote = (embed: EmbedBuilder) => + embed.setAuthor(authorOptions).addFields(quotedByField).setTimestamp(); + + const hasContent = quotedMessage.content.length > 0; + const hasEmbeds = embeds.length > 0; + const hasAttachments = attachmentUrls.length > 0; + const hasStickers = quotedMessage.stickers.size > 0; + + if (!hasContent && !hasStickers && !hasEmbeds && !hasAttachments) { + return null; + } + + if (hasContent || hasStickers || (!hasEmbeds && !hasAttachments)) { + const wrapper = stampAsQuote(new EmbedBuilder()).setDescription( + hasContent + ? clampText(quotedMessage.content, EMBED_DESC_LIMIT) + : hasStickers + ? '*sent a sticker*' + : null + ); + if (firstImage) { + wrapper.setImage(firstImage.url); + } + embeds = [wrapper, ...embeds]; + } else if (hasEmbeds) { + embeds[0] = stampAsQuote(embeds[0]); + } else { + embeds = [ + stampAsQuote(new EmbedBuilder()).setImage(firstImage?.url ?? null), + ]; + } + } + + // Don't re-send the image we already used as the embed's setImage, + // otherwise it shows up twice. + const filesToSend = firstImage + ? attachmentUrls.filter((url) => url !== firstImage.url) + : attachmentUrls; + + return { + allowedMentions: { parse: [] }, + embeds: embeds.length > 0 ? embeds : undefined, + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setURL(originalInfo.jumpLink) + .setLabel(JUMP_BUTTON_LABEL) + .setStyle(ButtonStyle.Link) + ), + ], + files: filesToSend.length > 0 ? filesToSend : undefined, + }; +}; diff --git a/src/features/quote/index.ts b/src/features/quote/index.ts new file mode 100644 index 0000000..dceaa45 --- /dev/null +++ b/src/features/quote/index.ts @@ -0,0 +1,121 @@ +import { Client, Events, type Message } from 'discord.js'; +import { createEvent } from '@/common/events/create-event.js'; +import { UserBotMessagesService } from '@/services/user-bot-messages/user-bot-messages-service.js'; +import { createQuoteEmbed } from './embed.js'; + +export const quoteEvent = createEvent( + { + name: Events.MessageCreate, + }, + async (message) => { + if (message.system || message.author.bot) { + return; + } + const guildId = message.guildId; + + const messageLinkRegex = new RegExp( + `https:\\/\\/discord\\.com\\/channels\\/${guildId}\\/(\\d+)\\/(\\d+)`, + 'g' + ); + + const matchedQuoteLinks = Array.from( + message.content.matchAll(messageLinkRegex) + ); + + if (matchedQuoteLinks.length === 0) { + return; + } + + const quotedMessages = await Promise.allSettled( + matchedQuoteLinks.map((match) => + getMessage({ + channelId: match[1], + messageId: match[2], + client: message.client, + }) + ) + ); + + const validQuotedMessages = quotedMessages.reduce[]>( + (acc, result) => { + if (result.status === 'fulfilled' && result.value !== null) { + acc.push(result.value); + } + return acc; + }, + [] + ); + + const onlyContainsLinks = + message.content.replace(messageLinkRegex, '').trim().length === 0; + + const embedOptions = validQuotedMessages.map((quotedMessage) => + createQuoteEmbed({ quotedMessage, quotedBy: message.author }) + ); + + const validEmbeds = embedOptions.filter( + (embed): embed is NonNullable => embed !== null + ); + + const shouldDelete = onlyContainsLinks && validEmbeds.length > 0; + + if (shouldDelete) { + try { + void message.delete(); + } catch {} + } + + if (validEmbeds.length === 0) { + return; + } + + const referenceMessageId = + message.reference?.messageId || (shouldDelete ? undefined : message.id); + + const channel = message.channel; + + const results = await Promise.allSettled( + validEmbeds.map(async (options, i) => { + const sentMessage = await channel.send( + i === 0 && referenceMessageId + ? { ...options, reply: { messageReference: referenceMessageId } } + : options + ); + void UserBotMessagesService.addUserBotMessage({ + messageId: sentMessage.id, + userId: message.author.id, + channelId: message.channel.id, + }); + }) + ); + + for (const result of results) { + if (result.status === 'rejected') { + console.error('Failed to send quote message:', result.reason); + } + } + + return; + } +); + +async function getMessage({ + channelId, + messageId, + client, +}: { + channelId: string; + messageId: string; + client: Client; +}) { + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased() || channel.isDMBased()) { + return null; + } + try { + const quotedMessage = await channel.messages.fetch(messageId); + return quotedMessage; + } catch { + return null; + } +}