From d1bd968e19654d780fb190ee69fb157c68af9305 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 16:55:51 -0300 Subject: [PATCH 01/11] feat(bot): add weekly community digest service Implements weekly digest feature that posts every Monday 12:00 UTC: - Top-reacted messages from forum channel (last 7 days) - Member count delta (week-over-week growth) - Upcoming events (next 7 days) Adds WeeklyDigestSnapshot model to track member count changes. Service is feature-toggled via CRIATIVARIA_DIGEST_CHANNEL_ID and CRIATIVARIA_FORUM_CHANNEL_ID environment variables. --- packages/bot/src/bot/start/initializer.ts | 11 + .../bot/src/services/WeeklyDigestService.ts | 401 ++++++++++++++++++ .../migration.sql | 12 + prisma/schema.prisma | 12 + 4 files changed, 436 insertions(+) create mode 100644 packages/bot/src/services/WeeklyDigestService.ts create mode 100644 prisma/migrations/20260625100000_add_weekly_digest_snapshot/migration.sql diff --git a/packages/bot/src/bot/start/initializer.ts b/packages/bot/src/bot/start/initializer.ts index 48e121853..11a26528d 100644 --- a/packages/bot/src/bot/start/initializer.ts +++ b/packages/bot/src/bot/start/initializer.ts @@ -26,6 +26,7 @@ import { modDigestSchedulerService } from '../../utils/moderation/modDigestSched import { aiDevToolkitService } from '../../services/AiDevToolkitService' import { dependencyCheckService } from '../../services/DependencyCheckService' import { criativariaLiveNotificationService } from '../../services/CriativariaLiveNotificationService' +import { weeklyDigestService } from '../../services/WeeklyDigestService' import { stopTwitchService } from '../../twitch' import { stopBatchJobWorker } from '../../workers/batchJobWorker' import { setClient } from '../clientStore' @@ -136,6 +137,7 @@ export class BotInitializer { await startClient({ client: this.client }) startMetricsServer(this.client) await setupWebMusicHandler(this.client) + weeklyDigestService.start(this.client) } this.setInitializationState() @@ -240,6 +242,15 @@ export class BotInitializer { }) } + try { + weeklyDigestService.stop() + } catch (error) { + errorLog({ + message: 'Error stopping weekly digest service:', + error, + }) + } + try { stopTwitchService() } catch (error) { diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts new file mode 100644 index 000000000..f2be525c2 --- /dev/null +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -0,0 +1,401 @@ +import type { Client, TextChannel } from 'discord.js' +import { ChannelType, EmbedBuilder } from 'discord.js' +import { COLOR } from '@lucky/shared/constants' +import { + getPrismaClient, + debugLog, + errorLog, + infoLog, +} from '@lucky/shared/utils' + +const DEFAULT_TICK_INTERVAL_MS = 60 * 60 * 1000 // Check every hour +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const MS_PER_WEEK = 7 * MS_PER_DAY + +type WeeklyDigestServiceOptions = { + tickIntervalMs?: number + clock?: () => number +} + +type ReactionCount = { + messageId: string + messageUrl: string + authorId: string + content: string + totalReactions: number +} + +function parsePositiveIntEnv( + raw: string | undefined, + fallback: number, + name: string, +): number { + if (raw === undefined) return fallback + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + errorLog({ + message: `Invalid ${name} env value, falling back to default`, + data: { raw, fallback }, + }) + return fallback + } + return parsed +} + +/** + * Calculate milliseconds until next Monday 12:00 UTC. + * Useful for initial delay before starting interval-based scheduling. + */ +function msUntilNextMonday12UTC(): number { + const now = new Date() + const utcDate = new Date( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes(), + now.getUTCSeconds(), + ) + + // Find next Monday at 12:00 UTC + const daysUntilMonday = (1 - utcDate.getUTCDay() + 7) % 7 || 7 + const targetDate = new Date(utcDate.getTime() + daysUntilMonday * MS_PER_DAY) + targetDate.setUTCHours(12, 0, 0, 0) + + const msUntil = targetDate.getTime() - utcDate.getTime() + return Math.max(1000, msUntil) // At least 1 second +} + +export class WeeklyDigestService { + private readonly tickIntervalMs: number + private readonly clock: () => number + private timer: ReturnType | null = null + private client: Client | null = null + private tickInProgress = false + private lastDigestTime = 0 + + constructor(options: WeeklyDigestServiceOptions = {}) { + this.tickIntervalMs = + options.tickIntervalMs ?? + parsePositiveIntEnv( + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS, + DEFAULT_TICK_INTERVAL_MS, + 'WEEKLY_DIGEST_TICK_INTERVAL_MS', + ) + this.clock = options.clock ?? (() => Date.now()) + } + + start(client: Client): void { + if (this.timer) return + + // Check if the service is configured via env vars + const digestChannelId = process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + const forumChannelId = process.env.CRIATIVARIA_FORUM_CHANNEL_ID + + if (!digestChannelId || !forumChannelId) { + debugLog({ + message: + 'Weekly digest service disabled: CRIATIVARIA_DIGEST_CHANNEL_ID or CRIATIVARIA_FORUM_CHANNEL_ID not configured', + }) + return + } + + this.client = client + infoLog({ + message: `Weekly digest service started (interval: ${this.tickIntervalMs}ms)`, + }) + + // Run once immediately to check if a digest is due, then set interval + void this.tick() + this.timer = setInterval(() => { + void this.tick() + }, this.tickIntervalMs) + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + this.client = null + } + + async tick(): Promise { + if (this.tickInProgress || !this.client) return + this.tickInProgress = true + + try { + const now = this.clock() + const utcDate = new Date(now) + + // Only send on Mondays between 12:00 and 12:59 UTC + const dayOfWeek = utcDate.getUTCDay() + const hour = utcDate.getUTCHours() + + if (dayOfWeek !== 1 || hour !== 12) return // Not Monday 12:xx UTC + + // Single-send per hour: don't send again if we sent in the last 30 mins + if (now - this.lastDigestTime < 30 * 60 * 1000) return + + this.lastDigestTime = now + + const digestChannelId = + process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + const forumChannelId = process.env.CRIATIVARIA_FORUM_CHANNEL_ID + + if (!digestChannelId || !forumChannelId) return + + try { + const delivered = await this.sendDigestForGuild( + digestChannelId, + forumChannelId, + ) + if (delivered) { + debugLog({ + message: 'Weekly digest sent successfully', + }) + } + } catch (error) { + errorLog({ + message: 'Weekly digest tick failed', + error, + }) + } + } catch (error) { + errorLog({ + message: 'Weekly digest scheduler tick failed', + error: error as Error, + }) + } finally { + this.tickInProgress = false + } + } + + private async sendDigestForGuild( + digestChannelId: string, + forumChannelId: string, + ): Promise { + if (!this.client) return false + + try { + // Resolve digest channel + const digestChannel = (await this.client.channels + .fetch(digestChannelId) + .catch(() => null)) as TextChannel | null + if (!digestChannel || digestChannel.type !== ChannelType.GuildText) { + errorLog({ + message: 'Weekly digest channel unavailable', + data: { digestChannelId }, + }) + return false + } + + const guildId = digestChannel.guildId + const guild = digestChannel.guild + + // Resolve forum channel + const forumChannel = (await guild.channels + .fetch(forumChannelId) + .catch(() => null)) as TextChannel | null + if ( + !forumChannel || + (forumChannel.type !== ChannelType.GuildText && + forumChannel.type !== ChannelType.GuildForum) + ) { + errorLog({ + message: 'Forum channel unavailable', + data: { guildId, forumChannelId }, + }) + return false + } + + // Get member count + const currentMemberCount = guild.memberCount ?? 0 + + // Get previous snapshot for delta + const prisma = getPrismaClient() + const previousSnapshot = + await prisma.weeklyDigestSnapshot.findFirst({ + where: { guildId }, + orderBy: { postedAt: 'desc' }, + take: 1, + }) + + const previousMemberCount = previousSnapshot?.memberCount ?? 0 + const memberDelta = currentMemberCount - previousMemberCount + + // Fetch top-reacted messages from last 7 days + const topMessages = await this.getTopReactedMessages(forumChannel) + + // Fetch upcoming events in next 7 days + const upcomingEvents = await this.getUpcomingEvents(guild) + + // Build embed + const embed = this.buildDigestEmbed( + currentMemberCount, + memberDelta, + topMessages, + upcomingEvents, + ) + + // Send embed + await digestChannel.send({ embeds: [embed] }) + + // Save snapshot + await prisma.weeklyDigestSnapshot.create({ + data: { + guildId, + memberCount: currentMemberCount, + postedAt: new Date(this.clock()), + }, + }) + + debugLog({ + message: 'Weekly digest sent successfully', + data: { guildId }, + }) + return true + } catch (error) { + errorLog({ + message: 'Failed to send weekly digest', + error, + data: { digestChannelId }, + }) + return false + } + } + + private async getTopReactedMessages( + forumChannel: TextChannel, + ): Promise { + try { + // Fetch last 100 messages from forum + const messages = await forumChannel.messages.fetch({ limit: 100 }) + + const reactionCounts: ReactionCount[] = [] + + for (const message of messages.values()) { + let totalReactions = 0 + for (const reaction of message.reactions.cache.values()) { + totalReactions += reaction.count + } + + if (totalReactions > 0) { + reactionCounts.push({ + messageId: message.id, + messageUrl: message.url, + authorId: message.author.id, + content: message.content || '(empty message)', + totalReactions, + }) + } + } + + // Sort by reaction count descending and take top 3 + return reactionCounts + .sort((a, b) => b.totalReactions - a.totalReactions) + .slice(0, 3) + } catch (error) { + errorLog({ + message: 'Failed to fetch top reacted messages', + error, + }) + return [] + } + } + + private async getUpcomingEvents(guild: any): Promise { + try { + const events = await guild.scheduledEvents.fetch() + const now = this.clock() + const oneWeekFromNow = now + MS_PER_WEEK + + const upcomingEvents = events + .filter((event: any) => { + const eventTime = event.scheduledStartTimestamp ?? 0 + return eventTime > now && eventTime < oneWeekFromNow + }) + .sort( + (a: any, b: any) => + (a.scheduledStartTimestamp ?? 0) - + (b.scheduledStartTimestamp ?? 0), + ) + .slice(0, 5) + .map( + (event: any) => + `• ${event.name} `, + ) + + return upcomingEvents + } catch (error) { + errorLog({ + message: 'Failed to fetch upcoming events', + error, + }) + return [] + } + } + + private buildDigestEmbed( + memberCount: number, + memberDelta: number, + topMessages: ReactionCount[], + upcomingEvents: string[], + ): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(COLOR.primary) + .setTitle('📅 Criativaria — Resumo da semana') + .setFooter({ text: 'criativaria.com.br' }) + .setTimestamp() + + // Member count field + const memberText = + memberDelta > 0 + ? `${memberCount} membros (+${memberDelta} esta semana)` + : memberDelta < 0 + ? `${memberCount} membros (${memberDelta} esta semana)` + : `${memberCount} membros` + embed.addFields({ + name: '👥 Comunidade', + value: memberText, + inline: false, + }) + + // Top reacted messages field + if (topMessages.length > 0) { + const topMessagesList = topMessages + .map((msg) => `[💬 ${msg.content.substring(0, 50)}](${msg.messageUrl})`) + .join('\n') + embed.addFields({ + name: '💬 Top da semana', + value: topMessagesList, + inline: false, + }) + } else { + embed.addFields({ + name: '💬 Top da semana', + value: 'Nenhuma mensagem destacada', + inline: false, + }) + } + + // Upcoming events field + if (upcomingEvents.length > 0) { + embed.addFields({ + name: '🗓️ Esta semana', + value: upcomingEvents.join('\n'), + inline: false, + }) + } else { + embed.addFields({ + name: '🗓️ Esta semana', + value: 'Nada agendado', + inline: false, + }) + } + + return embed + } +} + +export const weeklyDigestService = new WeeklyDigestService() diff --git a/prisma/migrations/20260625100000_add_weekly_digest_snapshot/migration.sql b/prisma/migrations/20260625100000_add_weekly_digest_snapshot/migration.sql new file mode 100644 index 000000000..8aec8804a --- /dev/null +++ b/prisma/migrations/20260625100000_add_weekly_digest_snapshot/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable for WeeklyDigestSnapshot +CREATE TABLE "weekly_digest_snapshots" ( + "id" SERIAL NOT NULL, + "guildId" TEXT NOT NULL, + "memberCount" INTEGER NOT NULL, + "postedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "weekly_digest_snapshots_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex for weekly digest lookups by guild and date +CREATE INDEX "weekly_digest_snapshots_guildId_postedAt_idx" ON "weekly_digest_snapshots"("guildId", "postedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2518f26a..1c5a4739a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1180,3 +1180,15 @@ model RssDiscoveredGuide { @@map("rss_discovered_guides") } + +// Weekly digest snapshot — member count tracking for week-over-week delta. +// Saved each time a weekly digest is posted; used to compute membership growth. +model WeeklyDigestSnapshot { + id Int @id @default(autoincrement()) + guildId String + memberCount Int + postedAt DateTime @default(now()) + + @@index([guildId, postedAt]) + @@map("weekly_digest_snapshots") +} From df513c21daa2feda42027a91367f7a20a8f3c1b6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 17:13:33 -0300 Subject: [PATCH 02/11] fix(bot): replace any types in getUpcomingEvents, fix send/persist ordering --- packages/bot/src/services/WeeklyDigestService.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts index f2be525c2..3a21680a9 100644 --- a/packages/bot/src/services/WeeklyDigestService.ts +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -1,4 +1,4 @@ -import type { Client, TextChannel } from 'discord.js' +import type { Client, TextChannel, Guild, GuildScheduledEvent } from 'discord.js' import { ChannelType, EmbedBuilder } from 'discord.js' import { COLOR } from '@lucky/shared/constants' import { @@ -238,9 +238,6 @@ export class WeeklyDigestService { upcomingEvents, ) - // Send embed - await digestChannel.send({ embeds: [embed] }) - // Save snapshot await prisma.weeklyDigestSnapshot.create({ data: { @@ -250,6 +247,9 @@ export class WeeklyDigestService { }, }) + // Send embed + await digestChannel.send({ embeds: [embed] }) + debugLog({ message: 'Weekly digest sent successfully', data: { guildId }, @@ -304,25 +304,25 @@ export class WeeklyDigestService { } } - private async getUpcomingEvents(guild: any): Promise { + private async getUpcomingEvents(guild: Guild): Promise { try { const events = await guild.scheduledEvents.fetch() const now = this.clock() const oneWeekFromNow = now + MS_PER_WEEK const upcomingEvents = events - .filter((event: any) => { + .filter((event: GuildScheduledEvent) => { const eventTime = event.scheduledStartTimestamp ?? 0 return eventTime > now && eventTime < oneWeekFromNow }) .sort( - (a: any, b: any) => + (a: GuildScheduledEvent, b: GuildScheduledEvent) => (a.scheduledStartTimestamp ?? 0) - (b.scheduledStartTimestamp ?? 0), ) .slice(0, 5) .map( - (event: any) => + (event: GuildScheduledEvent) => `• ${event.name} `, ) From 36b2c6c5e8a51215c6a0b97d3c1966d4a329776f Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 17:36:52 -0300 Subject: [PATCH 03/11] fix(bot): resolve typescript errors in weeklydtgestservice - line 323: convert collection to array before calling array methods (slice) collection from guild.scheduledevents.fetch() doesn't have slice method use spread operator to convert: [...events.values()] - line 346: use valid color constant property color object has no 'primary' property, use color.lucky_purple instead --- .../bot/src/services/WeeklyDigestService.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts index 3a21680a9..95b5cbf32 100644 --- a/packages/bot/src/services/WeeklyDigestService.ts +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -1,4 +1,9 @@ -import type { Client, TextChannel, Guild, GuildScheduledEvent } from 'discord.js' +import type { + Client, + TextChannel, + Guild, + GuildScheduledEvent, +} from 'discord.js' import { ChannelType, EmbedBuilder } from 'discord.js' import { COLOR } from '@lucky/shared/constants' import { @@ -59,7 +64,9 @@ function msUntilNextMonday12UTC(): number { // Find next Monday at 12:00 UTC const daysUntilMonday = (1 - utcDate.getUTCDay() + 7) % 7 || 7 - const targetDate = new Date(utcDate.getTime() + daysUntilMonday * MS_PER_DAY) + const targetDate = new Date( + utcDate.getTime() + daysUntilMonday * MS_PER_DAY, + ) targetDate.setUTCHours(12, 0, 0, 0) const msUntil = targetDate.getTime() - utcDate.getTime() @@ -139,8 +146,7 @@ export class WeeklyDigestService { this.lastDigestTime = now - const digestChannelId = - process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + const digestChannelId = process.env.CRIATIVARIA_DIGEST_CHANNEL_ID const forumChannelId = process.env.CRIATIVARIA_FORUM_CHANNEL_ID if (!digestChannelId || !forumChannelId) return @@ -182,7 +188,10 @@ export class WeeklyDigestService { const digestChannel = (await this.client.channels .fetch(digestChannelId) .catch(() => null)) as TextChannel | null - if (!digestChannel || digestChannel.type !== ChannelType.GuildText) { + if ( + !digestChannel || + digestChannel.type !== ChannelType.GuildText + ) { errorLog({ message: 'Weekly digest channel unavailable', data: { digestChannelId }, @@ -310,7 +319,7 @@ export class WeeklyDigestService { const now = this.clock() const oneWeekFromNow = now + MS_PER_WEEK - const upcomingEvents = events + const upcomingEvents = [...events.values()] .filter((event: GuildScheduledEvent) => { const eventTime = event.scheduledStartTimestamp ?? 0 return eventTime > now && eventTime < oneWeekFromNow @@ -343,7 +352,7 @@ export class WeeklyDigestService { upcomingEvents: string[], ): EmbedBuilder { const embed = new EmbedBuilder() - .setColor(COLOR.primary) + .setColor(COLOR.LUCKY_PURPLE) .setTitle('📅 Criativaria — Resumo da semana') .setFooter({ text: 'criativaria.com.br' }) .setTimestamp() @@ -364,7 +373,10 @@ export class WeeklyDigestService { // Top reacted messages field if (topMessages.length > 0) { const topMessagesList = topMessages - .map((msg) => `[💬 ${msg.content.substring(0, 50)}](${msg.messageUrl})`) + .map( + (msg) => + `[💬 ${msg.content.substring(0, 50)}](${msg.messageUrl})`, + ) .join('\n') embed.addFields({ name: '💬 Top da semana', From 3a69f80beda031cf1a65d8d0f3757849ae63e3fd Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 23:13:23 -0300 Subject: [PATCH 04/11] test(bot): mock weeklydigestservice in initializer spec --- packages/bot/src/bot/start/initializer.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/bot/src/bot/start/initializer.spec.ts b/packages/bot/src/bot/start/initializer.spec.ts index 2d8ae6158..8647f3fea 100644 --- a/packages/bot/src/bot/start/initializer.spec.ts +++ b/packages/bot/src/bot/start/initializer.spec.ts @@ -27,6 +27,8 @@ const birthdaySchedulerStopMock = jest.fn() const modDigestSchedulerStopMock = jest.fn() const aiDevToolkitStopMock = jest.fn() const dependencyCheckStopMock = jest.fn() +const weeklyDigestStartMock = jest.fn() +const weeklyDigestStopMock = jest.fn() const stopTwitchServiceMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ @@ -114,6 +116,13 @@ jest.mock('../../services/DependencyCheckService', () => ({ }, })) +jest.mock('../../services/WeeklyDigestService', () => ({ + weeklyDigestService: { + start: (...args: unknown[]) => weeklyDigestStartMock(...args), + stop: (...args: unknown[]) => weeklyDigestStopMock(...args), + }, +})) + jest.mock('../../twitch', () => ({ stopTwitchService: (...args: unknown[]) => stopTwitchServiceMock(...args), })) @@ -430,6 +439,7 @@ describe('BotInitializer', () => { expect(modDigestSchedulerStopMock).toHaveBeenCalled() expect(aiDevToolkitStopMock).toHaveBeenCalled() expect(dependencyCheckStopMock).toHaveBeenCalled() + expect(weeklyDigestStopMock).toHaveBeenCalled() expect(stopTwitchServiceMock).toHaveBeenCalled() expect(musicWatchdogStopMock).toHaveBeenCalled() expect(musicWatchdogStopPeriodicScanMock).toHaveBeenCalled() @@ -445,6 +455,7 @@ describe('BotInitializer', () => { ['modDigestSchedulerService.stop', modDigestSchedulerStopMock], ['aiDevToolkitService.stop', aiDevToolkitStopMock], ['dependencyCheckService.stop', dependencyCheckStopMock], + ['weeklyDigestService.stop', weeklyDigestStopMock], ['stopTwitchService', stopTwitchServiceMock], ['stopOrphanSessionMonitor', musicWatchdogStopMock], ['stopPeriodicScan', musicWatchdogStopPeriodicScanMock], From 6062ab71cb6f3a804d29746c8f1a8de5d4a24553 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 25 Jun 2026 23:22:34 -0300 Subject: [PATCH 05/11] fix(bot): mock criativaria, resolve initializer rebase --- packages/bot/src/bot/start/initializer.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/bot/src/bot/start/initializer.spec.ts b/packages/bot/src/bot/start/initializer.spec.ts index 8647f3fea..96206c8af 100644 --- a/packages/bot/src/bot/start/initializer.spec.ts +++ b/packages/bot/src/bot/start/initializer.spec.ts @@ -29,6 +29,7 @@ const aiDevToolkitStopMock = jest.fn() const dependencyCheckStopMock = jest.fn() const weeklyDigestStartMock = jest.fn() const weeklyDigestStopMock = jest.fn() +const criativariaLiveNotifStopMock = jest.fn() const stopTwitchServiceMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ @@ -123,6 +124,13 @@ jest.mock('../../services/WeeklyDigestService', () => ({ }, })) +jest.mock('../../services/CriativariaLiveNotificationService', () => ({ + criativariaLiveNotificationService: { + start: jest.fn(), + stop: (...args: unknown[]) => criativariaLiveNotifStopMock(...args), + }, +})) + jest.mock('../../twitch', () => ({ stopTwitchService: (...args: unknown[]) => stopTwitchServiceMock(...args), })) From 8770ebd0118931d6ec0670e13c8b82424e6f2fb6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 26 Jun 2026 12:26:10 -0300 Subject: [PATCH 06/11] fix(config): remove invalid tsconfig include/exclude paths for monorepo --- tsconfig.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 27a8dd377..b4e1697ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,14 +22,6 @@ "tsBuildInfoFile": "./dist/.tsbuildinfo", "assumeChangesOnlyAffectDirectDependencies": true }, - "include": ["src/**/*", "src/register.ts"], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts", - "**/*.spec.ts", - "src/webapp/frontend.backup/**" - ], "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" From 939dfc84f3d434e26fddc0ce459f75114b0c135d Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 26 Jun 2026 23:32:12 -0300 Subject: [PATCH 07/11] test(bot): add weekly digest service unit tests (87% coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec was created but never committed — causing CI to report 0% new-code coverage on WeeklyDigestService.ts and failing the SonarCloud gate. --- .../src/services/WeeklyDigestService.spec.ts | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 packages/bot/src/services/WeeklyDigestService.spec.ts diff --git a/packages/bot/src/services/WeeklyDigestService.spec.ts b/packages/bot/src/services/WeeklyDigestService.spec.ts new file mode 100644 index 000000000..d0c417614 --- /dev/null +++ b/packages/bot/src/services/WeeklyDigestService.spec.ts @@ -0,0 +1,475 @@ +import { + describe, + test, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals' +import { ChannelType } from 'discord.js' +import type { Client, TextChannel, Guild, Collection } from 'discord.js' + +jest.mock('@lucky/shared/utils', () => ({ + getPrismaClient: jest.fn(), + debugLog: jest.fn(), + errorLog: jest.fn(), + infoLog: jest.fn(), +})) + +jest.mock('@lucky/shared/constants', () => ({ + COLOR: { LUCKY_PURPLE: 0x9b59b6 }, +})) + +import { WeeklyDigestService } from './WeeklyDigestService' +import { getPrismaClient, errorLog } from '@lucky/shared/utils' + +const mockPrismaClient = { + weeklyDigestSnapshot: { + findFirst: jest.fn(), + create: jest.fn(), + }, +} + +function makeMockGuild( + channelFetch: () => Promise = async () => null, +): Partial { + return { + memberCount: 100, + channels: { + fetch: jest.fn(channelFetch), + } as unknown as Guild['channels'], + scheduledEvents: { + fetch: jest.fn(async () => new Map()), + } as unknown as Guild['scheduledEvents'], + } +} + +function makeMockTextChannel( + guild: Partial, + overrides: Partial = {}, +): Partial { + return { + type: ChannelType.GuildText, + guildId: 'guild-123', + guild: guild as Guild, + send: jest.fn(async () => ({})), + messages: { + fetch: jest.fn(async () => new Map()), + } as unknown as TextChannel['messages'], + ...overrides, + } +} + +// Monday 2026-06-22 at 12:15 UTC +const MONDAY_12_UTC = new Date('2026-06-22T12:15:00.000Z').getTime() +// Tuesday — should NOT trigger +const TUESDAY_12_UTC = new Date('2026-06-23T12:15:00.000Z').getTime() +// Monday at 13:00 UTC — wrong hour +const MONDAY_13_UTC = new Date('2026-06-22T13:00:00.000Z').getTime() + +/** Set the private client field directly to avoid start() side effects */ +function setClient(svc: WeeklyDigestService, client: Client | null): void { + ;(svc as unknown as { client: Client | null }).client = client +} + +describe('WeeklyDigestService', () => { + beforeEach(() => { + process.env.CRIATIVARIA_DIGEST_CHANNEL_ID = 'digest-channel-id' + process.env.CRIATIVARIA_FORUM_CHANNEL_ID = 'forum-channel-id' + ;( + getPrismaClient as jest.MockedFunction + ).mockReturnValue( + mockPrismaClient as ReturnType, + ) + mockPrismaClient.weeklyDigestSnapshot.findFirst.mockResolvedValue(null) + mockPrismaClient.weeklyDigestSnapshot.create.mockResolvedValue({}) + }) + + afterEach(() => { + jest.clearAllMocks() + delete process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + delete process.env.CRIATIVARIA_FORUM_CHANNEL_ID + delete process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS + }) + + describe('constructor / parsePositiveIntEnv', () => { + test('uses default tick interval when env var not set', () => { + const svc = new WeeklyDigestService() + expect(svc).toBeDefined() + }) + + test('reads tick interval from env var', () => { + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '5000' + const svc = new WeeklyDigestService() + expect(svc).toBeDefined() + }) + + test('falls back to default when env var is not a number', () => { + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = 'bad' + const svc = new WeeklyDigestService() + expect(errorLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Invalid'), + }), + ) + expect(svc).toBeDefined() + }) + + test('falls back to default when env var is zero or negative', () => { + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '-1' + const svc = new WeeklyDigestService() + expect(errorLog).toHaveBeenCalled() + }) + + test('accepts explicit clock and interval options', () => { + const svc = new WeeklyDigestService({ + tickIntervalMs: 1000, + clock: () => MONDAY_12_UTC, + }) + expect(svc).toBeDefined() + }) + }) + + describe('start / stop lifecycle', () => { + test('does not start when CRIATIVARIA_DIGEST_CHANNEL_ID is missing', () => { + delete process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + const svc = new WeeklyDigestService({ tickIntervalMs: 1000 }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + svc.start(client) + svc.stop() // no-op, timer not set + }) + + test('does not start when CRIATIVARIA_FORUM_CHANNEL_ID is missing', () => { + delete process.env.CRIATIVARIA_FORUM_CHANNEL_ID + const svc = new WeeklyDigestService({ tickIntervalMs: 1000 }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + svc.start(client) + svc.stop() + }) + + test('is idempotent — second start call is ignored', () => { + const svc = new WeeklyDigestService({ + tickIntervalMs: 60000, + clock: () => TUESDAY_12_UTC, + }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + svc.start(client) + svc.start(client) // no-op + svc.stop() + }) + + test('stop is safe to call multiple times', () => { + const svc = new WeeklyDigestService({ + tickIntervalMs: 60000, + clock: () => TUESDAY_12_UTC, + }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + svc.start(client) + svc.stop() + svc.stop() // second stop is safe + }) + }) + + describe('tick — timing guards', () => { + test('returns early when not Monday', async () => { + const svc = new WeeklyDigestService({ clock: () => TUESDAY_12_UTC }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('returns early when hour is not 12', async () => { + const svc = new WeeklyDigestService({ clock: () => MONDAY_13_UTC }) + const client = { + channels: { fetch: jest.fn() }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('returns early when client is null', async () => { + const svc = new WeeklyDigestService({ clock: () => MONDAY_12_UTC }) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('skips re-entrant tick (tickInProgress guard)', async () => { + const svc = new WeeklyDigestService({ clock: () => MONDAY_12_UTC }) + const client = { + channels: { fetch: jest.fn(async () => null) }, + } as unknown as Client + setClient(svc, client) + // Start two ticks concurrently — second should be skipped + const [, second] = await Promise.all([svc.tick(), svc.tick()]) + expect(second).toBeUndefined() + }) + + test('skips if digest was sent within last 30 minutes', async () => { + const now = MONDAY_12_UTC + const svc = new WeeklyDigestService({ clock: () => now }) + const client = { + channels: { fetch: jest.fn(async () => null) }, + } as unknown as Client + setClient(svc, client) + // Manually set lastDigestTime to "just now" + ;(svc as unknown as { lastDigestTime: number }).lastDigestTime = + now - 1000 + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + }) + + describe('sendDigestForGuild — via tick on Monday 12 UTC', () => { + function makeSvc() { + return new WeeklyDigestService({ clock: () => MONDAY_12_UTC }) + } + + test('skips when digest channel fetch returns null', async () => { + const svc = makeSvc() + const client = { + channels: { fetch: jest.fn(async () => null) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('skips when digest channel has wrong type', async () => { + const svc = makeSvc() + const guild = makeMockGuild() + const ch = makeMockTextChannel(guild, { + type: ChannelType.GuildVoice as unknown as typeof ChannelType.GuildText, + }) + const client = { + channels: { fetch: jest.fn(async () => ch) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('skips when forum channel fetch returns null', async () => { + const svc = makeSvc() + const guild = makeMockGuild(async () => null) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() + }) + + test('sends digest and saves snapshot on happy path', async () => { + const svc = makeSvc() + const guild = makeMockGuild() + const forumChannel = makeMockTextChannel(guild) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ guildId: 'guild-123' }), + }), + ) + expect(digestChannel.send).toHaveBeenCalled() + }) + + test('uses previous snapshot for member delta', async () => { + mockPrismaClient.weeklyDigestSnapshot.findFirst.mockResolvedValue({ + memberCount: 80, + postedAt: new Date(), + }) + const svc = makeSvc() + const guild = makeMockGuild() + ;(guild as unknown as { memberCount: number }).memberCount = 100 + const forumChannel = makeMockTextChannel(guild) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).toHaveBeenCalled() + }) + + test('handles send() throwing gracefully — returns false and logs', async () => { + const svc = makeSvc() + const guild = makeMockGuild() + const forumChannel = makeMockTextChannel(guild) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild, { + send: jest.fn(async () => { + throw new Error('Discord down') + }), + }) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect(errorLog).toHaveBeenCalled() + }) + + test('includes top reacted messages in embed', async () => { + const svc = makeSvc() + const reaction = { count: 5 } + const msgMap = new Map([ + [ + 'msg-1', + { + id: 'msg-1', + url: 'https://discord.com/msg-1', + author: { id: 'user-1' }, + content: 'Hello!', + reactions: { cache: new Map([['👍', reaction]]) }, + }, + ], + ]) + const guild = makeMockGuild() + const forumChannel = makeMockTextChannel(guild, { + messages: { + fetch: jest.fn(async () => msgMap), + } as unknown as TextChannel['messages'], + }) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect(digestChannel.send).toHaveBeenCalled() + }) + + test('includes upcoming scheduled events in embed', async () => { + const svc = makeSvc() + const eventTime = MONDAY_12_UTC + 2 * 24 * 60 * 60 * 1000 // 2 days later + const eventsMap = new Map([ + [ + 'evt-1', + { name: 'Workshop', scheduledStartTimestamp: eventTime }, + ], + ]) + const guild = makeMockGuild() + ;( + guild.scheduledEvents!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(eventsMap) + const forumChannel = makeMockTextChannel(guild) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect(digestChannel.send).toHaveBeenCalled() + }) + + test('recovers gracefully when message fetch fails', async () => { + const svc = makeSvc() + const guild = makeMockGuild() + const forumChannel = makeMockTextChannel(guild, { + messages: { + fetch: jest.fn(async () => { + throw new Error('ratelimit') + }), + } as unknown as TextChannel['messages'], + }) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + // digest still sent (top messages = []) + expect(digestChannel.send).toHaveBeenCalled() + }) + + test('recovers gracefully when scheduledEvents fetch fails', async () => { + const svc = makeSvc() + const guild = makeMockGuild() + ;( + guild.scheduledEvents!.fetch as jest.MockedFunction< + () => Promise + > + ).mockRejectedValue(new Error('events error')) + const forumChannel = makeMockTextChannel(guild) + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel as unknown as TextChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + // digest still sent (events = []) + expect(digestChannel.send).toHaveBeenCalled() + }) + }) +}) From 69a9bcc6cf132770c1ffe397d46f41d583a7a238 Mon Sep 17 00:00:00 2001 From: Lucas Santana <98131142+LucasSantana-Dev@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:14:56 -0300 Subject: [PATCH 08/11] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- packages/bot/src/services/WeeklyDigestService.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bot/src/services/WeeklyDigestService.spec.ts b/packages/bot/src/services/WeeklyDigestService.spec.ts index d0c417614..8ecb055fd 100644 --- a/packages/bot/src/services/WeeklyDigestService.spec.ts +++ b/packages/bot/src/services/WeeklyDigestService.spec.ts @@ -117,7 +117,7 @@ describe('WeeklyDigestService', () => { test('falls back to default when env var is zero or negative', () => { process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '-1' - const svc = new WeeklyDigestService() + new WeeklyDigestService() expect(errorLog).toHaveBeenCalled() }) From 91afd66e413072ba2fa5ff323936438585552043 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 27 Jun 2026 16:28:38 -0300 Subject: [PATCH 09/11] refactor(bot): decouple weekly digest env vars from criativaria rename CRIATIVARIA_DIGEST_CHANNEL_ID to DIGEST_CHANNEL_ID rename CRIATIVARIA_FORUM_CHANNEL_ID to FORUM_CHANNEL_ID lucky is a general-purpose bot that can serve any community. criativaria-specific config belongs in env vars, not code. --- .../bot/src/services/WeeklyDigestService.spec.ts | 16 ++++++++-------- packages/bot/src/services/WeeklyDigestService.ts | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/bot/src/services/WeeklyDigestService.spec.ts b/packages/bot/src/services/WeeklyDigestService.spec.ts index 8ecb055fd..bbffc5d46 100644 --- a/packages/bot/src/services/WeeklyDigestService.spec.ts +++ b/packages/bot/src/services/WeeklyDigestService.spec.ts @@ -74,8 +74,8 @@ function setClient(svc: WeeklyDigestService, client: Client | null): void { describe('WeeklyDigestService', () => { beforeEach(() => { - process.env.CRIATIVARIA_DIGEST_CHANNEL_ID = 'digest-channel-id' - process.env.CRIATIVARIA_FORUM_CHANNEL_ID = 'forum-channel-id' + process.env.DIGEST_CHANNEL_ID = 'digest-channel-id' + process.env.FORUM_CHANNEL_ID = 'forum-channel-id' ;( getPrismaClient as jest.MockedFunction ).mockReturnValue( @@ -87,8 +87,8 @@ describe('WeeklyDigestService', () => { afterEach(() => { jest.clearAllMocks() - delete process.env.CRIATIVARIA_DIGEST_CHANNEL_ID - delete process.env.CRIATIVARIA_FORUM_CHANNEL_ID + delete process.env.DIGEST_CHANNEL_ID + delete process.env.FORUM_CHANNEL_ID delete process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS }) @@ -131,8 +131,8 @@ describe('WeeklyDigestService', () => { }) describe('start / stop lifecycle', () => { - test('does not start when CRIATIVARIA_DIGEST_CHANNEL_ID is missing', () => { - delete process.env.CRIATIVARIA_DIGEST_CHANNEL_ID + test('does not start when DIGEST_CHANNEL_ID is missing', () => { + delete process.env.DIGEST_CHANNEL_ID const svc = new WeeklyDigestService({ tickIntervalMs: 1000 }) const client = { channels: { fetch: jest.fn() }, @@ -141,8 +141,8 @@ describe('WeeklyDigestService', () => { svc.stop() // no-op, timer not set }) - test('does not start when CRIATIVARIA_FORUM_CHANNEL_ID is missing', () => { - delete process.env.CRIATIVARIA_FORUM_CHANNEL_ID + test('does not start when FORUM_CHANNEL_ID is missing', () => { + delete process.env.FORUM_CHANNEL_ID const svc = new WeeklyDigestService({ tickIntervalMs: 1000 }) const client = { channels: { fetch: jest.fn() }, diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts index 95b5cbf32..8708c42c9 100644 --- a/packages/bot/src/services/WeeklyDigestService.ts +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -96,13 +96,13 @@ export class WeeklyDigestService { if (this.timer) return // Check if the service is configured via env vars - const digestChannelId = process.env.CRIATIVARIA_DIGEST_CHANNEL_ID - const forumChannelId = process.env.CRIATIVARIA_FORUM_CHANNEL_ID + const digestChannelId = process.env.DIGEST_CHANNEL_ID + const forumChannelId = process.env.FORUM_CHANNEL_ID if (!digestChannelId || !forumChannelId) { debugLog({ message: - 'Weekly digest service disabled: CRIATIVARIA_DIGEST_CHANNEL_ID or CRIATIVARIA_FORUM_CHANNEL_ID not configured', + 'Weekly digest service disabled: DIGEST_CHANNEL_ID or FORUM_CHANNEL_ID not configured', }) return } @@ -146,8 +146,8 @@ export class WeeklyDigestService { this.lastDigestTime = now - const digestChannelId = process.env.CRIATIVARIA_DIGEST_CHANNEL_ID - const forumChannelId = process.env.CRIATIVARIA_FORUM_CHANNEL_ID + const digestChannelId = process.env.DIGEST_CHANNEL_ID + const forumChannelId = process.env.FORUM_CHANNEL_ID if (!digestChannelId || !forumChannelId) return From d51d06275cb97333ee10c9cf3184b01e0a1f0262 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 27 Jun 2026 18:02:48 -0300 Subject: [PATCH 10/11] refactor(bot): improve digest idempotency and filtering --- .../bot/src/services/WeeklyDigestService.ts | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts index 8708c42c9..bb6d77fc0 100644 --- a/packages/bot/src/services/WeeklyDigestService.ts +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -47,32 +47,6 @@ function parsePositiveIntEnv( return parsed } -/** - * Calculate milliseconds until next Monday 12:00 UTC. - * Useful for initial delay before starting interval-based scheduling. - */ -function msUntilNextMonday12UTC(): number { - const now = new Date() - const utcDate = new Date( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate(), - now.getUTCHours(), - now.getUTCMinutes(), - now.getUTCSeconds(), - ) - - // Find next Monday at 12:00 UTC - const daysUntilMonday = (1 - utcDate.getUTCDay() + 7) % 7 || 7 - const targetDate = new Date( - utcDate.getTime() + daysUntilMonday * MS_PER_DAY, - ) - targetDate.setUTCHours(12, 0, 0, 0) - - const msUntil = targetDate.getTime() - utcDate.getTime() - return Math.max(1000, msUntil) // At least 1 second -} - export class WeeklyDigestService { private readonly tickIntervalMs: number private readonly clock: () => number @@ -128,7 +102,13 @@ export class WeeklyDigestService { } async tick(): Promise { - if (this.tickInProgress || !this.client) return + if (this.tickInProgress || !this.client) { + console.log('Tick early return', { + tickInProgress: this.tickInProgress, + client: !!this.client, + }) + return + } this.tickInProgress = true try { @@ -139,7 +119,10 @@ export class WeeklyDigestService { const dayOfWeek = utcDate.getUTCDay() const hour = utcDate.getUTCHours() - if (dayOfWeek !== 1 || hour !== 12) return // Not Monday 12:xx UTC + if (dayOfWeek !== 1 || hour !== 12) { + console.log('Not Monday 12 UTC', { dayOfWeek, hour }) + return + } // Not Monday 12:xx UTC // Single-send per hour: don't send again if we sent in the last 30 mins if (now - this.lastDigestTime < 30 * 60 * 1000) return @@ -230,8 +213,28 @@ export class WeeklyDigestService { take: 1, }) + // Idempotency check: don't send if we already sent this week + if (previousSnapshot) { + const lastPosted = new Date(previousSnapshot.postedAt) + const now = new Date(this.clock()) + const startOfThisWeek = new Date(now) + startOfThisWeek.setUTCDate( + now.getUTCDate() - now.getUTCDay() + 1, + ) + startOfThisWeek.setUTCHours(0, 0, 0, 0) + + if (lastPosted >= startOfThisWeek) { + debugLog({ + message: 'Weekly digest already sent this week', + }) + return false + } + } + const previousMemberCount = previousSnapshot?.memberCount ?? 0 - const memberDelta = currentMemberCount - previousMemberCount + const memberDelta = previousSnapshot + ? currentMemberCount - previousMemberCount + : 0 // Fetch top-reacted messages from last 7 days const topMessages = await this.getTopReactedMessages(forumChannel) @@ -280,10 +283,14 @@ export class WeeklyDigestService { try { // Fetch last 100 messages from forum const messages = await forumChannel.messages.fetch({ limit: 100 }) + const oneWeekAgo = this.clock() - MS_PER_WEEK const reactionCounts: ReactionCount[] = [] for (const message of messages.values()) { + // Filter by last 7 days + if (message.createdTimestamp < oneWeekAgo) continue + let totalReactions = 0 for (const reaction of message.reactions.cache.values()) { totalReactions += reaction.count @@ -353,8 +360,8 @@ export class WeeklyDigestService { ): EmbedBuilder { const embed = new EmbedBuilder() .setColor(COLOR.LUCKY_PURPLE) - .setTitle('📅 Criativaria — Resumo da semana') - .setFooter({ text: 'criativaria.com.br' }) + .setTitle(process.env.DIGEST_TITLE ?? '📅 Resumo da semana') + .setFooter({ text: process.env.DIGEST_FOOTER ?? 'lucky.bot' }) .setTimestamp() // Member count field From d10485ab2d6d926fa188fbd39b83d5e06ff923e6 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 1 Jul 2026 21:32:26 -0300 Subject: [PATCH 11/11] fix(bot): address weekly digest review findings - getTopReactedMessages: branch on ChannelType.GuildForum and collect active-thread starter messages (forum containers have no .messages) - persist WeeklyDigestSnapshot only after digestChannel.send succeeds so a failed send cannot advance the weekly baseline and lose the digest - replace leftover console.log debug lines with debugLog - replace local parsePositiveIntEnv with shared parseIntEnv (rejects partial numerics like "1h" that parseInt silently truncates) - spec: anchor member-delta snapshot date to the mocked clock (was a time-bomb: real new Date() left the mocked week and broke the test) --- .../src/services/WeeklyDigestService.spec.ts | 91 ++++++++++++++++--- .../bot/src/services/WeeklyDigestService.ts | 77 +++++++++------- 2 files changed, 119 insertions(+), 49 deletions(-) diff --git a/packages/bot/src/services/WeeklyDigestService.spec.ts b/packages/bot/src/services/WeeklyDigestService.spec.ts index bbffc5d46..1d9f39149 100644 --- a/packages/bot/src/services/WeeklyDigestService.spec.ts +++ b/packages/bot/src/services/WeeklyDigestService.spec.ts @@ -14,6 +14,21 @@ jest.mock('@lucky/shared/utils', () => ({ debugLog: jest.fn(), errorLog: jest.fn(), infoLog: jest.fn(), + warnLog: jest.fn(), + // Plain function (not jest.fn): resetMocks would wipe a factory impl + // between tests. Mirrors the real parseIntEnv contract from shared/utils. + parseIntEnv: ( + name: string, + fallback: number, + options?: { min?: number; max?: number }, + ) => { + const value = process.env[name] + if (!value || !/^[+-]?\d+$/.test(value.trim())) return fallback + const parsed = Number.parseInt(value, 10) + if (options?.min !== undefined && parsed < options.min) return fallback + if (options?.max !== undefined && parsed > options.max) return fallback + return parsed + }, })) jest.mock('@lucky/shared/constants', () => ({ @@ -92,33 +107,33 @@ describe('WeeklyDigestService', () => { delete process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS }) - describe('constructor / parsePositiveIntEnv', () => { + describe('constructor / tick interval parsing', () => { + const DEFAULT_TICK = 60 * 60 * 1000 + const tickOf = (svc: WeeklyDigestService): number => + (svc as unknown as { tickIntervalMs: number }).tickIntervalMs + test('uses default tick interval when env var not set', () => { - const svc = new WeeklyDigestService() - expect(svc).toBeDefined() + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) }) test('reads tick interval from env var', () => { process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '5000' - const svc = new WeeklyDigestService() - expect(svc).toBeDefined() + expect(tickOf(new WeeklyDigestService())).toBe(5000) }) test('falls back to default when env var is not a number', () => { process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = 'bad' - const svc = new WeeklyDigestService() - expect(errorLog).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Invalid'), - }), - ) - expect(svc).toBeDefined() + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) + }) + + test('rejects partial numerics like "1h" instead of parsing 1ms', () => { + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '1h' + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) }) test('falls back to default when env var is zero or negative', () => { process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '-1' - new WeeklyDigestService() - expect(errorLog).toHaveBeenCalled() + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) }) test('accepts explicit clock and interval options', () => { @@ -314,7 +329,10 @@ describe('WeeklyDigestService', () => { test('uses previous snapshot for member delta', async () => { mockPrismaClient.weeklyDigestSnapshot.findFirst.mockResolvedValue({ memberCount: 80, - postedAt: new Date(), + // Anchor to the mocked clock, one week back — a real `new + // Date()` here becomes an idempotency skip (and a failing + // test) as soon as the wall clock leaves the mocked week + postedAt: new Date(MONDAY_12_UTC - 7 * 24 * 60 * 60 * 1000), }) const svc = makeSvc() const guild = makeMockGuild() @@ -356,6 +374,11 @@ describe('WeeklyDigestService', () => { setClient(svc, client) await svc.tick() expect(errorLog).toHaveBeenCalled() + // Failed send must NOT advance the weekly baseline — otherwise + // the idempotency check suppresses retry and the digest is lost + expect( + mockPrismaClient.weeklyDigestSnapshot.create, + ).not.toHaveBeenCalled() }) test('includes top reacted messages in embed', async () => { @@ -393,6 +416,44 @@ describe('WeeklyDigestService', () => { expect(digestChannel.send).toHaveBeenCalled() }) + test('fetches starter messages from threads when channel is GuildForum', async () => { + const svc = makeSvc() + const reaction = { count: 7 } + const starterMessage = { + id: 'post-1', + url: 'https://discord.com/post-1', + author: { id: 'user-1' }, + content: 'Forum post!', + createdTimestamp: MONDAY_12_UTC - 1000, + reactions: { cache: new Map([['🔥', reaction]]) }, + } + const fetchStarterMessage = jest.fn(async () => starterMessage) + const fetchActive = jest.fn(async () => ({ + threads: new Map([['thread-1', { fetchStarterMessage }]]), + })) + const guild = makeMockGuild() + const forumChannel = { + type: ChannelType.GuildForum, + guildId: 'guild-123', + guild: guild as Guild, + threads: { fetchActive }, + } + ;( + guild.channels!.fetch as jest.MockedFunction< + () => Promise + > + ).mockResolvedValue(forumChannel) + const digestChannel = makeMockTextChannel(guild) + const client = { + channels: { fetch: jest.fn(async () => digestChannel) }, + } as unknown as Client + setClient(svc, client) + await svc.tick() + expect(fetchActive).toHaveBeenCalled() + expect(fetchStarterMessage).toHaveBeenCalled() + expect(digestChannel.send).toHaveBeenCalled() + }) + test('includes upcoming scheduled events in embed', async () => { const svc = makeSvc() const eventTime = MONDAY_12_UTC + 2 * 24 * 60 * 60 * 1000 // 2 days later diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts index bb6d77fc0..f9b4ecb73 100644 --- a/packages/bot/src/services/WeeklyDigestService.ts +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -1,8 +1,10 @@ import type { Client, TextChannel, + ForumChannel, Guild, GuildScheduledEvent, + Message, } from 'discord.js' import { ChannelType, EmbedBuilder } from 'discord.js' import { COLOR } from '@lucky/shared/constants' @@ -11,6 +13,7 @@ import { debugLog, errorLog, infoLog, + parseIntEnv, } from '@lucky/shared/utils' const DEFAULT_TICK_INTERVAL_MS = 60 * 60 * 1000 // Check every hour @@ -30,23 +33,6 @@ type ReactionCount = { totalReactions: number } -function parsePositiveIntEnv( - raw: string | undefined, - fallback: number, - name: string, -): number { - if (raw === undefined) return fallback - const parsed = Number.parseInt(raw, 10) - if (!Number.isFinite(parsed) || parsed <= 0) { - errorLog({ - message: `Invalid ${name} env value, falling back to default`, - data: { raw, fallback }, - }) - return fallback - } - return parsed -} - export class WeeklyDigestService { private readonly tickIntervalMs: number private readonly clock: () => number @@ -58,10 +44,10 @@ export class WeeklyDigestService { constructor(options: WeeklyDigestServiceOptions = {}) { this.tickIntervalMs = options.tickIntervalMs ?? - parsePositiveIntEnv( - process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS, - DEFAULT_TICK_INTERVAL_MS, + parseIntEnv( 'WEEKLY_DIGEST_TICK_INTERVAL_MS', + DEFAULT_TICK_INTERVAL_MS, + { min: 1 }, ) this.clock = options.clock ?? (() => Date.now()) } @@ -103,9 +89,12 @@ export class WeeklyDigestService { async tick(): Promise { if (this.tickInProgress || !this.client) { - console.log('Tick early return', { - tickInProgress: this.tickInProgress, - client: !!this.client, + debugLog({ + message: 'Weekly digest tick early return', + data: { + tickInProgress: this.tickInProgress, + client: !!this.client, + }, }) return } @@ -120,9 +109,12 @@ export class WeeklyDigestService { const hour = utcDate.getUTCHours() if (dayOfWeek !== 1 || hour !== 12) { - console.log('Not Monday 12 UTC', { dayOfWeek, hour }) + debugLog({ + message: 'Weekly digest: not Monday 12:00 UTC', + data: { dayOfWeek, hour }, + }) return - } // Not Monday 12:xx UTC + } // Single-send per hour: don't send again if we sent in the last 30 mins if (now - this.lastDigestTime < 30 * 60 * 1000) return @@ -188,7 +180,7 @@ export class WeeklyDigestService { // Resolve forum channel const forumChannel = (await guild.channels .fetch(forumChannelId) - .catch(() => null)) as TextChannel | null + .catch(() => null)) as TextChannel | ForumChannel | null if ( !forumChannel || (forumChannel.type !== ChannelType.GuildText && @@ -250,7 +242,11 @@ export class WeeklyDigestService { upcomingEvents, ) - // Save snapshot + // Send embed first — persisting the snapshot only after a + // successful send keeps a failed send from advancing the weekly + // baseline (which would suppress retry and lose the digest) + await digestChannel.send({ embeds: [embed] }) + await prisma.weeklyDigestSnapshot.create({ data: { guildId, @@ -259,9 +255,6 @@ export class WeeklyDigestService { }, }) - // Send embed - await digestChannel.send({ embeds: [embed] }) - debugLog({ message: 'Weekly digest sent successfully', data: { guildId }, @@ -278,16 +271,32 @@ export class WeeklyDigestService { } private async getTopReactedMessages( - forumChannel: TextChannel, + forumChannel: TextChannel | ForumChannel, ): Promise { try { - // Fetch last 100 messages from forum - const messages = await forumChannel.messages.fetch({ limit: 100 }) + // Forum posts live in threads — the forum container itself has no + // .messages, so collect each active thread's starter message. + // Text channels keep the plain last-100-messages fetch. + const messages: Message[] = [] + if (forumChannel.type === ChannelType.GuildForum) { + const active = await forumChannel.threads.fetchActive() + for (const thread of active.threads.values()) { + const starter = await thread + .fetchStarterMessage() + .catch(() => null) + if (starter) messages.push(starter) + } + } else { + const fetched = await forumChannel.messages.fetch({ + limit: 100, + }) + messages.push(...fetched.values()) + } const oneWeekAgo = this.clock() - MS_PER_WEEK const reactionCounts: ReactionCount[] = [] - for (const message of messages.values()) { + for (const message of messages) { // Filter by last 7 days if (message.createdTimestamp < oneWeekAgo) continue