diff --git a/.env.example b/.env.example index 2cb8ae6..627b257 100644 --- a/.env.example +++ b/.env.example @@ -12,12 +12,14 @@ 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 @@ -32,6 +34,3 @@ ADVENT_OF_CODE_TRACKER_PATH=advent-of-code-tracker.json DATABASE_URL=file:./data/webdev.db - - - 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 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 diff --git a/src/common/commands/index.ts b/src/common/commands/index.ts index 0ec093a..f1f54ad 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/triggers.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..5c86bbc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -35,6 +35,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'), @@ -43,6 +44,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/ready/index.ts b/src/features/ready/index.ts index f7eef82..0475cf9 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'; @@ -58,13 +59,18 @@ 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); } + 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-digest/config.ts b/src/features/showcase-digest/config.ts new file mode 100644 index 0000000..6f12554 --- /dev/null +++ b/src/features/showcase-digest/config.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..19b1207 --- /dev/null +++ b/src/features/showcase-digest/handlers.ts @@ -0,0 +1,129 @@ +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, + ChatInputCommandInteraction, + Collection, + MessageFlags, + ContainerBuilder, + TextDisplayBuilder, + ForumThreadChannel, + channelMention, + roleMention, +} from 'discord.js'; +import { + convertPosts, + filterPostsByCreationTime, + type Post, + ShowcaseDigestValidationError, +} from './util.js'; + +const postProcessor = async (posts: Collection) => { + const postsUnordered: Post[] = await convertPosts(posts, REACT_EMOJI); + const byLikesDescending = (postA: Post, postB: Post) => { + return postB.likes - postA.likes; + }; + return postsUnordered.sort(byLikesDescending); +}; + +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.' + ); + } + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent('## Showcase Digest') + ); + + posts.forEach((post, index) => { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `${index + 1}. ${channelMention(post.id)} (Comments: ${post.messageCount})` + ) + ); + }); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `-# ${roleMention(config.roleIds.digestRole)}` + ) + ); + + return container; +} + +export const createShowcaseDigest = async (): Promise => { + const digestChannel = SERVER_CHANNELS.showcaseDigest; + const forumChannel = SERVER_CHANNELS.showcase; + const posts = filterPostsByCreationTime( + forumChannel.threads.cache, + LOOKBACK_N + ); + + 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.' + ); + } + + const postsOrdered: Post[] = await postProcessor(posts); + const container = createContainer(postsOrdered); + + await digestChannel.send({ + flags: MessageFlags.IsComponentsV2, + components: [container], + allowedMentions: { roles: [config.roleIds.digestRole] }, + }); +}; + +export const digestHandler = async ( + interaction: ButtonInteraction | ChatInputCommandInteraction +) => { + if (!interaction.inGuild() || !interaction.guild) { + await interaction.reply({ + content: 'This command can only be run in the web development discord.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!isModerator(getCommandUser(interaction))) { + await interaction.reply({ + content: 'Only moderators can run the showcase digest.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + await createShowcaseDigest(); + } catch (error: unknown) { + if (error instanceof ShowcaseDigestValidationError) { + console.error(error.message); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: error.getExternalMessage(), + }); + } else { + console.error('An unexpected error caught:', error); + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: 'Something went wrong, please try again.', + }); + } + return; + } + + await interaction.reply({ + flags: MessageFlags.Ephemeral, + content: 'Successfully created a showcase digest!', + }); +}; diff --git a/src/features/showcase-digest/triggers.ts b/src/features/showcase-digest/triggers.ts new file mode 100644 index 0000000..e65f9d8 --- /dev/null +++ b/src/features/showcase-digest/triggers.ts @@ -0,0 +1,39 @@ +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'; + +export const createDigestCommand = createSlashCommand({ + data: { + name: 'digest-force', + description: `Forcefully consumes posts of the last ${LOOKBACK_N} days`, + }, + execute: digestHandler, +}); + +// 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 = async (): Promise => { + console.log('Running scheduled showcase digest...'); + try { + await createShowcaseDigest(); + console.log(`Successfully displayed showcase digest!`); + } catch (error) { + console.error(error); + } +}; + +export function initializeShowcaseDigestScheduler(): void { + console.log('Initializing showcase digest scheduler...'); + + SHOWCASE_DIGEST_CRONS.forEach((expression) => { + cron.schedule(expression, runScheduledShowcaseDigest, { timezone: 'UTC' }); + }); + + console.log( + 'Showcase digest scheduler initialized (runs 1st and 3rd Fridays at 16:00 UTC)' + ); +} diff --git a/src/features/showcase-digest/util.test.ts b/src/features/showcase-digest/util.test.ts new file mode 100644 index 0000000..6372459 --- /dev/null +++ b/src/features/showcase-digest/util.test.ts @@ -0,0 +1,174 @@ +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'; + +// 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_A = '%F0%9F%98%AD'; +const REACT_IDENTIFIER_B = '%F0%9F%91%8D'; + +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) => { + return 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_IDENTIFIER_A, count: 3 }, + { identifier: REACT_IDENTIFIER_B, count: 99 }, + ], + }), + ]); + + const result = await convertPosts(posts, REACT_IDENTIFIER_A); + + 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: REACT_IDENTIFIER_B, count: 9 }], + }), + ]); + + const [post] = await convertPosts(posts, REACT_IDENTIFIER_A); + + assert.equal(post.likes, 0); + }); + + void it('drops posts whose starter message is missing', async () => { + const posts = collectionOf([ + mockPost({ + id: 'ok', + reactions: [{ identifier: REACT_IDENTIFIER_A, count: 1 }], + }), + mockPost({ id: 'gone', noStarter: true }), + ]); + + const result = await convertPosts(posts, REACT_IDENTIFIER_A); + + 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_IDENTIFIER_A, count: 1 }], + }), + mockPost({ id: 'boom', starterRejects: true }), + ]); + + 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 () => { + const posts = collectionOf([ + mockPost({ id: 'a', messageCount: undefined }), + ]); + + const [post] = await convertPosts(posts, REACT_IDENTIFIER_A); + + 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..e7d2195 --- /dev/null +++ b/src/features/showcase-digest/util.ts @@ -0,0 +1,79 @@ +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 => { + return reaction.emoji.identifier === reactEmoji; +}; + +export const convertPosts = async ( + posts: Collection, + reactEmoji: string +): Promise => { + const settled = await Promise.allSettled( + posts.map(async (post): Promise => { + const firstMessage = await post.fetchStarterMessage(); + if (!firstMessage) { + throw new ShowcaseDigestValidationError( + 'Something went wrong, please try again later.', + 'Failed to get starter message under showcase-digest' + ); + } + const likes = + firstMessage.reactions.cache.find((reaction) => { + return filterByReaction(reaction, reactEmoji); + })?.count ?? 0; + return { + id: post.id, + messageCount: post.messageCount ?? 0, + likes, + }; + }) + ); + + return settled.flatMap((result) => { + if (result.status === 'rejected') { + if (result.reason instanceof ShowcaseDigestValidationError) { + console.error(result.reason.message); + } else { + console.error( + 'Unexpected error in scheduled showcase digest:', + result.reason + ); + } + return []; + } + return [result.value]; + }); +}; + +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; + }); +}; + +export class ShowcaseDigestValidationError extends Error { + externalMessage: string; + constructor(internalMessage: string, externalMessage: string) { + super(internalMessage); + this.externalMessage = externalMessage; + this.name = 'ShowcaseDigestValidationError'; + } + public getExternalMessage(): string { + return this.externalMessage; + } +}