diff --git a/packages/bot/src/bot/start/initializer.spec.ts b/packages/bot/src/bot/start/initializer.spec.ts index 2d8ae6158..96206c8af 100644 --- a/packages/bot/src/bot/start/initializer.spec.ts +++ b/packages/bot/src/bot/start/initializer.spec.ts @@ -27,6 +27,9 @@ 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 criativariaLiveNotifStopMock = jest.fn() const stopTwitchServiceMock = jest.fn() jest.mock('@lucky/shared/utils', () => ({ @@ -114,6 +117,20 @@ jest.mock('../../services/DependencyCheckService', () => ({ }, })) +jest.mock('../../services/WeeklyDigestService', () => ({ + weeklyDigestService: { + start: (...args: unknown[]) => weeklyDigestStartMock(...args), + stop: (...args: unknown[]) => weeklyDigestStopMock(...args), + }, +})) + +jest.mock('../../services/CriativariaLiveNotificationService', () => ({ + criativariaLiveNotificationService: { + start: jest.fn(), + stop: (...args: unknown[]) => criativariaLiveNotifStopMock(...args), + }, +})) + jest.mock('../../twitch', () => ({ stopTwitchService: (...args: unknown[]) => stopTwitchServiceMock(...args), })) @@ -430,6 +447,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 +463,7 @@ describe('BotInitializer', () => { ['modDigestSchedulerService.stop', modDigestSchedulerStopMock], ['aiDevToolkitService.stop', aiDevToolkitStopMock], ['dependencyCheckService.stop', dependencyCheckStopMock], + ['weeklyDigestService.stop', weeklyDigestStopMock], ['stopTwitchService', stopTwitchServiceMock], ['stopOrphanSessionMonitor', musicWatchdogStopMock], ['stopPeriodicScan', musicWatchdogStopPeriodicScanMock], 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.spec.ts b/packages/bot/src/services/WeeklyDigestService.spec.ts new file mode 100644 index 000000000..1d9f39149 --- /dev/null +++ b/packages/bot/src/services/WeeklyDigestService.spec.ts @@ -0,0 +1,536 @@ +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(), + 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', () => ({ + 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.DIGEST_CHANNEL_ID = 'digest-channel-id' + process.env.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.DIGEST_CHANNEL_ID + delete process.env.FORUM_CHANNEL_ID + delete process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS + }) + + 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', () => { + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) + }) + + test('reads tick interval from env var', () => { + process.env.WEEKLY_DIGEST_TICK_INTERVAL_MS = '5000' + 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' + 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' + expect(tickOf(new WeeklyDigestService())).toBe(DEFAULT_TICK) + }) + + 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 DIGEST_CHANNEL_ID is missing', () => { + delete process.env.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 FORUM_CHANNEL_ID is missing', () => { + delete process.env.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, + // 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() + ;(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() + // 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 () => { + 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('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 + 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() + }) + }) +}) diff --git a/packages/bot/src/services/WeeklyDigestService.ts b/packages/bot/src/services/WeeklyDigestService.ts new file mode 100644 index 000000000..f9b4ecb73 --- /dev/null +++ b/packages/bot/src/services/WeeklyDigestService.ts @@ -0,0 +1,429 @@ +import type { + Client, + TextChannel, + ForumChannel, + Guild, + GuildScheduledEvent, + Message, +} from 'discord.js' +import { ChannelType, EmbedBuilder } from 'discord.js' +import { COLOR } from '@lucky/shared/constants' +import { + getPrismaClient, + debugLog, + errorLog, + infoLog, + parseIntEnv, +} 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 +} + +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 ?? + parseIntEnv( + 'WEEKLY_DIGEST_TICK_INTERVAL_MS', + DEFAULT_TICK_INTERVAL_MS, + { min: 1 }, + ) + 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.DIGEST_CHANNEL_ID + const forumChannelId = process.env.FORUM_CHANNEL_ID + + if (!digestChannelId || !forumChannelId) { + debugLog({ + message: + 'Weekly digest service disabled: DIGEST_CHANNEL_ID or 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) { + debugLog({ + message: 'Weekly digest tick early return', + data: { + tickInProgress: this.tickInProgress, + client: !!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) { + debugLog({ + message: 'Weekly digest: not Monday 12:00 UTC', + data: { dayOfWeek, hour }, + }) + return + } + + // 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.DIGEST_CHANNEL_ID + const forumChannelId = process.env.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 | ForumChannel | 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, + }) + + // 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 = previousSnapshot + ? currentMemberCount - previousMemberCount + : 0 + + // 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 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, + 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 | ForumChannel, + ): Promise { + try { + // 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) { + // Filter by last 7 days + if (message.createdTimestamp < oneWeekAgo) continue + + 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: Guild): Promise { + try { + const events = await guild.scheduledEvents.fetch() + const now = this.clock() + const oneWeekFromNow = now + MS_PER_WEEK + + const upcomingEvents = [...events.values()] + .filter((event: GuildScheduledEvent) => { + const eventTime = event.scheduledStartTimestamp ?? 0 + return eventTime > now && eventTime < oneWeekFromNow + }) + .sort( + (a: GuildScheduledEvent, b: GuildScheduledEvent) => + (a.scheduledStartTimestamp ?? 0) - + (b.scheduledStartTimestamp ?? 0), + ) + .slice(0, 5) + .map( + (event: GuildScheduledEvent) => + `• ${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.LUCKY_PURPLE) + .setTitle(process.env.DIGEST_TITLE ?? '📅 Resumo da semana') + .setFooter({ text: process.env.DIGEST_FOOTER ?? 'lucky.bot' }) + .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") +} 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"