From e51e1511ddeae88f368a1bafb3e2683d5ee00088 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Tue, 13 Oct 2026 23:55:44 +0100 Subject: [PATCH 01/29] feat: add showcase-digest --- src/common/commands/index.ts | 2 + src/constants/channels.ts | 2 + src/env.ts | 3 + .../showcase-digest/trigger-digest.ts | 142 ++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 src/features/showcase-digest/trigger-digest.ts diff --git a/src/common/commands/index.ts b/src/common/commands/index.ts index 0ec093a..1e3c55c 100644 --- a/src/common/commands/index.ts +++ b/src/common/commands/index.ts @@ -11,6 +11,7 @@ import type { Command } from './types.js'; import { reportMessage } from '@/features/report-message/index.js'; import { tagCommand } from '@/features/tags/index.js'; import { botOptionsCommand } from '@/features/bot-options/index.js'; +import { createDigestCommand } from '@/features/showcase-digest/trigger-digest.js'; export const commands = new Map( [ @@ -22,6 +23,7 @@ export const commands = new Map( cacheMessages, publicGuidesCommand, createShowcaseCommand, + createDigestCommand, sendShowcasePinnedMessage, reportMessage, tagCommand, diff --git a/src/constants/channels.ts b/src/constants/channels.ts index 63468d2..000489a 100644 --- a/src/constants/channels.ts +++ b/src/constants/channels.ts @@ -16,6 +16,7 @@ type ChannelTypeMap = { showcase: ForumChannel; showcaseLogs: TextChannel; showcaseRules: TextChannel; + showcaseDigest: TextChannel; spamDetection: TextChannel; archiveCategory: CategoryChannel; }; @@ -27,6 +28,7 @@ const EXPECTED_DISCORD_TYPE: Record = { showcase: ChannelType.GuildForum, showcaseLogs: ChannelType.GuildText, showcaseRules: ChannelType.GuildText, + showcaseDigest: ChannelType.GuildText, spamDetection: ChannelType.GuildText, archiveCategory: ChannelType.GuildCategory, }; diff --git a/src/env.ts b/src/env.ts index 6242e75..6395069 100644 --- a/src/env.ts +++ b/src/env.ts @@ -24,6 +24,8 @@ export const config = { }, fetchAndSyncMessages: optionalEnv('FETCH_AND_SYNC_MESSAGES') !== 'false', guidesTrackerPath: optionalEnv('GUIDES_TRACKER_PATH'), + showcaseDigestLookbackDays: optionalEnv('SHOWCASE_DIGEST_LOOKBACK_DAYS'), + showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOI'), adventOfCodeTrackerPath: requireEnv('ADVENT_OF_CODE_TRACKER_PATH'), roleIds: { moderators: requireEnv('MODERATORS_ROLE_IDS') @@ -43,6 +45,7 @@ export const config = { showcase: requireEnv('SHOWCASE_CHANNEL_ID'), showcaseLogs: requireEnv('SHOWCASE_LOG_CHANNEL_ID'), showcaseRules: requireEnv('SHOWCASE_RULES_CHANNEL_ID'), + showcaseDigest: requireEnv('SHOWCASE_DIGEST_CHANNEL_ID'), spamDetection: requireEnv('SPAM_DETECTION_CHANNEL_ID'), archiveCategory: requireEnv('ARCHIVE_CATEGORY_ID'), }, diff --git a/src/features/showcase-digest/trigger-digest.ts b/src/features/showcase-digest/trigger-digest.ts new file mode 100644 index 0000000..a2cdbac --- /dev/null +++ b/src/features/showcase-digest/trigger-digest.ts @@ -0,0 +1,142 @@ +import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { SERVER_CHANNELS } from '@/constants/channels.js'; +import { MINUTE } from '@/constants/time.js'; +import { config } from '@/env.js'; + +import { + ButtonInteraction, + ChatInputCommandInteraction, + Collection, + MessageFlags, + ContainerBuilder, + TextDisplayBuilder, + TextChannel, + ForumThreadChannel, + channelMention, + Message, + MessageReaction, +} from 'discord.js'; + +const LOOKBACK_N: number = (() => { + const DEFAULT_N = 7; + if (config.showcaseDigestLookbackDays) { + try { + return Number.parseInt(config.showcaseDigestLookbackDays); + } catch (err) { + console.error('Failed to parse showcaseDigestLookbackDays: ', err); + } + } + console.warn(`DEFAULT_N is being used for showcase digest: n=${DEFAULT_N}`); + return DEFAULT_N; +})(); + +const REACT_EMOJI = (() => { + const DEFAULT_EMOJI: string = '%F0%9F%98%AD'; + if (config.showcaseReactEmoji) { + return config.showcaseReactEmoji; + } + console.warn( + `DEFAULT_REACT_EMOJI is being used for showcase digest: n=${DEFAULT_EMOJI}` + ); + return DEFAULT_EMOJI; +})(); + +const postProcessor = async (posts: Collection) => { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent('## Showcase Digest') + ); + type Post = { id: string; messageCount: number; likes: number }; + const filterByReaction = (reactEmoji: MessageReaction) => { + return reactEmoji.emoji.identifier == REACT_EMOJI; + }; + + const sortByDescPost = (postA: Post, postB: Post) => { + return postB.likes - postA.likes; + }; + const postsUnordered: Post[] = await Promise.all( + posts.map(async (post) => { + const firstMessage: Message | null = await post.fetchStarterMessage(); + if (!firstMessage) { + console.error('Failed to get starter message under showcase-digest'); + return { id: '', messageCount: 0, likes: 0 }; + } + const likes = firstMessage.reactions.cache.find(filterByReaction)?.count; + return { + id: post.id, + messageCount: post.messageCount ? post.messageCount : 0, + likes: likes ? likes : 0, + }; + }) + ); + + const postsOrdered = postsUnordered.sort(sortByDescPost); + + postsOrdered.forEach((post, index) => { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `${index}. ${channelMention(post.id)} (Comments: ${post.messageCount})` + ) + ); + }); + return container.components.length ? container : null; +}; + +const digestHandler = async ( + interaction: ButtonInteraction | ChatInputCommandInteraction +) => { + if (!interaction.inGuild() || !interaction.guild) { + // This might need an upgrade? I don't think its handled correctly. + console.error( + 'Failure from command interaction on showcaseDigest: No guild present' + ); + await interaction.reply({ + content: + 'There was an error processing the digest. Please try again later.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const digestChannel = SERVER_CHANNELS.showcaseDigest as TextChannel; + const forumChannel = SERVER_CHANNELS.showcase; + const posts = forumChannel.threads.cache.filter((post) => { + const creationTime = post.createdAt?.getTime(); + const now = new Date().getTime(); + if (!creationTime) { + console.error('Creation time is missing from post on showcase-digest'); + return false; + } + return creationTime > now - MINUTE * LOOKBACK_N; + }); + + if (posts) { + const component = await postProcessor(posts); + if (component === null) { + console.warn('Showcases Forum was empty!'); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: + 'There was no valid posts in showcases forum. (N might be too low?)', + }); + return; + } + + await digestChannel.send({ + flags: MessageFlags.IsComponentsV2, + components: [component], + }); + await interaction.reply('This worked!'); + } else { + console.error('No posts found.'); + await interaction.reply('No posts were found.'); + } +}; + +export const createDigestCommand = createSlashCommand({ + data: { + name: 'digest-force', + description: `Forcefully consumes posts of the last ${LOOKBACK_N} days`, + }, + execute: digestHandler, +}); From 1407a2baf26c0a21f64cd07fc07e6be52c3b53f4 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 00:10:00 +0100 Subject: [PATCH 02/29] fix: split the showcase-digest in multiple files --- src/features/showcase-digest/config.ts | 25 +++++++++++ .../{trigger-digest.ts => handlers.ts} | 44 +++---------------- src/features/showcase-digest/triggers.ts | 11 +++++ 3 files changed, 41 insertions(+), 39 deletions(-) create mode 100644 src/features/showcase-digest/config.ts rename src/features/showcase-digest/{trigger-digest.ts => handlers.ts} (72%) create mode 100644 src/features/showcase-digest/triggers.ts diff --git a/src/features/showcase-digest/config.ts b/src/features/showcase-digest/config.ts new file mode 100644 index 0000000..1be2778 --- /dev/null +++ b/src/features/showcase-digest/config.ts @@ -0,0 +1,25 @@ +import { config } from '@/env.js'; + +export const LOOKBACK_N: number = (() => { + const DEFAULT_N = 7; + if (config.showcaseDigestLookbackDays) { + try { + return Number.parseInt(config.showcaseDigestLookbackDays); + } catch (err) { + console.error('Failed to parse showcaseDigestLookbackDays: ', err); + } + } + console.warn(`DEFAULT_N is being used for showcase digest: N=${DEFAULT_N}`); + return DEFAULT_N; +})(); + +export const REACT_EMOJI = (() => { + const DEFAULT_EMOJI: string = '%F0%9F%98%AD'; + if (config.showcaseReactEmoji) { + return config.showcaseReactEmoji; + } + console.warn( + `DEFAULT_REACT_EMOJI is being used for showcase digest: REACT_EMOJI=${DEFAULT_EMOJI}` + ); + return DEFAULT_EMOJI; +})(); diff --git a/src/features/showcase-digest/trigger-digest.ts b/src/features/showcase-digest/handlers.ts similarity index 72% rename from src/features/showcase-digest/trigger-digest.ts rename to src/features/showcase-digest/handlers.ts index a2cdbac..824a6cc 100644 --- a/src/features/showcase-digest/trigger-digest.ts +++ b/src/features/showcase-digest/handlers.ts @@ -1,8 +1,6 @@ -import { createSlashCommand } from '@/common/commands/create-commands.js'; import { SERVER_CHANNELS } from '@/constants/channels.js'; -import { MINUTE } from '@/constants/time.js'; -import { config } from '@/env.js'; - +import { DAY } from '@/constants/time.js'; +import { LOOKBACK_N, REACT_EMOJI } from './config.js'; import { ButtonInteraction, ChatInputCommandInteraction, @@ -17,30 +15,6 @@ import { MessageReaction, } from 'discord.js'; -const LOOKBACK_N: number = (() => { - const DEFAULT_N = 7; - if (config.showcaseDigestLookbackDays) { - try { - return Number.parseInt(config.showcaseDigestLookbackDays); - } catch (err) { - console.error('Failed to parse showcaseDigestLookbackDays: ', err); - } - } - console.warn(`DEFAULT_N is being used for showcase digest: n=${DEFAULT_N}`); - return DEFAULT_N; -})(); - -const REACT_EMOJI = (() => { - const DEFAULT_EMOJI: string = '%F0%9F%98%AD'; - if (config.showcaseReactEmoji) { - return config.showcaseReactEmoji; - } - console.warn( - `DEFAULT_REACT_EMOJI is being used for showcase digest: n=${DEFAULT_EMOJI}` - ); - return DEFAULT_EMOJI; -})(); - const postProcessor = async (posts: Collection) => { const container = new ContainerBuilder(); container.addTextDisplayComponents( @@ -79,10 +53,10 @@ const postProcessor = async (posts: Collection) => { ) ); }); - return container.components.length ? container : null; + return container.components.length > 1 ? container : null; }; -const digestHandler = async ( +export const digestHandler = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { if (!interaction.inGuild() || !interaction.guild) { @@ -107,7 +81,7 @@ const digestHandler = async ( console.error('Creation time is missing from post on showcase-digest'); return false; } - return creationTime > now - MINUTE * LOOKBACK_N; + return creationTime > now - DAY * LOOKBACK_N; }); if (posts) { @@ -132,11 +106,3 @@ const digestHandler = async ( await interaction.reply('No posts were found.'); } }; - -export const createDigestCommand = createSlashCommand({ - data: { - name: 'digest-force', - description: `Forcefully consumes posts of the last ${LOOKBACK_N} days`, - }, - execute: digestHandler, -}); diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts new file mode 100644 index 0000000..4170c36 --- /dev/null +++ b/src/features/showcase-digest/triggers.ts @@ -0,0 +1,11 @@ +import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { LOOKBACK_N } from './config.js'; +import { digestHandler } from './handlers.js'; + +export const createDigestCommand = createSlashCommand({ + data: { + name: 'digest-force', + description: `Forcefully consumes posts of the last ${LOOKBACK_N} days`, + }, + execute: digestHandler, +}); From 1ac90c0eddac0f635f1dc7d490b0807c99a62b6f Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 00:35:44 +0100 Subject: [PATCH 03/29] fix: split out the functions for showcase digest --- src/features/showcase-digest/handlers.ts | 97 ++++++++++++++---------- 1 file changed, 57 insertions(+), 40 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 824a6cc..ac3603a 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -15,20 +15,14 @@ import { MessageReaction, } from 'discord.js'; -const postProcessor = async (posts: Collection) => { - const container = new ContainerBuilder(); - container.addTextDisplayComponents( - new TextDisplayBuilder().setContent('## Showcase Digest') - ); - type Post = { id: string; messageCount: number; likes: number }; - const filterByReaction = (reactEmoji: MessageReaction) => { - return reactEmoji.emoji.identifier == REACT_EMOJI; - }; +type Post = { id: string; messageCount: number; likes: number }; - const sortByDescPost = (postA: Post, postB: Post) => { - return postB.likes - postA.likes; - }; - const postsUnordered: Post[] = await Promise.all( +const filterByReaction = (reactEmoji: MessageReaction) => { + return reactEmoji.emoji.identifier == REACT_EMOJI; +}; + +const convertPosts = async (posts: Collection) => { + return await Promise.all( posts.map(async (post) => { const firstMessage: Message | null = await post.fetchStarterMessage(); if (!firstMessage) { @@ -43,7 +37,17 @@ const postProcessor = async (posts: Collection) => { }; }) ); +}; +const postProcessor = async (posts: Collection) => { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent('## Showcase Digest') + ); + + const postsUnordered: Post[] = await convertPosts(posts); + const sortByDescPost = (postA: Post, postB: Post) => + postB.likes - postA.likes; const postsOrdered = postsUnordered.sort(sortByDescPost); postsOrdered.forEach((post, index) => { @@ -56,6 +60,20 @@ const postProcessor = async (posts: Collection) => { return container.components.length > 1 ? container : null; }; +function filterPostsByCreationTime( + posts: Collection, + lookbackN: number +): Collection { + return posts.filter((post) => { + const creationTime = post.createdAt?.getTime(); + const now = new Date().getTime(); + if (!creationTime) { + console.error('Creation time is missing from post on showcase-digest'); + return false; + } + return creationTime > now - DAY * lookbackN; + }); +} export const digestHandler = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { @@ -74,35 +92,34 @@ export const digestHandler = async ( const digestChannel = SERVER_CHANNELS.showcaseDigest as TextChannel; const forumChannel = SERVER_CHANNELS.showcase; - const posts = forumChannel.threads.cache.filter((post) => { - const creationTime = post.createdAt?.getTime(); - const now = new Date().getTime(); - if (!creationTime) { - console.error('Creation time is missing from post on showcase-digest'); - return false; - } - return creationTime > now - DAY * LOOKBACK_N; - }); + const posts = filterPostsByCreationTime( + forumChannel.threads.cache, + LOOKBACK_N + ); - if (posts) { - const component = await postProcessor(posts); - if (component === null) { - console.warn('Showcases Forum was empty!'); - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - 'There was no valid posts in showcases forum. (N might be too low?)', - }); - return; - } + if (posts.size === 0) { + console.warn('No posts found.'); + await interaction.reply('No posts were found.'); + return; + } - await digestChannel.send({ - flags: MessageFlags.IsComponentsV2, - components: [component], + const component = await postProcessor(posts); + if (component === null) { + console.warn('Showcases Forum was empty!'); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: + 'There was no valid posts in showcases forum. (N might be too low?)', }); - await interaction.reply('This worked!'); - } else { - console.error('No posts found.'); - await interaction.reply('No posts were found.'); + return; } + + await digestChannel.send({ + flags: MessageFlags.IsComponentsV2, + components: [component], + }); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: 'Successfully digested the message!', + }); }; From 9ea18420304cbc4dfcc7c70faf3523ee1906ccd4 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 00:39:10 +0100 Subject: [PATCH 04/29] fix: hoist Date.now() out of filter per post --- src/features/showcase-digest/handlers.test.ts | 0 src/features/showcase-digest/handlers.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/features/showcase-digest/handlers.test.ts diff --git a/src/features/showcase-digest/handlers.test.ts b/src/features/showcase-digest/handlers.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index ac3603a..3c6ea0f 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -64,9 +64,9 @@ function filterPostsByCreationTime( posts: Collection, lookbackN: number ): Collection { + const now = new Date().getTime(); return posts.filter((post) => { const creationTime = post.createdAt?.getTime(); - const now = new Date().getTime(); if (!creationTime) { console.error('Creation time is missing from post on showcase-digest'); return false; From d0697b4c5592dcebed20e23250b8aa7773bc6a37 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 00:48:27 +0100 Subject: [PATCH 05/29] fix: rewrite conversion to be safe, apply KISS&DRY --- src/features/showcase-digest/handlers.ts | 48 ++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 3c6ea0f..1108504 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -11,7 +11,6 @@ import { TextChannel, ForumThreadChannel, channelMention, - Message, MessageReaction, } from 'discord.js'; @@ -21,44 +20,53 @@ const filterByReaction = (reactEmoji: MessageReaction) => { return reactEmoji.emoji.identifier == REACT_EMOJI; }; -const convertPosts = async (posts: Collection) => { - return await Promise.all( +const convertPosts = async ( + posts: Collection +): Promise => { + const settled = await Promise.all( posts.map(async (post) => { - const firstMessage: Message | null = await post.fetchStarterMessage(); + const firstMessage = await post.fetchStarterMessage().catch(() => null); if (!firstMessage) { console.error('Failed to get starter message under showcase-digest'); - return { id: '', messageCount: 0, likes: 0 }; + return null; } - const likes = firstMessage.reactions.cache.find(filterByReaction)?.count; + const likes = + firstMessage.reactions.cache.find(filterByReaction)?.count ?? 0; return { id: post.id, - messageCount: post.messageCount ? post.messageCount : 0, - likes: likes ? likes : 0, + messageCount: post.messageCount ?? 0, + likes, }; }) ); + + return settled.filter((post): post is Post => post !== null); }; const postProcessor = async (posts: Collection) => { + const postsUnordered: Post[] = await convertPosts(posts); + const sortByDescPost = (postA: Post, postB: Post) => + postB.likes - postA.likes; + return postsUnordered.sort(sortByDescPost); +}; + +/* This function expects a non-empty posts */ +function createContainer(posts: Post[]): ContainerBuilder { const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent('## Showcase Digest') ); - const postsUnordered: Post[] = await convertPosts(posts); - const sortByDescPost = (postA: Post, postB: Post) => - postB.likes - postA.likes; - const postsOrdered = postsUnordered.sort(sortByDescPost); - - postsOrdered.forEach((post, index) => { + posts.forEach((post, index) => { container.addTextDisplayComponents( new TextDisplayBuilder().setContent( - `${index}. ${channelMention(post.id)} (Comments: ${post.messageCount})` + `${index + 1}. ${channelMention(post.id)} (Comments: ${post.messageCount})` ) ); }); - return container.components.length > 1 ? container : null; -}; + + return container; +} function filterPostsByCreationTime( posts: Collection, @@ -103,8 +111,9 @@ export const digestHandler = async ( return; } - const component = await postProcessor(posts); - if (component === null) { + const postsOrdered: Post[] = await postProcessor(posts); + + if (postsOrdered.length === 0) { console.warn('Showcases Forum was empty!'); await interaction.reply({ flags: MessageFlags.Ephemeral, @@ -113,6 +122,7 @@ export const digestHandler = async ( }); return; } + const component = createContainer(postsOrdered); await digestChannel.send({ flags: MessageFlags.IsComponentsV2, From 55f3d0645d4f398e63bc9833572eebfbe953a6b1 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 01:12:35 +0100 Subject: [PATCH 06/29] fix: move functions out of handlers into util + unit tests --- src/features/showcase-digest/handlers.test.ts | 0 src/features/showcase-digest/handlers.ts | 48 +----- src/features/showcase-digest/util.test.ts | 159 ++++++++++++++++++ src/features/showcase-digest/util.ts | 50 ++++++ 4 files changed, 211 insertions(+), 46 deletions(-) delete mode 100644 src/features/showcase-digest/handlers.test.ts create mode 100644 src/features/showcase-digest/util.test.ts create mode 100644 src/features/showcase-digest/util.ts diff --git a/src/features/showcase-digest/handlers.test.ts b/src/features/showcase-digest/handlers.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 1108504..c2d7b5f 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -1,5 +1,4 @@ import { SERVER_CHANNELS } from '@/constants/channels.js'; -import { DAY } from '@/constants/time.js'; import { LOOKBACK_N, REACT_EMOJI } from './config.js'; import { ButtonInteraction, @@ -11,40 +10,11 @@ import { TextChannel, ForumThreadChannel, channelMention, - MessageReaction, } from 'discord.js'; - -type Post = { id: string; messageCount: number; likes: number }; - -const filterByReaction = (reactEmoji: MessageReaction) => { - return reactEmoji.emoji.identifier == REACT_EMOJI; -}; - -const convertPosts = async ( - posts: Collection -): Promise => { - const settled = await Promise.all( - posts.map(async (post) => { - const firstMessage = await post.fetchStarterMessage().catch(() => null); - if (!firstMessage) { - console.error('Failed to get starter message under showcase-digest'); - return null; - } - const likes = - firstMessage.reactions.cache.find(filterByReaction)?.count ?? 0; - return { - id: post.id, - messageCount: post.messageCount ?? 0, - likes, - }; - }) - ); - - return settled.filter((post): post is Post => post !== null); -}; +import { convertPosts, filterPostsByCreationTime, type Post } from './util.js'; const postProcessor = async (posts: Collection) => { - const postsUnordered: Post[] = await convertPosts(posts); + const postsUnordered: Post[] = await convertPosts(posts, REACT_EMOJI); const sortByDescPost = (postA: Post, postB: Post) => postB.likes - postA.likes; return postsUnordered.sort(sortByDescPost); @@ -68,20 +38,6 @@ function createContainer(posts: Post[]): ContainerBuilder { return container; } -function filterPostsByCreationTime( - posts: Collection, - lookbackN: number -): Collection { - const now = new Date().getTime(); - return posts.filter((post) => { - const creationTime = post.createdAt?.getTime(); - if (!creationTime) { - console.error('Creation time is missing from post on showcase-digest'); - return false; - } - return creationTime > now - DAY * lookbackN; - }); -} export const digestHandler = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts new file mode 100644 index 0000000..e96279b --- /dev/null +++ b/src/features/showcase-digest/util.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Collection } from 'discord.js'; +import type { ForumThreadChannel, MessageReaction } from 'discord.js'; +import { DAY } from '@/constants/time.js'; +import { convertPosts, filterPostsByCreationTime } from './util.js'; + +const REACT = '%F0%9F%98%AD'; + +function reactionCache( + entries: { identifier: string; count: number }[] +): Collection { + const cache = new Collection(); + entries.forEach((entry, index) => + cache.set(entry.identifier + index, { + emoji: { identifier: entry.identifier }, + count: entry.count, + } as unknown as MessageReaction) + ); + return cache; +} + +type MockPostOptions = { + id: string; + messageCount?: number; + createdAt?: Date | null; + reactions?: { identifier: string; count: number }[]; + noStarter?: boolean; + starterRejects?: boolean; +}; + +function mockPost(options: MockPostOptions): ForumThreadChannel { + return { + id: options.id, + messageCount: options.messageCount ?? 0, + createdAt: options.createdAt === undefined ? new Date() : options.createdAt, + fetchStarterMessage: async () => { + if (options.starterRejects) throw new Error('fetch failed'); + if (options.noStarter) return null; + return { reactions: { cache: reactionCache(options.reactions ?? []) } }; + }, + } as unknown as ForumThreadChannel; +} + +function collectionOf( + posts: ForumThreadChannel[] +): Collection { + const collection = new Collection(); + posts.forEach((post) => collection.set(post.id, post)); + return collection; +} + +void describe('showcase-digest/util', () => { + void describe('convertPosts', () => { + void it('maps a post to the count of the configured react emoji', async () => { + const posts = collectionOf([ + mockPost({ + id: 'a', + messageCount: 5, + reactions: [ + { identifier: REACT, count: 3 }, + { identifier: '%F0%9F%91%8D', count: 99 }, + ], + }), + ]); + + const result = await convertPosts(posts, REACT); + + assert.deepEqual(result, [{ id: 'a', messageCount: 5, likes: 3 }]); + }); + + void it('defaults likes to 0 when the react emoji is absent', async () => { + const posts = collectionOf([ + mockPost({ + id: 'a', + reactions: [{ identifier: '%F0%9F%91%8D', count: 9 }], + }), + ]); + + const [post] = await convertPosts(posts, REACT); + + assert.equal(post.likes, 0); + }); + + void it('drops posts whose starter message is missing', async () => { + const posts = collectionOf([ + mockPost({ id: 'ok', reactions: [{ identifier: REACT, count: 1 }] }), + mockPost({ id: 'gone', noStarter: true }), + ]); + + const result = await convertPosts(posts, REACT); + + assert.deepEqual( + result.map((post) => post.id), + ['ok'] + ); + }); + + void it('drops posts whose starter message fetch rejects', async () => { + const posts = collectionOf([ + mockPost({ id: 'ok', reactions: [{ identifier: REACT, count: 1 }] }), + mockPost({ id: 'boom', starterRejects: true }), + ]); + + const result = await convertPosts(posts, REACT); + + assert.deepEqual( + result.map((post) => post.id), + ['ok'] + ); + }); + + void it('defaults a missing messageCount to 0', async () => { + const posts = collectionOf([ + mockPost({ id: 'a', messageCount: undefined }), + ]); + + const [post] = await convertPosts(posts, REACT); + + assert.equal(post.messageCount, 0); + }); + }); + + void describe('filterPostsByCreationTime', () => { + const now = Date.now(); + + void it('keeps posts created within the lookback window', () => { + const posts = collectionOf([ + mockPost({ id: 'recent', createdAt: new Date(now - 1 * DAY) }), + ]); + + const result = filterPostsByCreationTime(posts, 7); + + assert.deepEqual([...result.keys()], ['recent']); + }); + + void it('excludes posts older than the lookback window', () => { + const posts = collectionOf([ + mockPost({ id: 'recent', createdAt: new Date(now - 1 * DAY) }), + mockPost({ id: 'stale', createdAt: new Date(now - 10 * DAY) }), + ]); + + const result = filterPostsByCreationTime(posts, 7); + + assert.deepEqual([...result.keys()], ['recent']); + }); + + void it('excludes posts with a missing creation time', () => { + const posts = collectionOf([ + mockPost({ id: 'recent', createdAt: new Date(now - 1 * DAY) }), + mockPost({ id: 'undated', createdAt: null }), + ]); + + const result = filterPostsByCreationTime(posts, 7); + + assert.deepEqual([...result.keys()], ['recent']); + }); + }); +}); diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts new file mode 100644 index 0000000..482dde8 --- /dev/null +++ b/src/features/showcase-digest/util.ts @@ -0,0 +1,50 @@ +import { DAY } from '@/constants/time.js'; +import { Collection, ForumThreadChannel, MessageReaction } from 'discord.js'; + +export type Post = { id: string; messageCount: number; likes: number }; + +export const filterByReaction = ( + reaction: MessageReaction, + reactEmoji: string +): boolean => reaction.emoji.identifier === reactEmoji; + +export const convertPosts = async ( + posts: Collection, + reactEmoji: string +): Promise => { + const settled = await Promise.all( + posts.map(async (post) => { + const firstMessage = await post.fetchStarterMessage().catch(() => null); + if (!firstMessage) { + console.error('Failed to get starter message under showcase-digest'); + return null; + } + const likes = + firstMessage.reactions.cache.find((reaction) => + filterByReaction(reaction, reactEmoji) + )?.count ?? 0; + return { + id: post.id, + messageCount: post.messageCount ?? 0, + likes, + }; + }) + ); + + return settled.filter((post): post is Post => post !== null); +}; + +export const filterPostsByCreationTime = ( + posts: Collection, + lookbackN: number +): Collection => { + const now = new Date().getTime(); + return posts.filter((post) => { + const creationTime = post.createdAt?.getTime(); + if (!creationTime) { + console.error('Creation time is missing from post on showcase-digest'); + return false; + } + return creationTime > now - DAY * lookbackN; + }); +}; From 4dd7a2dae406f71c1d6c7a539144f906fc1ba90b Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 01:34:25 +0100 Subject: [PATCH 07/29] feat: add showcase-digest cronjob trigger --- src/features/ready/index.ts | 8 ++++++++ src/features/showcase/create-showcase.ts | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index f7eef82..1c946a5 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -3,6 +3,7 @@ import { createEvent } from '@/common/events/create-event.js'; import { resolveChannels } from '@/constants/channels.js'; import { config } from '@/env.js'; import { initializeAdventScheduler } from '@/util/advent-scheduler.js'; +import { initializeShowcaseDigestScheduler } from '@/features/showcase-digest/triggers.js'; import { fetchAndCachePublicChannelsMessages } from '@/util/channel-prefetch.js'; import { syncGuidesToChannel } from '@/util/post-guides.js'; import { leaveIfNotAllowedServer } from '@/util/server-guard.js'; @@ -65,6 +66,13 @@ export const readyEvent = createEvent( console.error('❌ Failed to initialize Advent of Code scheduler:', error); } + // Initialize showcase digest scheduler + try { + initializeShowcaseDigestScheduler(); + } catch (error) { + console.error('Failed to initialize showcase digest scheduler:', error); + } + // Make sure all channels in the archived category are properly archived on startup try { await syncArchiveCategoryChannels(); diff --git a/src/features/showcase/create-showcase.ts b/src/features/showcase/create-showcase.ts index 108edc4..c6133d8 100644 --- a/src/features/showcase/create-showcase.ts +++ b/src/features/showcase/create-showcase.ts @@ -85,7 +85,6 @@ const modalHandler: ModalSubmitInteraction = { allowedMentions: { users: [interaction.user.id] }, }, }); - await thread.send({ flags: MessageFlags.IsComponentsV2, components: [ From abd0f690825502948d4130edddf2177e7a156f50 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 20:02:25 +0100 Subject: [PATCH 08/29] fix: add missing functions for showcase-digest --- src/features/showcase-digest/handlers.ts | 32 ++++++++++++++++++++++++ src/features/showcase-digest/triggers.ts | 28 ++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index c2d7b5f..f555fc0 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -38,6 +38,38 @@ function createContainer(posts: Post[]): ContainerBuilder { return container; } +/** + * Builds and posts the showcase digest to the digest channel without an + * interaction, so it can be driven by the scheduler. Relies on channels already + * resolved into SERVER_CHANNELS at ready. + */ +export const runShowcaseDigest = async (): Promise<'sent' | 'empty'> => { + const digestChannel = SERVER_CHANNELS.showcaseDigest as TextChannel; + const forumChannel = SERVER_CHANNELS.showcase; + const posts = filterPostsByCreationTime( + forumChannel.threads.cache, + LOOKBACK_N + ); + + if (posts.size === 0) { + console.warn('No posts found.'); + return 'empty'; + } + + const postsOrdered: Post[] = await postProcessor(posts); + + if (postsOrdered.length === 0) { + console.warn('Showcases Forum was empty!'); + return 'empty'; + } + + await digestChannel.send({ + flags: MessageFlags.IsComponentsV2, + components: [createContainer(postsOrdered)], + }); + return 'sent'; +}; + export const digestHandler = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index 4170c36..dd8b535 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -1,6 +1,7 @@ +import * as cron from 'node-cron'; import { createSlashCommand } from '@/common/commands/create-commands.js'; import { LOOKBACK_N } from './config.js'; -import { digestHandler } from './handlers.js'; +import { digestHandler, runShowcaseDigest } from './handlers.js'; export const createDigestCommand = createSlashCommand({ data: { @@ -9,3 +10,28 @@ export const createDigestCommand = createSlashCommand({ }, execute: digestHandler, }); + +// Weekly, Fridays at 16:00 UTC. Pairs with the default 7-day lookback window. +// https://github.com/node-cron/node-cron?tab=readme-ov-file#cron-syntax +const SHOWCASE_DIGEST_CRON = '0 16 * * 5'; + +export function initializeShowcaseDigestScheduler(): void { + console.log('Initializing showcase digest scheduler...'); + + cron.schedule( + SHOWCASE_DIGEST_CRON, + () => { + console.log('Running scheduled showcase digest...'); + runShowcaseDigest() + .then((result) => console.log(`Scheduled showcase digest: ${result}`)) + .catch((error) => + console.error('Error in scheduled showcase digest:', error) + ); + }, + { timezone: 'UTC' } + ); + + console.log( + 'Showcase digest scheduler initialized (runs Fridays at 16:00 UTC)' + ); +} From 5549661d92d69c2634ab74eaa3d5e3a9875faa9d Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Wed, 14 Oct 2026 20:18:49 +0100 Subject: [PATCH 09/29] feat: add pingable role --- src/env.ts | 1 + src/features/showcase-digest/handlers.ts | 32 ++++++++---------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/env.ts b/src/env.ts index 6395069..874b815 100644 --- a/src/env.ts +++ b/src/env.ts @@ -37,6 +37,7 @@ export const config = { b: optionalEnv('ROLE_B_ID'), c: optionalEnv('ROLE_C_ID'), tagAccess: requireEnv('TAG_ACCESS_ROLE_ID'), + digestRole: requireEnv('DIGEST_ROLE_ID'), }, channelIds: { repelLogs: requireEnv('REPEL_LOG_CHANNEL_ID'), diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index f555fc0..ba9ed3a 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -1,4 +1,5 @@ import { SERVER_CHANNELS } from '@/constants/channels.js'; +import { config } from '@/env.js'; import { LOOKBACK_N, REACT_EMOJI } from './config.js'; import { ButtonInteraction, @@ -10,6 +11,7 @@ import { TextChannel, ForumThreadChannel, channelMention, + roleMention, } from 'discord.js'; import { convertPosts, filterPostsByCreationTime, type Post } from './util.js'; @@ -35,6 +37,12 @@ function createContainer(posts: Post[]): ContainerBuilder { ); }); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `-# ${roleMention(config.roleIds.digestRole)}` + ) + ); + return container; } @@ -66,6 +74,7 @@ export const runShowcaseDigest = async (): Promise<'sent' | 'empty'> => { await digestChannel.send({ flags: MessageFlags.IsComponentsV2, components: [createContainer(postsOrdered)], + allowedMentions: { roles: [config.roleIds.digestRole] }, }); return 'sent'; }; @@ -86,23 +95,9 @@ export const digestHandler = async ( return; } - const digestChannel = SERVER_CHANNELS.showcaseDigest as TextChannel; - const forumChannel = SERVER_CHANNELS.showcase; - const posts = filterPostsByCreationTime( - forumChannel.threads.cache, - LOOKBACK_N - ); + const result = await runShowcaseDigest(); - if (posts.size === 0) { - console.warn('No posts found.'); - await interaction.reply('No posts were found.'); - return; - } - - const postsOrdered: Post[] = await postProcessor(posts); - - if (postsOrdered.length === 0) { - console.warn('Showcases Forum was empty!'); + if (result === 'empty') { await interaction.reply({ flags: MessageFlags.Ephemeral, content: @@ -110,12 +105,7 @@ export const digestHandler = async ( }); return; } - const component = createContainer(postsOrdered); - await digestChannel.send({ - flags: MessageFlags.IsComponentsV2, - components: [component], - }); await interaction.reply({ flags: MessageFlags.Ephemeral, content: 'Successfully digested the message!', From 33c2c9fe123098818104857fe89307669877e24d Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 03:39:20 +0100 Subject: [PATCH 10/29] fix: update the default emoji to thumbsup --- src/features/showcase-digest/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/showcase-digest/config.ts b/src/features/showcase-digest/config.ts index 1be2778..592280f 100644 --- a/src/features/showcase-digest/config.ts +++ b/src/features/showcase-digest/config.ts @@ -14,7 +14,7 @@ export const LOOKBACK_N: number = (() => { })(); export const REACT_EMOJI = (() => { - const DEFAULT_EMOJI: string = '%F0%9F%98%AD'; + const DEFAULT_EMOJI: string = '%F0%9F%91%8D'; if (config.showcaseReactEmoji) { return config.showcaseReactEmoji; } From d988d955366ef773d6a6ada5994aa268c5569dc8 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 03:42:40 +0100 Subject: [PATCH 11/29] fix: prevent non-moderators from running manual digest --- src/features/showcase-digest/handlers.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index ba9ed3a..46fd149 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -1,5 +1,7 @@ import { SERVER_CHANNELS } from '@/constants/channels.js'; import { config } from '@/env.js'; +import { getCommandUser } from '@/util/member.js'; +import { isModerator } from '@/util/permissions.js'; import { LOOKBACK_N, REACT_EMOJI } from './config.js'; import { ButtonInteraction, @@ -95,6 +97,14 @@ export const digestHandler = async ( return; } + if (!isModerator(getCommandUser(interaction))) { + await interaction.reply({ + content: 'Only moderators can run the showcase digest.', + flags: MessageFlags.Ephemeral, + }); + return; + } + const result = await runShowcaseDigest(); if (result === 'empty') { From ec44c5fb7cf7022ba2a59d8c0122a122a959b5a7 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 03:48:25 +0100 Subject: [PATCH 12/29] feat: allow the digest scheduler to only execute on within correct timing --- src/features/showcase-digest/config.ts | 2 +- src/features/showcase-digest/triggers.ts | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/features/showcase-digest/config.ts b/src/features/showcase-digest/config.ts index 592280f..26c0fdc 100644 --- a/src/features/showcase-digest/config.ts +++ b/src/features/showcase-digest/config.ts @@ -1,7 +1,7 @@ import { config } from '@/env.js'; export const LOOKBACK_N: number = (() => { - const DEFAULT_N = 7; + const DEFAULT_N = 14; if (config.showcaseDigestLookbackDays) { try { return Number.parseInt(config.showcaseDigestLookbackDays); diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index dd8b535..feea2ac 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -1,5 +1,6 @@ import * as cron from 'node-cron'; import { createSlashCommand } from '@/common/commands/create-commands.js'; +import { WEEK } from '@/constants/time.js'; import { LOOKBACK_N } from './config.js'; import { digestHandler, runShowcaseDigest } from './handlers.js'; @@ -11,16 +12,31 @@ export const createDigestCommand = createSlashCommand({ execute: digestHandler, }); -// Weekly, Fridays at 16:00 UTC. Pairs with the default 7-day lookback window. // https://github.com/node-cron/node-cron?tab=readme-ov-file#cron-syntax const SHOWCASE_DIGEST_CRON = '0 16 * * 5'; +// Number of weeks between runs, derived from the lookback window. node-cron +// can't express "every N days" directly, so we fire weekly and skip the weeks +// that don't line up with the interval. +const INTERVAL_WEEKS = Math.max(1, Math.round(LOOKBACK_N / 7)); + +const currentWeekIndex = (): number => Math.floor(Date.now() / WEEK); + +const isScheduledWeek = (): boolean => + currentWeekIndex() % INTERVAL_WEEKS === 0; + export function initializeShowcaseDigestScheduler(): void { console.log('Initializing showcase digest scheduler...'); cron.schedule( SHOWCASE_DIGEST_CRON, () => { + if (!isScheduledWeek()) { + console.log( + `Skipping showcase digest this week (runs every ${INTERVAL_WEEKS} week(s))` + ); + return; + } console.log('Running scheduled showcase digest...'); runShowcaseDigest() .then((result) => console.log(`Scheduled showcase digest: ${result}`)) @@ -32,6 +48,6 @@ export function initializeShowcaseDigestScheduler(): void { ); console.log( - 'Showcase digest scheduler initialized (runs Fridays at 16:00 UTC)' + `Showcase digest scheduler initialized (runs Fridays at 16:00 UTC, every ${INTERVAL_WEEKS} week(s))` ); } From c99c7ccd702a723344fc85ff7423748ab4c6f1a0 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 04:04:26 +0100 Subject: [PATCH 13/29] fix: append example vars into .env.example + typo fix --- .env.example | 12 +++++++++--- src/env.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 2cb8ae6..3e4c47b 100644 --- a/.env.example +++ b/.env.example @@ -12,18 +12,27 @@ REPEL_LOG_CHANNEL_ID=your_repel_log_channel_id_here ONBOARDING_CHANNEL_ID=onboarding_channel_id_here SPAM_DETECTION_CHANNEL_ID=spam_detection_channel_id_here ARCHIVE_CATEGORY_ID=archive_category_id_here +SHOWCASE_DIGEST_CHANNEL_ID=showcase_digest_channel_id_here # Role IDs (REQUIRED) MODERATORS_ROLE_IDS=role_id_1,role_id_2,role_id_3 REPEL_ROLE_ID=your_repel_role_id_here ONBOARDING_ROLE_ID=onboarding_role_id_here TAG_ACCESS_ROLE_ID=tag_access_role_id_here +DIGEST_ROLE_ID=digest_role_id_here # Optional Role IDs # ROLE_A_ID=optional_role_a_id # ROLE_B_ID=optional_role_b_id # ROLE_C_ID=optional_role_c_id +# Showcase Digest (OPTIONAL) +# Lookback window in days (defaults to 14 if unset) +# SHOWCASE_DIGEST_LOOKBACK_DAYS=7 +# URL-encoded UTF-8 of the reaction counted as a "like" (defaults to thumbsup) +# NOTE: env.ts reads this under the misspelled key SHOWCASE_REACT_EMOIJ +# SHOWCASE_REACT_EMOIJ=%F0%9F%91%8D + # Data Persistence (OPTIONAL) # Local development defaults to current directory # Docker deployments should use /app/data for persistence @@ -32,6 +41,3 @@ ADVENT_OF_CODE_TRACKER_PATH=advent-of-code-tracker.json DATABASE_URL=file:./data/webdev.db - - - diff --git a/src/env.ts b/src/env.ts index 874b815..54db84f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -25,7 +25,7 @@ export const config = { fetchAndSyncMessages: optionalEnv('FETCH_AND_SYNC_MESSAGES') !== 'false', guidesTrackerPath: optionalEnv('GUIDES_TRACKER_PATH'), showcaseDigestLookbackDays: optionalEnv('SHOWCASE_DIGEST_LOOKBACK_DAYS'), - showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOI'), + showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOIJ'), adventOfCodeTrackerPath: requireEnv('ADVENT_OF_CODE_TRACKER_PATH'), roleIds: { moderators: requireEnv('MODERATORS_ROLE_IDS') From 9200e44cd36f77001723c2e23b6e6b299880997c Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 04:06:43 +0100 Subject: [PATCH 14/29] fix: correct import on commands/index.ts --- src/common/commands/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/commands/index.ts b/src/common/commands/index.ts index 1e3c55c..f1f54ad 100644 --- a/src/common/commands/index.ts +++ b/src/common/commands/index.ts @@ -11,7 +11,7 @@ import type { Command } from './types.js'; import { reportMessage } from '@/features/report-message/index.js'; import { tagCommand } from '@/features/tags/index.js'; import { botOptionsCommand } from '@/features/bot-options/index.js'; -import { createDigestCommand } from '@/features/showcase-digest/trigger-digest.js'; +import { createDigestCommand } from '@/features/showcase-digest/triggers.js'; export const commands = new Map( [ From 1c3e8c8850004ecf3d70d693384f7b71df61f6d4 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Thu, 15 Oct 2026 04:09:37 +0100 Subject: [PATCH 15/29] fix: add missing .env.test vars --- .env.test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.test b/.env.test index 3acd3da..22fc81b 100644 --- a/.env.test +++ b/.env.test @@ -21,12 +21,14 @@ SHOWCASE_LOG_CHANNEL_ID=your-showcase-log-channel-id SHOWCASE_RULES_CHANNEL_ID=your-showcase-rules-channel-id SPAM_DETECTION_CHANNEL_ID=spam-detection-channel-id-here ARCHIVE_CATEGORY_ID=your-archived-category-id +SHOWCASE_DIGEST_CHANNEL_ID=your-showcase-digest-channel-id # Role IDs (from your dev server) REPEL_ROLE_ID=your-repel-role-id MODERATORS_ROLE_IDS=your-moderator-role-id REGULAR_ROLE_ID=your-regular-role-id TAG_ACCESS_ROLE_ID=your-tag-access-role-id +DIGEST_ROLE_ID=your-digest-role-id # Other GUIDES_TRACKER_PATH=guides-tracker.json From a181b32f87a3fe6731aa2d81461758604d08859f Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 16:10:53 +0100 Subject: [PATCH 16/29] fix: rm comments & lint --- .env.example | 2 +- src/env.ts | 2 +- src/features/ready/index.ts | 2 -- src/features/showcase-digest/util.test.ts | 8 ++++++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 3e4c47b..a2fa470 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,8 @@ DIGEST_ROLE_ID=digest_role_id_here # Showcase Digest (OPTIONAL) # Lookback window in days (defaults to 14 if unset) # SHOWCASE_DIGEST_LOOKBACK_DAYS=7 +# # URL-encoded UTF-8 of the reaction counted as a "like" (defaults to thumbsup) -# NOTE: env.ts reads this under the misspelled key SHOWCASE_REACT_EMOIJ # SHOWCASE_REACT_EMOIJ=%F0%9F%91%8D # Data Persistence (OPTIONAL) diff --git a/src/env.ts b/src/env.ts index 54db84f..c17cfb8 100644 --- a/src/env.ts +++ b/src/env.ts @@ -25,7 +25,7 @@ export const config = { fetchAndSyncMessages: optionalEnv('FETCH_AND_SYNC_MESSAGES') !== 'false', guidesTrackerPath: optionalEnv('GUIDES_TRACKER_PATH'), showcaseDigestLookbackDays: optionalEnv('SHOWCASE_DIGEST_LOOKBACK_DAYS'), - showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOIJ'), + showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOJI'), adventOfCodeTrackerPath: requireEnv('ADVENT_OF_CODE_TRACKER_PATH'), roleIds: { moderators: requireEnv('MODERATORS_ROLE_IDS') diff --git a/src/features/ready/index.ts b/src/features/ready/index.ts index 1c946a5..0475cf9 100644 --- a/src/features/ready/index.ts +++ b/src/features/ready/index.ts @@ -59,14 +59,12 @@ export const readyEvent = createEvent( } } - // Initialize Advent of Code scheduler try { initializeAdventScheduler(client, config.channelIds.adventOfCode); } catch (error) { console.error('❌ Failed to initialize Advent of Code scheduler:', error); } - // Initialize showcase digest scheduler try { initializeShowcaseDigestScheduler(); } catch (error) { diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts index e96279b..78942ba 100644 --- a/src/features/showcase-digest/util.test.ts +++ b/src/features/showcase-digest/util.test.ts @@ -35,8 +35,12 @@ function mockPost(options: MockPostOptions): ForumThreadChannel { messageCount: options.messageCount ?? 0, createdAt: options.createdAt === undefined ? new Date() : options.createdAt, fetchStarterMessage: async () => { - if (options.starterRejects) throw new Error('fetch failed'); - if (options.noStarter) return null; + if (options.starterRejects) { + throw new Error('fetch failed'); + } + if (options.noStarter) { + return null; + } return { reactions: { cache: reactionCache(options.reactions ?? []) } }; }, } as unknown as ForumThreadChannel; From fc63a3631e41d652ab340ac16e1ac2c5ac2a6bf4 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 17:29:31 +0100 Subject: [PATCH 17/29] feat: drop variables showcase-digest config from .env --- .env.example | 7 ------ src/env.ts | 2 -- src/features/showcase-digest/config.ts | 16 -------------- src/features/showcase-digest/util.test.ts | 26 +++++++++++++++-------- 4 files changed, 17 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index a2fa470..627b257 100644 --- a/.env.example +++ b/.env.example @@ -26,13 +26,6 @@ DIGEST_ROLE_ID=digest_role_id_here # ROLE_B_ID=optional_role_b_id # ROLE_C_ID=optional_role_c_id -# Showcase Digest (OPTIONAL) -# Lookback window in days (defaults to 14 if unset) -# SHOWCASE_DIGEST_LOOKBACK_DAYS=7 -# -# URL-encoded UTF-8 of the reaction counted as a "like" (defaults to thumbsup) -# SHOWCASE_REACT_EMOIJ=%F0%9F%91%8D - # Data Persistence (OPTIONAL) # Local development defaults to current directory # Docker deployments should use /app/data for persistence diff --git a/src/env.ts b/src/env.ts index c17cfb8..5c86bbc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -24,8 +24,6 @@ export const config = { }, fetchAndSyncMessages: optionalEnv('FETCH_AND_SYNC_MESSAGES') !== 'false', guidesTrackerPath: optionalEnv('GUIDES_TRACKER_PATH'), - showcaseDigestLookbackDays: optionalEnv('SHOWCASE_DIGEST_LOOKBACK_DAYS'), - showcaseReactEmoji: optionalEnv('SHOWCASE_REACT_EMOJI'), adventOfCodeTrackerPath: requireEnv('ADVENT_OF_CODE_TRACKER_PATH'), roleIds: { moderators: requireEnv('MODERATORS_ROLE_IDS') diff --git a/src/features/showcase-digest/config.ts b/src/features/showcase-digest/config.ts index 26c0fdc..beefa99 100644 --- a/src/features/showcase-digest/config.ts +++ b/src/features/showcase-digest/config.ts @@ -1,25 +1,9 @@ -import { config } from '@/env.js'; - export const LOOKBACK_N: number = (() => { const DEFAULT_N = 14; - if (config.showcaseDigestLookbackDays) { - try { - return Number.parseInt(config.showcaseDigestLookbackDays); - } catch (err) { - console.error('Failed to parse showcaseDigestLookbackDays: ', err); - } - } - console.warn(`DEFAULT_N is being used for showcase digest: N=${DEFAULT_N}`); return DEFAULT_N; })(); export const REACT_EMOJI = (() => { const DEFAULT_EMOJI: string = '%F0%9F%91%8D'; - if (config.showcaseReactEmoji) { - return config.showcaseReactEmoji; - } - console.warn( - `DEFAULT_REACT_EMOJI is being used for showcase digest: REACT_EMOJI=${DEFAULT_EMOJI}` - ); return DEFAULT_EMOJI; })(); diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts index 78942ba..0aa4e7c 100644 --- a/src/features/showcase-digest/util.test.ts +++ b/src/features/showcase-digest/util.test.ts @@ -5,7 +5,9 @@ import type { ForumThreadChannel, MessageReaction } from 'discord.js'; import { DAY } from '@/constants/time.js'; import { convertPosts, filterPostsByCreationTime } from './util.js'; -const REACT = '%F0%9F%98%AD'; +// This magic identifier is the emoji code used to define what reaction it is. +// Code %F0%9F%98%AD refers to 'LOUDLY CRYING FACE' emoji +const REACT_IDENTIFIER = '%F0%9F%98%AD'; function reactionCache( entries: { identifier: string; count: number }[] @@ -62,13 +64,13 @@ void describe('showcase-digest/util', () => { id: 'a', messageCount: 5, reactions: [ - { identifier: REACT, count: 3 }, + { identifier: REACT_IDENTIFIER, count: 3 }, { identifier: '%F0%9F%91%8D', count: 99 }, ], }), ]); - const result = await convertPosts(posts, REACT); + const result = await convertPosts(posts, REACT_IDENTIFIER); assert.deepEqual(result, [{ id: 'a', messageCount: 5, likes: 3 }]); }); @@ -81,18 +83,21 @@ void describe('showcase-digest/util', () => { }), ]); - const [post] = await convertPosts(posts, REACT); + const [post] = await convertPosts(posts, REACT_IDENTIFIER); assert.equal(post.likes, 0); }); void it('drops posts whose starter message is missing', async () => { const posts = collectionOf([ - mockPost({ id: 'ok', reactions: [{ identifier: REACT, count: 1 }] }), + mockPost({ + id: 'ok', + reactions: [{ identifier: REACT_IDENTIFIER, count: 1 }], + }), mockPost({ id: 'gone', noStarter: true }), ]); - const result = await convertPosts(posts, REACT); + const result = await convertPosts(posts, REACT_IDENTIFIER); assert.deepEqual( result.map((post) => post.id), @@ -102,11 +107,14 @@ void describe('showcase-digest/util', () => { void it('drops posts whose starter message fetch rejects', async () => { const posts = collectionOf([ - mockPost({ id: 'ok', reactions: [{ identifier: REACT, count: 1 }] }), + mockPost({ + id: 'ok', + reactions: [{ identifier: REACT_IDENTIFIER, count: 1 }], + }), mockPost({ id: 'boom', starterRejects: true }), ]); - const result = await convertPosts(posts, REACT); + const result = await convertPosts(posts, REACT_IDENTIFIER); assert.deepEqual( result.map((post) => post.id), @@ -119,7 +127,7 @@ void describe('showcase-digest/util', () => { mockPost({ id: 'a', messageCount: undefined }), ]); - const [post] = await convertPosts(posts, REACT); + const [post] = await convertPosts(posts, REACT_IDENTIFIER); assert.equal(post.messageCount, 0); }); From c4c0b890e922f1e4f2192a78668f43a310d3f825 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 19:22:53 +0100 Subject: [PATCH 18/29] fix: refactor code to prefer throwing errors and handling them --- src/features/showcase-digest/handlers.ts | 64 +++++++++++++----------- src/features/showcase-digest/triggers.ts | 16 +++--- src/features/showcase-digest/util.ts | 39 ++++++++++++++- 3 files changed, 83 insertions(+), 36 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 46fd149..d2d96e3 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -10,12 +10,17 @@ import { MessageFlags, ContainerBuilder, TextDisplayBuilder, - TextChannel, ForumThreadChannel, channelMention, roleMention, } from 'discord.js'; -import { convertPosts, filterPostsByCreationTime, type Post } from './util.js'; +import { + convertPosts, + filterPostsByCreationTime, + type Post, + LEVEL, + ShowcaseDigestValidationError, +} from './util.js'; const postProcessor = async (posts: Collection) => { const postsUnordered: Post[] = await convertPosts(posts, REACT_EMOJI); @@ -24,8 +29,15 @@ const postProcessor = async (posts: Collection) => { return postsUnordered.sort(sortByDescPost); }; -/* This function expects a non-empty posts */ function createContainer(posts: Post[]): ContainerBuilder { + if (posts.length == 0) { + throw new ShowcaseDigestValidationError( + 'Something went wrong. Please try again later.', + 'createContainer passed posts[] with length 0. Impossible scenario.', + LEVEL.SEVERE + ); + } + const container = new ContainerBuilder(); container.addTextDisplayComponents( new TextDisplayBuilder().setContent('## Showcase Digest') @@ -53,8 +65,8 @@ function createContainer(posts: Post[]): ContainerBuilder { * interaction, so it can be driven by the scheduler. Relies on channels already * resolved into SERVER_CHANNELS at ready. */ -export const runShowcaseDigest = async (): Promise<'sent' | 'empty'> => { - const digestChannel = SERVER_CHANNELS.showcaseDigest as TextChannel; +export const createShowcaseDigest = async (): Promise => { + const digestChannel = SERVER_CHANNELS.showcaseDigest; const forumChannel = SERVER_CHANNELS.showcase; const posts = filterPostsByCreationTime( forumChannel.threads.cache, @@ -62,36 +74,27 @@ export const runShowcaseDigest = async (): Promise<'sent' | 'empty'> => { ); if (posts.size === 0) { - console.warn('No posts found.'); - return 'empty'; + throw new ShowcaseDigestValidationError( + 'There was no posts to display in this digest.' + ); } const postsOrdered: Post[] = await postProcessor(posts); - - if (postsOrdered.length === 0) { - console.warn('Showcases Forum was empty!'); - return 'empty'; - } + const container = createContainer(postsOrdered); await digestChannel.send({ flags: MessageFlags.IsComponentsV2, - components: [createContainer(postsOrdered)], + components: [container], allowedMentions: { roles: [config.roleIds.digestRole] }, }); - return 'sent'; }; export const digestHandler = async ( interaction: ButtonInteraction | ChatInputCommandInteraction ) => { if (!interaction.inGuild() || !interaction.guild) { - // This might need an upgrade? I don't think its handled correctly. - console.error( - 'Failure from command interaction on showcaseDigest: No guild present' - ); await interaction.reply({ - content: - 'There was an error processing the digest. Please try again later.', + content: 'This command can only be run in the web development discord.', flags: MessageFlags.Ephemeral, }); return; @@ -105,15 +108,18 @@ export const digestHandler = async ( return; } - const result = await runShowcaseDigest(); - - if (result === 'empty') { - await interaction.reply({ - flags: MessageFlags.Ephemeral, - content: - 'There was no valid posts in showcases forum. (N might be too low?)', - }); - return; + try { + await createShowcaseDigest(); + } catch (error: unknown) { + if (error instanceof ShowcaseDigestValidationError) { + error.logError(); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: error.getUserMessage(), + }); + return; + } + throw error; } await interaction.reply({ diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index feea2ac..6df323f 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -2,7 +2,8 @@ import * as cron from 'node-cron'; import { createSlashCommand } from '@/common/commands/create-commands.js'; import { WEEK } from '@/constants/time.js'; import { LOOKBACK_N } from './config.js'; -import { digestHandler, runShowcaseDigest } from './handlers.js'; +import { digestHandler, createShowcaseDigest } from './handlers.js'; +import { ShowcaseDigestValidationError } from './util.js'; export const createDigestCommand = createSlashCommand({ data: { @@ -38,11 +39,14 @@ export function initializeShowcaseDigestScheduler(): void { return; } console.log('Running scheduled showcase digest...'); - runShowcaseDigest() - .then((result) => console.log(`Scheduled showcase digest: ${result}`)) - .catch((error) => - console.error('Error in scheduled showcase digest:', error) - ); + createShowcaseDigest() + .then(() => console.log(`Successfully displayed showcase digest!`)) + .catch((error) => { + if (error instanceof ShowcaseDigestValidationError) { + error.logError(); // No output to give to the user. + console.error('Failed to run showcase digest!'); + } + }); }, { timezone: 'UTC' } ); diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index 482dde8..e02e37f 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -16,7 +16,7 @@ export const convertPosts = async ( posts.map(async (post) => { const firstMessage = await post.fetchStarterMessage().catch(() => null); if (!firstMessage) { - console.error('Failed to get starter message under showcase-digest'); + console.warn('Failed to get starter message under showcase-digest'); return null; } const likes = @@ -48,3 +48,40 @@ export const filterPostsByCreationTime = ( return creationTime > now - DAY * lookbackN; }); }; + +export enum LEVEL { + SEVERE, + WARN, + INFO, +} +export class ShowcaseDigestValidationError extends Error { + userMessage: string; + level?: LEVEL; + constructor(userMessage: string, logError?: string, level?: LEVEL) { + super(logError); + this.userMessage = userMessage; + this.name = 'ShowcaseDigestValidationError'; + this.level = level; + } + public logError() { + const logMessage = this.message; + if (logMessage === '') { + return; + } + switch (this.level) { + case LEVEL.WARN: + console.warn(logMessage); + break; + case LEVEL.INFO: + console.log(logMessage); + break; + default: + case LEVEL.SEVERE: + console.error(logMessage); + break; + } + } + public getUserMessage(): string { + return this.userMessage; + } +} From 2f91566cf84604a325299184efc8c3b14dca04b0 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 19:48:11 +0100 Subject: [PATCH 19/29] fix: update all to use codeblocks, allSettled use, updated user msgs --- src/features/showcase-digest/handlers.ts | 2 +- src/features/showcase-digest/triggers.ts | 5 ++-- src/features/showcase-digest/util.test.ts | 36 +++++++++++------------ src/features/showcase-digest/util.ts | 34 ++++++++++++++------- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index d2d96e3..95cd983 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -124,6 +124,6 @@ export const digestHandler = async ( await interaction.reply({ flags: MessageFlags.Ephemeral, - content: 'Successfully digested the message!', + content: 'Successfully create a showcase digest!', }); }; diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index 6df323f..e411d88 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -23,8 +23,9 @@ const INTERVAL_WEEKS = Math.max(1, Math.round(LOOKBACK_N / 7)); const currentWeekIndex = (): number => Math.floor(Date.now() / WEEK); -const isScheduledWeek = (): boolean => - currentWeekIndex() % INTERVAL_WEEKS === 0; +const isScheduledWeek = (): boolean => { + return currentWeekIndex() % INTERVAL_WEEKS === 0; +}; export function initializeShowcaseDigestScheduler(): void { console.log('Initializing showcase digest scheduler...'); diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts index 0aa4e7c..fff733e 100644 --- a/src/features/showcase-digest/util.test.ts +++ b/src/features/showcase-digest/util.test.ts @@ -7,7 +7,8 @@ import { convertPosts, filterPostsByCreationTime } from './util.js'; // This magic identifier is the emoji code used to define what reaction it is. // Code %F0%9F%98%AD refers to 'LOUDLY CRYING FACE' emoji -const REACT_IDENTIFIER = '%F0%9F%98%AD'; +const REACT_IDENTIFIER_A = '%F0%9F%98%AD'; +const REACT_IDENTIFIER_B = '%F0%9F%91%8D'; function reactionCache( entries: { identifier: string; count: number }[] @@ -52,7 +53,9 @@ function collectionOf( posts: ForumThreadChannel[] ): Collection { const collection = new Collection(); - posts.forEach((post) => collection.set(post.id, post)); + posts.forEach((post) => { + return collection.set(post.id, post); + }); return collection; } @@ -64,13 +67,13 @@ void describe('showcase-digest/util', () => { id: 'a', messageCount: 5, reactions: [ - { identifier: REACT_IDENTIFIER, count: 3 }, - { identifier: '%F0%9F%91%8D', count: 99 }, + { identifier: REACT_IDENTIFIER_A, count: 3 }, + { identifier: REACT_IDENTIFIER_B, count: 99 }, ], }), ]); - const result = await convertPosts(posts, REACT_IDENTIFIER); + const result = await convertPosts(posts, REACT_IDENTIFIER_A); assert.deepEqual(result, [{ id: 'a', messageCount: 5, likes: 3 }]); }); @@ -79,11 +82,11 @@ void describe('showcase-digest/util', () => { const posts = collectionOf([ mockPost({ id: 'a', - reactions: [{ identifier: '%F0%9F%91%8D', count: 9 }], + reactions: [{ identifier: REACT_IDENTIFIER_B, count: 9 }], }), ]); - const [post] = await convertPosts(posts, REACT_IDENTIFIER); + const [post] = await convertPosts(posts, REACT_IDENTIFIER_A); assert.equal(post.likes, 0); }); @@ -92,12 +95,12 @@ void describe('showcase-digest/util', () => { const posts = collectionOf([ mockPost({ id: 'ok', - reactions: [{ identifier: REACT_IDENTIFIER, count: 1 }], + reactions: [{ identifier: REACT_IDENTIFIER_A, count: 1 }], }), mockPost({ id: 'gone', noStarter: true }), ]); - const result = await convertPosts(posts, REACT_IDENTIFIER); + const result = await convertPosts(posts, REACT_IDENTIFIER_A); assert.deepEqual( result.map((post) => post.id), @@ -105,21 +108,18 @@ void describe('showcase-digest/util', () => { ); }); - void it('drops posts whose starter message fetch rejects', async () => { + void it('propagates a non-validation error from starter message fetch', async () => { const posts = collectionOf([ mockPost({ id: 'ok', - reactions: [{ identifier: REACT_IDENTIFIER, count: 1 }], + reactions: [{ identifier: REACT_IDENTIFIER_A, count: 1 }], }), mockPost({ id: 'boom', starterRejects: true }), ]); - const result = await convertPosts(posts, REACT_IDENTIFIER); - - assert.deepEqual( - result.map((post) => post.id), - ['ok'] - ); + await assert.rejects(() => convertPosts(posts, REACT_IDENTIFIER_A), { + message: 'fetch failed', + }); }); void it('defaults a missing messageCount to 0', async () => { @@ -127,7 +127,7 @@ void describe('showcase-digest/util', () => { mockPost({ id: 'a', messageCount: undefined }), ]); - const [post] = await convertPosts(posts, REACT_IDENTIFIER); + const [post] = await convertPosts(posts, REACT_IDENTIFIER_A); assert.equal(post.messageCount, 0); }); diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index e02e37f..b7526f3 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -6,23 +6,28 @@ export type Post = { id: string; messageCount: number; likes: number }; export const filterByReaction = ( reaction: MessageReaction, reactEmoji: string -): boolean => reaction.emoji.identifier === reactEmoji; +): boolean => { + return reaction.emoji.identifier === reactEmoji; +}; export const convertPosts = async ( posts: Collection, reactEmoji: string ): Promise => { - const settled = await Promise.all( - posts.map(async (post) => { - const firstMessage = await post.fetchStarterMessage().catch(() => null); + const settled = await Promise.allSettled( + posts.map(async (post): Promise => { + const firstMessage = await post.fetchStarterMessage(); if (!firstMessage) { - console.warn('Failed to get starter message under showcase-digest'); - return null; + throw new ShowcaseDigestValidationError( + 'Something went wrong, please try again later.', + 'Failed to get starter message under showcase-digest', + LEVEL.WARN + ); } const likes = - firstMessage.reactions.cache.find((reaction) => - filterByReaction(reaction, reactEmoji) - )?.count ?? 0; + firstMessage.reactions.cache.find((reaction) => { + return filterByReaction(reaction, reactEmoji); + })?.count ?? 0; return { id: post.id, messageCount: post.messageCount ?? 0, @@ -31,7 +36,16 @@ export const convertPosts = async ( }) ); - return settled.filter((post): post is Post => post !== null); + return settled.flatMap((result) => { + if (result.status === 'rejected') { + if (result.reason instanceof ShowcaseDigestValidationError) { + result.reason.logError(); + return []; + } + throw result.reason; + } + return [result.value]; + }); }; export const filterPostsByCreationTime = ( From 714ed492f8cd44b4a2879fed776c05876738028d Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 20:14:13 +0100 Subject: [PATCH 20/29] fix: spelling, error handling fixes, digest cron simplified --- src/features/showcase-digest/config.ts | 11 +---- src/features/showcase-digest/handlers.ts | 16 +++---- src/features/showcase-digest/triggers.ts | 55 +++++++++--------------- src/features/showcase-digest/util.ts | 8 +++- 4 files changed, 36 insertions(+), 54 deletions(-) diff --git a/src/features/showcase-digest/config.ts b/src/features/showcase-digest/config.ts index beefa99..6f12554 100644 --- a/src/features/showcase-digest/config.ts +++ b/src/features/showcase-digest/config.ts @@ -1,9 +1,2 @@ -export const LOOKBACK_N: number = (() => { - const DEFAULT_N = 14; - return DEFAULT_N; -})(); - -export const REACT_EMOJI = (() => { - const DEFAULT_EMOJI: string = '%F0%9F%91%8D'; - return DEFAULT_EMOJI; -})(); +export const LOOKBACK_N: number = 14; +export const REACT_EMOJI = '%F0%9F%91%8D'; diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 95cd983..857f2aa 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -60,11 +60,6 @@ function createContainer(posts: Post[]): ContainerBuilder { return container; } -/** - * Builds and posts the showcase digest to the digest channel without an - * interaction, so it can be driven by the scheduler. Relies on channels already - * resolved into SERVER_CHANNELS at ready. - */ export const createShowcaseDigest = async (): Promise => { const digestChannel = SERVER_CHANNELS.showcaseDigest; const forumChannel = SERVER_CHANNELS.showcase; @@ -117,13 +112,18 @@ export const digestHandler = async ( flags: MessageFlags.Ephemeral, content: error.getUserMessage(), }); - return; + } else { + console.error('An unexpected error caught:', error); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: 'Something went wrong, please try again.', + }); } - throw error; + return; } await interaction.reply({ flags: MessageFlags.Ephemeral, - content: 'Successfully create a showcase digest!', + content: 'Successfully created a showcase digest!', }); }; diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index e411d88..6a8e78e 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -1,6 +1,5 @@ import * as cron from 'node-cron'; import { createSlashCommand } from '@/common/commands/create-commands.js'; -import { WEEK } from '@/constants/time.js'; import { LOOKBACK_N } from './config.js'; import { digestHandler, createShowcaseDigest } from './handlers.js'; import { ShowcaseDigestValidationError } from './util.js'; @@ -13,46 +12,32 @@ export const createDigestCommand = createSlashCommand({ execute: digestHandler, }); -// https://github.com/node-cron/node-cron?tab=readme-ov-file#cron-syntax -const SHOWCASE_DIGEST_CRON = '0 16 * * 5'; - -// Number of weeks between runs, derived from the lookback window. node-cron -// can't express "every N days" directly, so we fire weekly and skip the weeks -// that don't line up with the interval. -const INTERVAL_WEEKS = Math.max(1, Math.round(LOOKBACK_N / 7)); - -const currentWeekIndex = (): number => Math.floor(Date.now() / WEEK); - -const isScheduledWeek = (): boolean => { - return currentWeekIndex() % INTERVAL_WEEKS === 0; +// Fire on the 1st and 3rd Friday of every month at 16:00 UTC using node-cron's +// nth-weekday syntax (5#1 = first Friday, 5#3 = third Friday). +// https://www.nodecron.com/cron-syntax#nth-weekday +const SHOWCASE_DIGEST_CRONS = ['0 16 * * 5#1', '0 16 * * 5#3']; + +const runScheduledShowcaseDigest = (): void => { + console.log('Running scheduled showcase digest...'); + createShowcaseDigest() + .then(() => console.log(`Successfully displayed showcase digest!`)) + .catch((error) => { + if (error instanceof ShowcaseDigestValidationError) { + error.logError(); // No output to give to the user. + } else { + console.error('Something unexpected happened: ', error); + } + }); }; export function initializeShowcaseDigestScheduler(): void { console.log('Initializing showcase digest scheduler...'); - cron.schedule( - SHOWCASE_DIGEST_CRON, - () => { - if (!isScheduledWeek()) { - console.log( - `Skipping showcase digest this week (runs every ${INTERVAL_WEEKS} week(s))` - ); - return; - } - console.log('Running scheduled showcase digest...'); - createShowcaseDigest() - .then(() => console.log(`Successfully displayed showcase digest!`)) - .catch((error) => { - if (error instanceof ShowcaseDigestValidationError) { - error.logError(); // No output to give to the user. - console.error('Failed to run showcase digest!'); - } - }); - }, - { timezone: 'UTC' } - ); + SHOWCASE_DIGEST_CRONS.forEach((expression) => { + cron.schedule(expression, runScheduledShowcaseDigest, { timezone: 'UTC' }); + }); console.log( - `Showcase digest scheduler initialized (runs Fridays at 16:00 UTC, every ${INTERVAL_WEEKS} week(s))` + 'Showcase digest scheduler initialized (runs 1st and 3rd Fridays at 16:00 UTC)' ); } diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index b7526f3..c148a13 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -40,9 +40,13 @@ export const convertPosts = async ( if (result.status === 'rejected') { if (result.reason instanceof ShowcaseDigestValidationError) { result.reason.logError(); - return []; + } else { + console.error( + 'Unexpected error in scheduled showcase digest:', + result.reason + ); } - throw result.reason; + return []; } return [result.value]; }); From 6320ad2eb31416faf62c9796a564f1aa89496304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Skraburski?= Date: Sun, 18 Oct 2026 20:14:57 +0100 Subject: [PATCH 21/29] fix: update sortByDescPost to byLikesDescending Co-authored-by: Wiktoria van Harneveldt <62669899+wiktoriavh@users.noreply.github.com> --- src/features/showcase-digest/handlers.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 857f2aa..9150a70 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -24,9 +24,10 @@ import { const postProcessor = async (posts: Collection) => { const postsUnordered: Post[] = await convertPosts(posts, REACT_EMOJI); - const sortByDescPost = (postA: Post, postB: Post) => - postB.likes - postA.likes; - return postsUnordered.sort(sortByDescPost); + const byLikesDescending= (postA: Post, postB: Post) => { + return postB.likes - postA.likes; + }; + return postsUnordered.sort(byLikesDescending); }; function createContainer(posts: Post[]): ContainerBuilder { From 5a1a243129b167a2841b3d1bee7851f006cc22f0 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 20:16:34 +0100 Subject: [PATCH 22/29] fix: formatting on byLikesDescending --- src/features/showcase-digest/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 9150a70..8c8a308 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -24,7 +24,7 @@ import { const postProcessor = async (posts: Collection) => { const postsUnordered: Post[] = await convertPosts(posts, REACT_EMOJI); - const byLikesDescending= (postA: Post, postB: Post) => { + const byLikesDescending = (postA: Post, postB: Post) => { return postB.likes - postA.likes; }; return postsUnordered.sort(byLikesDescending); From 5b64411f171c4942aab9fc5a47190d176ff99ea2 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 20:19:07 +0100 Subject: [PATCH 23/29] fix: stale test from rejection handling --- src/features/showcase-digest/util.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts index fff733e..6372459 100644 --- a/src/features/showcase-digest/util.test.ts +++ b/src/features/showcase-digest/util.test.ts @@ -108,7 +108,7 @@ void describe('showcase-digest/util', () => { ); }); - void it('propagates a non-validation error from starter message fetch', async () => { + void it('drops posts whose starter message fetch rejects', async () => { const posts = collectionOf([ mockPost({ id: 'ok', @@ -117,9 +117,12 @@ void describe('showcase-digest/util', () => { mockPost({ id: 'boom', starterRejects: true }), ]); - await assert.rejects(() => convertPosts(posts, REACT_IDENTIFIER_A), { - message: 'fetch failed', - }); + const result = await convertPosts(posts, REACT_IDENTIFIER_A); + + assert.deepEqual( + result.map((post) => post.id), + ['ok'] + ); }); void it('defaults a missing messageCount to 0', async () => { From 2fec8f3e38f4de86677eac931fc41c0a04b80103 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 20:34:43 +0100 Subject: [PATCH 24/29] fix: readd diff line from showcase accident --- src/features/showcase/create-showcase.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/showcase/create-showcase.ts b/src/features/showcase/create-showcase.ts index c6133d8..108edc4 100644 --- a/src/features/showcase/create-showcase.ts +++ b/src/features/showcase/create-showcase.ts @@ -85,6 +85,7 @@ const modalHandler: ModalSubmitInteraction = { allowedMentions: { users: [interaction.user.id] }, }, }); + await thread.send({ flags: MessageFlags.IsComponentsV2, components: [ From 5190b38c8df80630bc1c431eae3b301252857283 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 21:17:43 +0100 Subject: [PATCH 25/29] fix: remove promise.then, simplify error class --- src/features/showcase-digest/handlers.ts | 6 ++--- src/features/showcase-digest/triggers.ts | 17 ++++++------- src/features/showcase-digest/util.ts | 32 +++--------------------- 3 files changed, 12 insertions(+), 43 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index 8c8a308..d1c8179 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -18,7 +18,6 @@ import { convertPosts, filterPostsByCreationTime, type Post, - LEVEL, ShowcaseDigestValidationError, } from './util.js'; @@ -34,8 +33,7 @@ function createContainer(posts: Post[]): ContainerBuilder { if (posts.length == 0) { throw new ShowcaseDigestValidationError( 'Something went wrong. Please try again later.', - 'createContainer passed posts[] with length 0. Impossible scenario.', - LEVEL.SEVERE + 'createContainer passed posts[] with length 0. Impossible scenario.' ); } @@ -108,7 +106,7 @@ export const digestHandler = async ( await createShowcaseDigest(); } catch (error: unknown) { if (error instanceof ShowcaseDigestValidationError) { - error.logError(); + console.error(error.message); await interaction.reply({ flags: MessageFlags.Ephemeral, content: error.getUserMessage(), diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index 6a8e78e..8b903f2 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -17,17 +17,14 @@ export const createDigestCommand = createSlashCommand({ // https://www.nodecron.com/cron-syntax#nth-weekday const SHOWCASE_DIGEST_CRONS = ['0 16 * * 5#1', '0 16 * * 5#3']; -const runScheduledShowcaseDigest = (): void => { +const runScheduledShowcaseDigest = async (): Promise => { console.log('Running scheduled showcase digest...'); - createShowcaseDigest() - .then(() => console.log(`Successfully displayed showcase digest!`)) - .catch((error) => { - if (error instanceof ShowcaseDigestValidationError) { - error.logError(); // No output to give to the user. - } else { - console.error('Something unexpected happened: ', error); - } - }); + try { + await createShowcaseDigest(); + console.log(`Successfully displayed showcase digest!`); + } catch (error) { + console.error(error); + } }; export function initializeShowcaseDigestScheduler(): void { diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index c148a13..58d1db3 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -20,8 +20,7 @@ export const convertPosts = async ( if (!firstMessage) { throw new ShowcaseDigestValidationError( 'Something went wrong, please try again later.', - 'Failed to get starter message under showcase-digest', - LEVEL.WARN + 'Failed to get starter message under showcase-digest' ); } const likes = @@ -39,7 +38,7 @@ export const convertPosts = async ( return settled.flatMap((result) => { if (result.status === 'rejected') { if (result.reason instanceof ShowcaseDigestValidationError) { - result.reason.logError(); + console.error(result.reason.message); } else { console.error( 'Unexpected error in scheduled showcase digest:', @@ -67,37 +66,12 @@ export const filterPostsByCreationTime = ( }); }; -export enum LEVEL { - SEVERE, - WARN, - INFO, -} export class ShowcaseDigestValidationError extends Error { userMessage: string; - level?: LEVEL; - constructor(userMessage: string, logError?: string, level?: LEVEL) { + constructor(userMessage: string, logError?: string) { super(logError); this.userMessage = userMessage; this.name = 'ShowcaseDigestValidationError'; - this.level = level; - } - public logError() { - const logMessage = this.message; - if (logMessage === '') { - return; - } - switch (this.level) { - case LEVEL.WARN: - console.warn(logMessage); - break; - case LEVEL.INFO: - console.log(logMessage); - break; - default: - case LEVEL.SEVERE: - console.error(logMessage); - break; - } } public getUserMessage(): string { return this.userMessage; From 8ccd50009d3f0134f1d6dff60b1b59cf59e9ba63 Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 21:21:43 +0100 Subject: [PATCH 26/29] fix: add digest .env vars to production --- .env.production | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.production b/.env.production index d729609..bf2c30f 100644 --- a/.env.production +++ b/.env.production @@ -14,13 +14,14 @@ SHOWCASE_LOG_CHANNEL_ID=1517565847982444634 SHOWCASE_RULES_CHANNEL_ID=1517948527098073158 SPAM_DETECTION_CHANNEL_ID=973895712314110032 ARCHIVE_CATEGORY_ID=837507969859977258 - +SHOWCASE_DIGEST_CHANNEL_ID=1561474075745059017 # Role IDs (from your dev server) REPEL_ROLE_ID=1002411741776461844 MODERATORS_ROLE_IDS=849481536654803004 REGULAR_ROLE_ID=1383170370634514544 TAG_ACCESS_ROLE_ID=1502128862224711805 +DIGEST_ROLE_ID=1561474157479207032 # Other GUIDES_TRACKER_PATH=/app/data/guides-tracker.json From d4c41b72ca2cad6ac64dd2b757952025be324cef Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 21:24:22 +0100 Subject: [PATCH 27/29] fix: remove unused import --- src/features/showcase-digest/triggers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts index 8b903f2..e65f9d8 100644 --- a/src/features/showcase-digest/triggers.ts +++ b/src/features/showcase-digest/triggers.ts @@ -2,7 +2,6 @@ import * as cron from 'node-cron'; import { createSlashCommand } from '@/common/commands/create-commands.js'; import { LOOKBACK_N } from './config.js'; import { digestHandler, createShowcaseDigest } from './handlers.js'; -import { ShowcaseDigestValidationError } from './util.js'; export const createDigestCommand = createSlashCommand({ data: { From 951d020f255e48e24a904caaea6221d010203d1e Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 21:27:14 +0100 Subject: [PATCH 28/29] fix: rename showcase error vars to useful names --- src/features/showcase-digest/util.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index 58d1db3..af72516 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -68,9 +68,9 @@ export const filterPostsByCreationTime = ( export class ShowcaseDigestValidationError extends Error { userMessage: string; - constructor(userMessage: string, logError?: string) { - super(logError); - this.userMessage = userMessage; + constructor(external: string, internal?: string) { + super(internal); + this.userMessage = external; this.name = 'ShowcaseDigestValidationError'; } public getUserMessage(): string { From e74dd04b0485f10acb6096957852f77f3848cd8c Mon Sep 17 00:00:00 2001 From: michal-skraburski Date: Sun, 18 Oct 2026 21:31:22 +0100 Subject: [PATCH 29/29] fix: update showcase error to be more verbose --- src/features/showcase-digest/handlers.ts | 3 ++- src/features/showcase-digest/util.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/features/showcase-digest/handlers.ts b/src/features/showcase-digest/handlers.ts index d1c8179..19b1207 100644 --- a/src/features/showcase-digest/handlers.ts +++ b/src/features/showcase-digest/handlers.ts @@ -69,6 +69,7 @@ export const createShowcaseDigest = async (): Promise => { if (posts.size === 0) { throw new ShowcaseDigestValidationError( + 'There was no posts to be displayed in Showcase Digest.', 'There was no posts to display in this digest.' ); } @@ -109,7 +110,7 @@ export const digestHandler = async ( console.error(error.message); await interaction.reply({ flags: MessageFlags.Ephemeral, - content: error.getUserMessage(), + content: error.getExternalMessage(), }); } else { console.error('An unexpected error caught:', error); diff --git a/src/features/showcase-digest/util.ts b/src/features/showcase-digest/util.ts index af72516..e7d2195 100644 --- a/src/features/showcase-digest/util.ts +++ b/src/features/showcase-digest/util.ts @@ -67,13 +67,13 @@ export const filterPostsByCreationTime = ( }; export class ShowcaseDigestValidationError extends Error { - userMessage: string; - constructor(external: string, internal?: string) { - super(internal); - this.userMessage = external; + externalMessage: string; + constructor(internalMessage: string, externalMessage: string) { + super(internalMessage); + this.externalMessage = externalMessage; this.name = 'ShowcaseDigestValidationError'; } - public getUserMessage(): string { - return this.userMessage; + public getExternalMessage(): string { + return this.externalMessage; } }