From 8eda3ab5ac1464ab4b52cd6fdd4cf2b13f92e2bf Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 03:08:46 +0300 Subject: [PATCH 1/7] feat: add id-length lint rule and refactor related code --- oxlint.config.ts | 8 ++++++++ src/env.ts | 1 + src/features/showcase/edit-showcase.ts | 4 ++-- src/features/showcase/util.ts | 4 +++- src/features/tags/create-tag.ts | 2 +- src/features/tags/edit-tag.ts | 4 ++-- src/util/advent-scheduler.test.ts | 21 +++++++++---------- src/util/advent-scheduler.ts | 2 +- src/util/fuzzy-search.ts | 28 +++++++++++++------------- 9 files changed, 41 insertions(+), 33 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 7d5c909..3dab7b8 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -11,6 +11,14 @@ export default defineConfig({ 'prefer-template': 'warn', 'typescript/no-explicit-any': 'error', 'prefer-const': 'error', + 'id-length': [ + 'error', + { + min: 2, + checkGeneric: false, + exceptions: ['_', 'i', 'j', 'x', 'y', 'z'], + }, + ], }, ignorePatterns: ['node_modules', 'dist', 'build', 'coverage', '.git'], }); diff --git a/src/env.ts b/src/env.ts index ba2d53b..9edf730 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,3 +1,4 @@ +// oxlint-disable id-length import '@/loadEnvFile.js'; function optionalEnv(key: string): string | undefined { diff --git a/src/features/showcase/edit-showcase.ts b/src/features/showcase/edit-showcase.ts index f053049..a9f0bf8 100644 --- a/src/features/showcase/edit-showcase.ts +++ b/src/features/showcase/edit-showcase.ts @@ -223,8 +223,8 @@ const modalHandler: ModalSubmitInteraction = { if (prevTagIds.length !== newProjectTags.length) { return false; } - const s = new Set(prevTagIds); - return newProjectTags.every((t) => s.has(t)); + const previousTagIdsSet = new Set(prevTagIds); + return newProjectTags.every((tag) => previousTagIdsSet.has(tag)); }; if (!tagsEqual()) { changes.push({ diff --git a/src/features/showcase/util.ts b/src/features/showcase/util.ts index 1d7daf6..69ef530 100644 --- a/src/features/showcase/util.ts +++ b/src/features/showcase/util.ts @@ -160,7 +160,9 @@ export const resolveTagNames = ( tagIds: readonly string[], availableTags: GuildForumTag[] ): string[] => { - return tagIds.map((id) => availableTags.find((t) => t.id === id)?.name ?? id); + return tagIds.map( + (id) => availableTags.find((tag) => tag.id === id)?.name ?? id + ); }; export const getShowcaseLogChannel = (guild: Guild | null) => { diff --git a/src/features/tags/create-tag.ts b/src/features/tags/create-tag.ts index 8cb9f69..c6e3711 100644 --- a/src/features/tags/create-tag.ts +++ b/src/features/tags/create-tag.ts @@ -129,7 +129,7 @@ const submissionHandler: ModalSubmitInteraction = { await interaction.reply({ components: [ ErrorMessages.Tags.TagAlreadyExists( - existingTags.map((t) => t.name).join(', ') + existingTags.map((tag) => tag.name).join(', ') ), ], flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, diff --git a/src/features/tags/edit-tag.ts b/src/features/tags/edit-tag.ts index 1c20835..4450d97 100644 --- a/src/features/tags/edit-tag.ts +++ b/src/features/tags/edit-tag.ts @@ -63,7 +63,7 @@ export const editTagCommandHandler = async ( .setCustomId('aliases') .setStyle(TextInputStyle.Short) .setRequired(true) - .setValue(tag.aliases.map((a) => a.name).join(', ')) + .setValue(tag.aliases.map((alias) => alias.name).join(', ')) ), new LabelBuilder() .setLabel('Short Description') @@ -142,7 +142,7 @@ const modalHandler: ModalSubmitInteraction = { await interaction.reply({ components: [ ErrorMessages.Tags.TagAlreadyExists( - existingTags.map((t) => t.name).join(', ') + existingTags.map((tag) => tag.name).join(', ') ), ], flags: MessageFlags.Ephemeral | MessageFlags.IsComponentsV2, diff --git a/src/util/advent-scheduler.test.ts b/src/util/advent-scheduler.test.ts index 1ef0166..dcd4391 100644 --- a/src/util/advent-scheduler.test.ts +++ b/src/util/advent-scheduler.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; -import test from 'node:test'; +import test, { it } from 'node:test'; import { config } from '@/env.js'; const { loadTracker, saveTracker } = await import('./advent-scheduler.js'); @@ -13,17 +13,14 @@ async function cleanupTestTracker() { } } -void test('advent scheduler: tracker file operations', async (t) => { - await t.test( - 'should create empty tracker if file does not exist', - async () => { - await cleanupTestTracker(); - const tracker = await loadTracker(); - assert.deepEqual(tracker, {}); - } - ); +void test('advent scheduler: tracker file operations', async () => { + await it('should create empty tracker if file does not exist', async () => { + await cleanupTestTracker(); + const tracker = await loadTracker(); + assert.deepEqual(tracker, {}); + }); - await t.test('should save and load tracker data correctly', async () => { + await it('should save and load tracker data correctly', async () => { const testData = { '2025': [1, 2, 3], '2026': [1], @@ -33,7 +30,7 @@ void test('advent scheduler: tracker file operations', async (t) => { assert.deepEqual(loaded, testData); }); - await t.test('should track multiple days per year', async () => { + await it('should track multiple days per year', async () => { const tracker = { '2025': [1, 5, 10, 15, 20, 25], }; diff --git a/src/util/advent-scheduler.ts b/src/util/advent-scheduler.ts index bbbbd82..aa942ad 100644 --- a/src/util/advent-scheduler.ts +++ b/src/util/advent-scheduler.ts @@ -39,7 +39,7 @@ async function markDayAsPosted(year: number, day: number): Promise { if (!tracker[yearKey].includes(day)) { tracker[yearKey].push(day); - tracker[yearKey].sort((a, b) => a - b); + tracker[yearKey].sort((firstDay, secondDay) => firstDay - secondDay); await saveTracker(tracker); } } diff --git a/src/util/fuzzy-search.ts b/src/util/fuzzy-search.ts index 4f3e20b..fe12df0 100644 --- a/src/util/fuzzy-search.ts +++ b/src/util/fuzzy-search.ts @@ -1,18 +1,18 @@ -export const levenshtein = (a: string, b: string) => { - const dp = Array.from({ length: a.length + 1 }, () => - Array(b.length + 1).fill(0) +export const levenshtein = (originalString: string, targetString: string) => { + const dp = Array.from({ length: originalString.length + 1 }, () => + Array(targetString.length + 1).fill(0) ); - for (let i = 0; i <= a.length; i++) { + for (let i = 0; i <= originalString.length; i++) { dp[i][0] = i; } - for (let j = 0; j <= b.length; j++) { + for (let j = 0; j <= targetString.length; j++) { dp[0][j] = j; } - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; + for (let i = 1; i <= originalString.length; i++) { + for (let j = 1; j <= targetString.length; j++) { + const cost = originalString[i - 1] === targetString[j - 1] ? 0 : 1; dp[i][j] = Math.min( dp[i - 1][j] + 1, // deletion dp[i][j - 1] + 1, // insertion @@ -21,7 +21,7 @@ export const levenshtein = (a: string, b: string) => { } } - return dp[a.length][b.length]; + return dp[originalString.length][targetString.length]; }; const bestSubstringDistance = (query: string, text: string): number => { @@ -71,7 +71,7 @@ export function fuzzySearch({ query = query.trim().toLowerCase(); const queryLen = query.length; - const scored = items.map((item) => { + const scoringArray = items.map((item) => { let maxFuzzyScore = 0; let titleMatchScore = 0; @@ -110,11 +110,11 @@ export function fuzzySearch({ }); return ( - scored + scoringArray // Filter by the original fuzzy score threshold (optional, you could use a lower threshold) - .filter((s) => s.score >= threshold) - .sort((a, b) => b.score - a.score) + .filter((result) => result.score >= threshold) + .sort((first, second) => second.score - first.score) .slice(0, limit) - .map((s) => s.item) + .map((result) => result.item) ); } From 2d71067b813805dcef5cc138d29062b530401e8e Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 04:57:20 +0300 Subject: [PATCH 2/7] feat: handle user reactions to remove bot message --- src/common/events/index.ts | 2 + src/features/reactions/index.ts | 38 ++++++++++++++++++ .../reactions/remove-user-bot-message.ts | 40 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/features/reactions/index.ts create mode 100644 src/features/reactions/remove-user-bot-message.ts diff --git a/src/common/events/index.ts b/src/common/events/index.ts index d3f25bd..390008c 100644 --- a/src/common/events/index.ts +++ b/src/common/events/index.ts @@ -5,6 +5,7 @@ import { readyEvent } from '@/features/ready/index.js'; 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'; export const events: DiscordEvent[] = [ readyEvent, @@ -13,4 +14,5 @@ export const events: DiscordEvent[] = [ interactionCreateEvent, archiveChannels, tagReceivedEvent, + reactionAddEvent, ].flat(); diff --git a/src/features/reactions/index.ts b/src/features/reactions/index.ts new file mode 100644 index 0000000..1742c9e --- /dev/null +++ b/src/features/reactions/index.ts @@ -0,0 +1,38 @@ +import { createEvent } from '@/common/events/create-event.js'; +import { + Events, + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from 'discord.js'; +import { removeUserBotMessage } from './remove-user-bot-message.js'; + +export type ReactionAddEvent = { + reaction: MessageReaction | PartialMessageReaction; + user: User | PartialUser; +}; + +const handlers = [removeUserBotMessage]; + +export const reactionAddEvent = createEvent( + { + name: Events.MessageReactionAdd, + }, + async (reaction, user, details) => { + if (user.bot) { + return; + } + try { + if (reaction.partial) { + await reaction.fetch(); + } + } catch { + return; + } + + for (const handler of handlers) { + await handler(reaction, user, details); + } + } +); diff --git a/src/features/reactions/remove-user-bot-message.ts b/src/features/reactions/remove-user-bot-message.ts new file mode 100644 index 0000000..e4022ec --- /dev/null +++ b/src/features/reactions/remove-user-bot-message.ts @@ -0,0 +1,40 @@ +import type { ClientEvents, Events, GuildMember } from 'discord.js'; +import { UserBotMessagesService } from '@/services/user-bot-messages/user-bot-messages-service.js'; + +const DELETE_EMOJIS = ['🗑️', '❌']; + +export const removeUserBotMessage: ( + ...args: ClientEvents[Events.MessageReactionAdd] +) => Promise | void = async (reaction, user) => { + if ( + reaction.emoji.name === null || + !DELETE_EMOJIS.includes(reaction.emoji.name) + ) { + return; + } + if (reaction.message.author?.id !== user.client.user.id) { + return; + } + const guild = reaction.message.guild; + if (guild === null) { + return; + } + let member: GuildMember; + try { + member = + guild.members.cache.get(user.id) ?? (await guild.members.fetch(user.id)); + } catch { + return; + } + try { + const deleted = await UserBotMessagesService.deleteUserBotMessage({ + messageId: reaction.message.id, + user: member, + }); + if (deleted) { + await reaction.message.delete(); + } + } catch { + return; + } +}; From bf7ee871499c3817abc28eece429a7493448d631 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 04:59:31 +0300 Subject: [PATCH 3/7] feat: add `DAYS_TO_KEEP_USER_BOT_MESSAGES` option --- prisma/schema.prisma | 1 + src/options.ts | 5 +++++ .../user-bot-messages/user-bot-messages-service.ts | 9 ++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 559da2f..17cef68 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,7 @@ enum OptionKey { TAG_PREFIX MAX_TAGS_PER_MESSAGE DAYS_TO_KEEP_TAGS + DAYS_TO_KEEP_USER_BOT_MESSAGES } // Options for the bot, stored in the database. diff --git a/src/options.ts b/src/options.ts index e3adc1e..001f629 100644 --- a/src/options.ts +++ b/src/options.ts @@ -23,6 +23,11 @@ const OptionsDefaults = { type: 'number', displayName: 'Days to Keep Tags', }, + [OptionKey.DAYS_TO_KEEP_USER_BOT_MESSAGES]: { + value: '7', + type: 'number', + displayName: 'Days to Keep User Bot Messages', + }, } as const satisfies Record; export type OptionTypeOf = diff --git a/src/services/user-bot-messages/user-bot-messages-service.ts b/src/services/user-bot-messages/user-bot-messages-service.ts index dcca54a..3a6bb60 100644 --- a/src/services/user-bot-messages/user-bot-messages-service.ts +++ b/src/services/user-bot-messages/user-bot-messages-service.ts @@ -2,8 +2,8 @@ import type { GuildMember } from 'discord.js'; import { prisma } from '@/db/prisma.js'; import { isStaff } from '@/util/permissions.js'; import { DAY } from '@/constants/time.js'; - -const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // every hour +import { getBotOption } from '@/options.js'; +import { OptionKey } from '@/generated/prisma/enums.js'; export const UserBotMessagesService = { async deleteUserBotMessage({ @@ -65,6 +65,9 @@ export const UserBotMessagesService = { }; void cleanup(); - setInterval(cleanup, CLEANUP_INTERVAL_MS); + const daysToKeep = getBotOption( + OptionKey.DAYS_TO_KEEP_USER_BOT_MESSAGES + ).value; + setInterval(cleanup, daysToKeep * DAY); }, }; From 50cfee7945e7327ee641f87e5bc71f22e4e16c92 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 08:14:05 +0300 Subject: [PATCH 4/7] feat: call userBotMessages cleanup in bot ready event --- src/features/ready/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index 95c365c..935abf1 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -6,6 +6,7 @@ import { fetchAndCachePublicChannelsMessages } from '@/util/cache.js'; import { syncGuidesToChannel } from '@/util/post-guides.js'; import { leaveIfNotAllowedServer } from '@/util/server-guard.js'; import { syncArchiveCategoryChannels } from '../archive-channels/util.js'; +import { UserBotMessagesService } from '@/services/user-bot-messages/user-bot-messages-service.js'; export const readyEvent = createEvent( { @@ -71,5 +72,8 @@ export const readyEvent = createEvent( error ); } + + // Start the cleanup interval for expired user bot messages + void UserBotMessagesService.startExpiredMessageCleanup(); } ); From fbd26f2d55bbae008cd69c726485b77190ad0213 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Tue, 25 Aug 2026 01:57:24 +0300 Subject: [PATCH 5/7] refactor: add line breaks --- src/features/reactions/remove-user-bot-message.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/features/reactions/remove-user-bot-message.ts b/src/features/reactions/remove-user-bot-message.ts index e4022ec..ccab885 100644 --- a/src/features/reactions/remove-user-bot-message.ts +++ b/src/features/reactions/remove-user-bot-message.ts @@ -12,13 +12,16 @@ export const removeUserBotMessage: ( ) { return; } + if (reaction.message.author?.id !== user.client.user.id) { return; } + const guild = reaction.message.guild; if (guild === null) { return; } + let member: GuildMember; try { member = @@ -26,11 +29,13 @@ export const removeUserBotMessage: ( } catch { return; } + try { const deleted = await UserBotMessagesService.deleteUserBotMessage({ messageId: reaction.message.id, user: member, }); + if (deleted) { await reaction.message.delete(); } From 3924cae643eed8132947e26c23cb9607646168ba Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Tue, 25 Aug 2026 01:57:58 +0300 Subject: [PATCH 6/7] refactor: remove unnecessary comment --- src/features/ready/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index 935abf1..93ac662 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -73,7 +73,6 @@ export const readyEvent = createEvent( ); } - // Start the cleanup interval for expired user bot messages void UserBotMessagesService.startExpiredMessageCleanup(); } ); From fda2c1fa6644c9d6e9f41062a215d90cde4b34d3 Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 08:04:59 +0300 Subject: [PATCH 7/7] feat: add quote feature --- src/common/events/index.ts | 2 + src/features/quote/embed.ts | 250 ++++++++++++++++++++++++++++++++++++ src/features/quote/index.ts | 121 +++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 src/features/quote/embed.ts create mode 100644 src/features/quote/index.ts 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; + } +}