From 8eda3ab5ac1464ab4b52cd6fdd4cf2b13f92e2bf Mon Sep 17 00:00:00 2001 From: Ali Hammoud Date: Mon, 24 Aug 2026 03:08:46 +0300 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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(); } );