From 1cb00907b1ce70a7de24c33966a6150121d2410d Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Tue, 7 Apr 2026 18:32:00 -0300 Subject: [PATCH 1/3] feat(bot): scheduled weekly mod digest Adds /digest schedule and /digest unschedule subcommands plus a background scheduler so guilds can opt into automatic weekly moderation summaries. - New ModDigestConfigService stores per-guild config in Redis (mod-digest:config:) with a Set index of enabled guilds. - New ModDigestSchedulerService ticks once per hour (configurable via MOD_DIGEST_TICK_INTERVAL_MS) and posts the existing /digest embed for each due guild based on lastSentAt + MOD_DIGEST_PERIOD_DAYS. - Embed building extracted into utils/moderation/digestEmbed.ts so the slash command and the scheduler share one builder. - /digest is now subcommand-based: view (default), schedule, unschedule. Scheduling immediately posts a sample digest in the chosen channel. - Scheduler is started inside the existing client.once('ready') wiring in clientHandler/service.ts. Tests: 32 new tests across digestEmbed, modDigestConfig, modDigestScheduler plus an updated digest.spec.ts covering all three subcommands and error paths. --- .../moderation/commands/digest.spec.ts | 273 ++++++++++------ .../functions/moderation/commands/digest.ts | 248 +++++++++----- .../handlers/clientHandler/service.spec.ts | 7 + .../bot/src/handlers/clientHandler/service.ts | 3 + .../src/utils/moderation/digestEmbed.spec.ts | 112 +++++++ .../bot/src/utils/moderation/digestEmbed.ts | 95 ++++++ .../utils/moderation/modDigestConfig.spec.ts | 198 ++++++++++++ .../src/utils/moderation/modDigestConfig.ts | 80 +++++ .../moderation/modDigestScheduler.spec.ts | 304 ++++++++++++++++++ .../utils/moderation/modDigestScheduler.ts | 156 +++++++++ 10 files changed, 1294 insertions(+), 182 deletions(-) create mode 100644 packages/bot/src/utils/moderation/digestEmbed.spec.ts create mode 100644 packages/bot/src/utils/moderation/digestEmbed.ts create mode 100644 packages/bot/src/utils/moderation/modDigestConfig.spec.ts create mode 100644 packages/bot/src/utils/moderation/modDigestConfig.ts create mode 100644 packages/bot/src/utils/moderation/modDigestScheduler.spec.ts create mode 100644 packages/bot/src/utils/moderation/modDigestScheduler.ts diff --git a/packages/bot/src/functions/moderation/commands/digest.spec.ts b/packages/bot/src/functions/moderation/commands/digest.spec.ts index 185c72ea0..202fae27a 100644 --- a/packages/bot/src/functions/moderation/commands/digest.spec.ts +++ b/packages/bot/src/functions/moderation/commands/digest.spec.ts @@ -6,6 +6,10 @@ const getRecentCasesMock = jest.fn() const interactionReplyMock = jest.fn() const infoLogMock = jest.fn() const errorLogMock = jest.fn() +const enableMock = jest.fn() +const disableMock = jest.fn() +const markSentMock = jest.fn() +const sendDigestForGuildMock = jest.fn() jest.mock('@lucky/shared/services', () => ({ moderationService: { @@ -23,22 +27,45 @@ jest.mock('../../../utils/general/interactionReply', () => ({ interactionReply: (...args: unknown[]) => interactionReplyMock(...args), })) +jest.mock('../../../utils/moderation/modDigestConfig', () => ({ + modDigestConfigService: { + enable: (...args: unknown[]) => enableMock(...args), + disable: (...args: unknown[]) => disableMock(...args), + markSent: (...args: unknown[]) => markSentMock(...args), + }, +})) + +jest.mock('../../../utils/moderation/modDigestScheduler', () => ({ + modDigestSchedulerService: { + sendDigestForGuild: (...args: unknown[]) => + sendDigestForGuildMock(...args), + }, +})) + function makeCase(type: string, moderatorName: string, daysAgo: number) { const createdAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000) return { type, moderatorName, createdAt } } -function createInteraction(period: string | null = null) { +type InteractionOverrides = { + subcommand?: string | null + period?: string | null + channel?: any + guild?: { id: string; name: string } | null +} + +function createInteraction(overrides: InteractionOverrides = {}) { + const subcommand = overrides.subcommand ?? 'view' return { - guild: { - id: '123456789012345678', - name: 'TestServer', - }, - user: { - tag: 'Admin#0001', - }, + guild: + overrides.guild === undefined + ? { id: '123456789012345678', name: 'TestServer' } + : overrides.guild, + user: { tag: 'Admin#0001' }, options: { - getString: jest.fn((_name: string) => period), + getSubcommand: jest.fn().mockReturnValue(subcommand), + getString: jest.fn(() => overrides.period ?? null), + getChannel: jest.fn(() => overrides.channel ?? null), }, replied: false, deferred: false, @@ -59,115 +86,171 @@ describe('digest command', () => { expect(data.description).toContain('digest') }) - it('replies with embed when cases exist in period', async () => { - getStatsMock.mockResolvedValue({ totalCases: 10, activeCases: 2 }) - getRecentCasesMock.mockResolvedValue([ - makeCase('WARN', 'Mod1', 3), - makeCase('BAN', 'Mod2', 5), - makeCase('WARN', 'Mod1', 6), - ]) + it('exposes view, schedule, and unschedule subcommands', () => { + const data = digestCommand.data.toJSON() + const subNames = (data.options ?? []).map((o: any) => o.name) + expect(subNames).toEqual( + expect.arrayContaining(['view', 'schedule', 'unschedule']), + ) + }) - const interaction = createInteraction('7d') - await digestCommand.execute({ interaction, client: {} as any }) + describe('view subcommand', () => { + it('replies with embed when cases exist in period', async () => { + getStatsMock.mockResolvedValue({ totalCases: 10, activeCases: 2 }) + getRecentCasesMock.mockResolvedValue([ + makeCase('WARN', 'Mod1', 3), + makeCase('BAN', 'Mod2', 5), + ]) - expect(getStatsMock).toHaveBeenCalledWith('123456789012345678') - expect(getRecentCasesMock).toHaveBeenCalledWith('123456789012345678', 500) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.embeds).toHaveLength(1) - expect(infoLogMock).toHaveBeenCalledTimes(1) - }) + const interaction = createInteraction({ subcommand: 'view', period: '7d' }) + await digestCommand.execute({ interaction, client: {} as any }) - it('defaults to 7d period when no option provided', async () => { - getStatsMock.mockResolvedValue({ totalCases: 5, activeCases: 0 }) - getRecentCasesMock.mockResolvedValue([makeCase('KICK', 'Mod1', 2)]) + expect(getStatsMock).toHaveBeenCalledWith('123456789012345678') + expect(getRecentCasesMock).toHaveBeenCalledWith( + '123456789012345678', + 500, + ) + expect(interactionReplyMock).toHaveBeenCalledTimes(1) + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.embeds).toHaveLength(1) + expect(infoLogMock).toHaveBeenCalledTimes(1) + }) - const interaction = createInteraction(null) - await digestCommand.execute({ interaction, client: {} as any }) + it('defaults to 7d period when no option provided', async () => { + getStatsMock.mockResolvedValue({ totalCases: 5, activeCases: 0 }) + getRecentCasesMock.mockResolvedValue([makeCase('KICK', 'Mod1', 2)]) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.embeds).toHaveLength(1) - }) + const interaction = createInteraction({ subcommand: 'view' }) + await digestCommand.execute({ interaction, client: {} as any }) - it('shows no actions message when no cases in period', async () => { - getStatsMock.mockResolvedValue({ totalCases: 100, activeCases: 5 }) - // All cases are older than 7 days - getRecentCasesMock.mockResolvedValue([ - makeCase('WARN', 'Mod1', 10), - makeCase('BAN', 'Mod2', 15), - ]) + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.embeds).toHaveLength(1) + }) - const interaction = createInteraction('7d') - await digestCommand.execute({ interaction, client: {} as any }) + it('falls back to view when no subcommand is supplied', async () => { + getStatsMock.mockResolvedValue({ totalCases: 0, activeCases: 0 }) + getRecentCasesMock.mockResolvedValue([]) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - const embed = replyArg.content.embeds[0] - const fields = embed.data?.fields ?? [] - const actionsField = fields.find((f: any) => f.name.includes('Actions')) - expect(actionsField?.value).toContain('No actions recorded') - }) + const interaction = createInteraction({ subcommand: null }) + await digestCommand.execute({ interaction, client: {} as any }) - it('uses 30d period when specified', async () => { - getStatsMock.mockResolvedValue({ totalCases: 20, activeCases: 1 }) - getRecentCasesMock.mockResolvedValue([makeCase('MUTE', 'Mod1', 20)]) + expect(getStatsMock).toHaveBeenCalled() + expect(interactionReplyMock).toHaveBeenCalledTimes(1) + }) - const interaction = createInteraction('30d') - await digestCommand.execute({ interaction, client: {} as any }) + it('replies with error message when service throws', async () => { + getStatsMock.mockRejectedValue(new Error('DB error')) + getRecentCasesMock.mockResolvedValue([]) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - const embed = replyArg.content.embeds[0] - expect(embed.data?.title).toContain('30 days') + const interaction = createInteraction({ subcommand: 'view', period: '7d' }) + await digestCommand.execute({ interaction, client: {} as any }) + + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('Failed') + expect(errorLogMock).toHaveBeenCalledTimes(1) + }) }) - it('replies with error message when not in a guild', async () => { - const interaction = { - guild: null, - user: { tag: 'Admin#0001' }, - options: { getString: jest.fn(() => null) }, - replied: false, - deferred: false, - } as any + describe('schedule subcommand', () => { + it('enables config and posts a sample digest', async () => { + enableMock.mockResolvedValue({}) + sendDigestForGuildMock.mockResolvedValue(true) + markSentMock.mockResolvedValue(undefined) - await digestCommand.execute({ interaction, client: {} as any }) + const channel = { id: 'channel-1', type: 0 } + const interaction = createInteraction({ subcommand: 'schedule', channel }) + await digestCommand.execute({ interaction, client: {} as any }) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.content).toContain('server') - expect(getStatsMock).not.toHaveBeenCalled() - }) + expect(enableMock).toHaveBeenCalledWith( + '123456789012345678', + 'channel-1', + ) + expect(sendDigestForGuildMock).toHaveBeenCalledWith( + '123456789012345678', + 'channel-1', + ) + expect(markSentMock).toHaveBeenCalledWith('123456789012345678') + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('scheduled') + expect(replyArg.content.content).toContain('sample digest') + }) - it('replies with error message when service throws', async () => { - getStatsMock.mockRejectedValue(new Error('DB error')) - getRecentCasesMock.mockResolvedValue([]) + it('reports a partial success when sample digest fails to post', async () => { + enableMock.mockResolvedValue({}) + sendDigestForGuildMock.mockResolvedValue(false) - const interaction = createInteraction('7d') - await digestCommand.execute({ interaction, client: {} as any }) + const channel = { id: 'channel-2', type: 0 } + const interaction = createInteraction({ subcommand: 'schedule', channel }) + await digestCommand.execute({ interaction, client: {} as any }) - expect(interactionReplyMock).toHaveBeenCalledTimes(1) - const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.content).toContain('Failed') - expect(errorLogMock).toHaveBeenCalledTimes(1) + expect(markSentMock).not.toHaveBeenCalled() + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('schedule is active') + }) + + it('rejects non-text channels', async () => { + const channel = { id: 'voice', type: 2 } + const interaction = createInteraction({ subcommand: 'schedule', channel }) + await digestCommand.execute({ interaction, client: {} as any }) + + expect(enableMock).not.toHaveBeenCalled() + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('text channel') + }) + + it('replies with error when enable throws', async () => { + enableMock.mockRejectedValue(new Error('redis down')) + const channel = { id: 'channel-1', type: 0 } + const interaction = createInteraction({ subcommand: 'schedule', channel }) + + await digestCommand.execute({ interaction, client: {} as any }) + + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('Failed') + expect(errorLogMock).toHaveBeenCalled() + }) }) - it('shows top moderators field when cases exist', async () => { - getStatsMock.mockResolvedValue({ totalCases: 15, activeCases: 3 }) - getRecentCasesMock.mockResolvedValue([ - makeCase('WARN', 'Alice', 1), - makeCase('BAN', 'Alice', 2), - makeCase('KICK', 'Bob', 3), - ]) + describe('unschedule subcommand', () => { + it('confirms removal when config existed', async () => { + disableMock.mockResolvedValue(true) - const interaction = createInteraction('7d') + const interaction = createInteraction({ subcommand: 'unschedule' }) + await digestCommand.execute({ interaction, client: {} as any }) + + expect(disableMock).toHaveBeenCalledWith('123456789012345678') + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('disabled') + }) + + it('reports nothing-to-do when config was missing', async () => { + disableMock.mockResolvedValue(false) + + const interaction = createInteraction({ subcommand: 'unschedule' }) + await digestCommand.execute({ interaction, client: {} as any }) + + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('No active digest') + }) + + it('replies with error when disable throws', async () => { + disableMock.mockRejectedValue(new Error('redis down')) + const interaction = createInteraction({ subcommand: 'unschedule' }) + + await digestCommand.execute({ interaction, client: {} as any }) + + const replyArg = interactionReplyMock.mock.calls[0][0] as any + expect(replyArg.content.content).toContain('Failed') + expect(errorLogMock).toHaveBeenCalled() + }) + }) + + it('replies with error message when not in a guild', async () => { + const interaction = createInteraction({ subcommand: 'view', guild: null }) await digestCommand.execute({ interaction, client: {} as any }) const replyArg = interactionReplyMock.mock.calls[0][0] as any - const embed = replyArg.content.embeds[0] - const fields = embed.data?.fields ?? [] - const modField = fields.find((f: any) => f.name.includes('moderator')) - expect(modField).toBeDefined() - expect(modField?.value).toContain('Alice') + expect(replyArg.content.content).toContain('server') + expect(getStatsMock).not.toHaveBeenCalled() }) }) diff --git a/packages/bot/src/functions/moderation/commands/digest.ts b/packages/bot/src/functions/moderation/commands/digest.ts index 92127b17d..0c29392f7 100644 --- a/packages/bot/src/functions/moderation/commands/digest.ts +++ b/packages/bot/src/functions/moderation/commands/digest.ts @@ -1,34 +1,60 @@ import { SlashCommandBuilder, PermissionFlagsBits, - EmbedBuilder, + ChannelType, + type ChatInputCommandInteraction, + type TextChannel, } from 'discord.js' import Command from '../../../models/Command.js' import { moderationService } from '@lucky/shared/services' import { infoLog, errorLog } from '@lucky/shared/utils' import { interactionReply } from '../../../utils/general/interactionReply.js' - -const PERIOD_DAYS: Record = { - '7d': 7, - '30d': 30, - '90d': 90, -} +import { + buildDigestEmbed, + resolveDigestPeriodDays, +} from '../../../utils/moderation/digestEmbed.js' +import { modDigestConfigService } from '../../../utils/moderation/modDigestConfig.js' +import { modDigestSchedulerService } from '../../../utils/moderation/modDigestScheduler.js' export default new Command({ data: new SlashCommandBuilder() .setName('digest') - .setDescription('📊 Show a moderation activity digest for this server.') + .setDescription('📊 Moderation activity digest tools') .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) - .addStringOption((option) => - option - .setName('period') - .setDescription('Time period to summarise (default: 7d)') - .setRequired(false) - .addChoices( - { name: 'Last 7 days', value: '7d' }, - { name: 'Last 30 days', value: '30d' }, - { name: 'Last 90 days', value: '90d' }, + .addSubcommand((sub) => + sub + .setName('view') + .setDescription('Show a moderation digest right now') + .addStringOption((option) => + option + .setName('period') + .setDescription('Time period to summarise (default: 7d)') + .setRequired(false) + .addChoices( + { name: 'Last 7 days', value: '7d' }, + { name: 'Last 30 days', value: '30d' }, + { name: 'Last 90 days', value: '90d' }, + ), + ), + ) + .addSubcommand((sub) => + sub + .setName('schedule') + .setDescription( + 'Enable weekly automated digest posts in a channel', + ) + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Text channel that will receive the digest') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), ), + ) + .addSubcommand((sub) => + sub + .setName('unschedule') + .setDescription('Disable the automated weekly digest'), ), category: 'moderation', execute: async ({ interaction }) => { @@ -40,80 +66,128 @@ export default new Command({ return } - const period = interaction.options.getString('period') ?? '7d' - const days = PERIOD_DAYS[period] ?? 7 - const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) - - try { - const [stats, recentCases] = await Promise.all([ - moderationService.getStats(interaction.guild.id), - moderationService.getRecentCases(interaction.guild.id, 500), - ]) - - const periodCases = recentCases.filter((c) => c.createdAt >= since) - - const periodByType: Record = {} - for (const c of periodCases) { - periodByType[c.type] = (periodByType[c.type] ?? 0) + 1 - } - - const typeLines = Object.entries(periodByType) - .sort((a, b) => b[1] - a[1]) - .map(([type, count]) => `â€ĸ **${type.toUpperCase()}**: ${count}`) - .join('\n') - - const topModerators: Record = {} - for (const c of periodCases) { - topModerators[c.moderatorName] = (topModerators[c.moderatorName] ?? 0) + 1 - } - const topModLines = Object.entries(topModerators) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([name, count]) => `â€ĸ **${name}**: ${count} action${count !== 1 ? 's' : ''}`) - .join('\n') - - const embed = new EmbedBuilder() - .setColor(0x5865f2) - .setTitle(`📊 Moderation Digest — Last ${days} days`) - .addFields( - { - name: '📈 All-time totals', - value: [ - `Total cases: **${stats.totalCases}**`, - `Active cases: **${stats.activeCases}**`, - ].join('\n'), - inline: false, - }, - { - name: `đŸ—‚ī¸ Actions in the last ${days} days`, - value: periodCases.length > 0 - ? `**${periodCases.length}** total\n${typeLines}` - : 'No actions recorded.', - inline: false, - }, - ) + const subcommand = interaction.options.getSubcommand(false) ?? 'view' - if (topModLines) { - embed.addFields({ - name: '🏅 Top moderators', - value: topModLines, - inline: false, - }) - } + if (subcommand === 'schedule') { + await handleSchedule(interaction) + return + } - embed.setTimestamp().setFooter({ text: `Period: last ${days} days` }) + if (subcommand === 'unschedule') { + await handleUnschedule(interaction) + return + } + + await handleView(interaction) + }, +}) - await interactionReply({ interaction, content: { embeds: [embed] } }) +async function handleView( + interaction: ChatInputCommandInteraction, +): Promise { + const period = interaction.options.getString('period') ?? '7d' + const days = resolveDigestPeriodDays(period) + try { + const guildId = interaction.guild!.id + const [stats, recentCases] = await Promise.all([ + moderationService.getStats(guildId), + moderationService.getRecentCases(guildId, 500), + ]) + + const embed = buildDigestEmbed({ stats, cases: recentCases, days }) + + await interactionReply({ interaction, content: { embeds: [embed] } }) + + infoLog({ + message: `Mod digest viewed by ${interaction.user.tag} in ${interaction.guild!.name} (period: ${period})`, + }) + } catch (error) { + errorLog({ message: 'Failed to generate mod digest', error: error as Error }) + await interactionReply({ + interaction, + content: { content: '❌ Failed to generate digest. Please try again.' }, + }) + } +} + +async function handleSchedule( + interaction: ChatInputCommandInteraction, +): Promise { + const channel = interaction.options.getChannel('channel') + if (!channel || channel.type !== ChannelType.GuildText) { + await interactionReply({ + interaction, + content: { content: '❌ Please pick a text channel.' }, + }) + return + } + + const guildId = interaction.guild!.id + const channelId = (channel as TextChannel).id + + try { + await modDigestConfigService.enable(guildId, channelId) + const sent = await modDigestSchedulerService.sendDigestForGuild( + guildId, + channelId, + ) + if (sent) { + await modDigestConfigService.markSent(guildId) + } + + await interactionReply({ + interaction, + content: { + content: `✅ Weekly mod digest scheduled for <#${channelId}>. ${ + sent + ? 'A sample digest has been posted now.' + : 'Sample digest could not be posted yet, but the schedule is active.' + }`, + }, + }) + + infoLog({ + message: `Mod digest scheduled by ${interaction.user.tag} in ${interaction.guild!.name} → channel ${channelId}`, + }) + } catch (error) { + errorLog({ message: 'Failed to schedule mod digest', error: error as Error }) + await interactionReply({ + interaction, + content: { + content: '❌ Failed to schedule the digest. Please try again.', + }, + }) + } +} + +async function handleUnschedule( + interaction: ChatInputCommandInteraction, +): Promise { + try { + const removed = await modDigestConfigService.disable(interaction.guild!.id) + + await interactionReply({ + interaction, + content: { + content: removed + ? '✅ Weekly mod digest disabled.' + : 'â„šī¸ No active digest schedule to disable.', + }, + }) + + if (removed) { infoLog({ - message: `Mod digest viewed by ${interaction.user.tag} in ${interaction.guild.name} (period: ${period})`, - }) - } catch (error) { - errorLog({ message: 'Failed to generate mod digest', error: error as Error }) - await interactionReply({ - interaction, - content: { content: '❌ Failed to generate digest. Please try again.' }, + message: `Mod digest unscheduled by ${interaction.user.tag} in ${interaction.guild!.name}`, }) } - }, -}) + } catch (error) { + errorLog({ message: 'Failed to unschedule mod digest', error: error as Error }) + await interactionReply({ + interaction, + content: { + content: '❌ Failed to disable the digest. Please try again.', + }, + }) + } +} diff --git a/packages/bot/src/handlers/clientHandler/service.spec.ts b/packages/bot/src/handlers/clientHandler/service.spec.ts index 137264313..29a8d99e5 100644 --- a/packages/bot/src/handlers/clientHandler/service.spec.ts +++ b/packages/bot/src/handlers/clientHandler/service.spec.ts @@ -27,6 +27,13 @@ jest.mock('../../services/MusicPresenceService', () => ({ initMusicPresence: jest.fn(), })) +jest.mock('../../utils/moderation/modDigestScheduler', () => ({ + modDigestSchedulerService: { + start: jest.fn(), + stop: jest.fn(), + }, +})) + jest.mock('discord.js', () => { const originalModule = jest.requireActual('discord.js') diff --git a/packages/bot/src/handlers/clientHandler/service.ts b/packages/bot/src/handlers/clientHandler/service.ts index a97dfe391..48d2c9073 100644 --- a/packages/bot/src/handlers/clientHandler/service.ts +++ b/packages/bot/src/handlers/clientHandler/service.ts @@ -6,6 +6,7 @@ import { config } from '@lucky/shared/config' import type Command from '../../models/Command' import { startPresenceRotation } from './presence' import { initMusicPresence } from '../../services/MusicPresenceService' +import { modDigestSchedulerService } from '../../utils/moderation/modDigestScheduler' let presenceControls: { stop: () => void; pause: () => void; resume: () => void } | null = null @@ -79,6 +80,8 @@ export async function startClient({ const { startTwitchService } = await import('../../twitch/index.js') await startTwitchService(client) + + modDigestSchedulerService.start(client) } catch (error) { errorLog({ message: 'Error in ready handler:', diff --git a/packages/bot/src/utils/moderation/digestEmbed.spec.ts b/packages/bot/src/utils/moderation/digestEmbed.spec.ts new file mode 100644 index 000000000..21f518976 --- /dev/null +++ b/packages/bot/src/utils/moderation/digestEmbed.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from '@jest/globals' +import { + buildDigestEmbed, + filterCasesSince, + resolveDigestPeriodDays, + type DigestCase, +} from './digestEmbed' + +function caseDaysAgo(type: string, moderatorName: string, daysAgo: number): DigestCase { + return { + type, + moderatorName, + createdAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000), + } +} + +describe('resolveDigestPeriodDays', () => { + it('defaults to 7 when no period is supplied', () => { + expect(resolveDigestPeriodDays(null)).toBe(7) + expect(resolveDigestPeriodDays(undefined)).toBe(7) + }) + + it('maps known period strings to days', () => { + expect(resolveDigestPeriodDays('7d')).toBe(7) + expect(resolveDigestPeriodDays('30d')).toBe(30) + expect(resolveDigestPeriodDays('90d')).toBe(90) + }) + + it('falls back to 7 for unknown periods', () => { + expect(resolveDigestPeriodDays('xx')).toBe(7) + }) +}) + +describe('filterCasesSince', () => { + it('keeps only cases within the window', () => { + const cases = [ + caseDaysAgo('warn', 'A', 1), + caseDaysAgo('ban', 'B', 8), + caseDaysAgo('kick', 'A', 6), + ] + const filtered = filterCasesSince(cases, 7) + expect(filtered).toHaveLength(2) + expect(filtered.map((c) => c.type)).toEqual(['warn', 'kick']) + }) + + it('returns an empty array when nothing is in window', () => { + expect(filterCasesSince([caseDaysAgo('ban', 'A', 30)], 7)).toEqual([]) + }) +}) + +describe('buildDigestEmbed', () => { + it('renders totals, period actions, and top moderators', () => { + const embed = buildDigestEmbed({ + stats: { totalCases: 12, activeCases: 4 }, + cases: [ + caseDaysAgo('warn', 'Alice', 1), + caseDaysAgo('warn', 'Alice', 2), + caseDaysAgo('ban', 'Bob', 3), + ], + days: 7, + }) + const data = embed.toJSON() + expect(data.title).toContain('7 days') + + const fields = data.fields ?? [] + const totalsField = fields.find((f) => f.name.includes('totals')) + expect(totalsField?.value).toContain('12') + expect(totalsField?.value).toContain('4') + + const actionsField = fields.find((f) => f.name.includes('Actions')) + expect(actionsField?.value).toContain('3') + expect(actionsField?.value).toContain('WARN') + + const topField = fields.find((f) => f.name.includes('moderator')) + expect(topField?.value).toContain('Alice') + expect(topField?.value).toContain('Bob') + }) + + it('shows the empty actions message when nothing is in window', () => { + const embed = buildDigestEmbed({ + stats: { totalCases: 5, activeCases: 0 }, + cases: [caseDaysAgo('ban', 'A', 30)], + days: 7, + }) + const fields = embed.toJSON().fields ?? [] + const actionsField = fields.find((f) => f.name.includes('Actions')) + expect(actionsField?.value).toContain('No actions recorded') + }) + + it('omits the moderators field when no period cases exist', () => { + const embed = buildDigestEmbed({ + stats: { totalCases: 5, activeCases: 0 }, + cases: [], + days: 7, + }) + const fields = embed.toJSON().fields ?? [] + const topField = fields.find((f) => f.name.includes('moderator')) + expect(topField).toBeUndefined() + }) + + it('uses singular "action" when a moderator only has one case', () => { + const embed = buildDigestEmbed({ + stats: { totalCases: 1, activeCases: 0 }, + cases: [caseDaysAgo('warn', 'Solo', 1)], + days: 7, + }) + const fields = embed.toJSON().fields ?? [] + const topField = fields.find((f) => f.name.includes('moderator')) + expect(topField?.value).toContain('1 action') + expect(topField?.value).not.toContain('1 actions') + }) +}) diff --git a/packages/bot/src/utils/moderation/digestEmbed.ts b/packages/bot/src/utils/moderation/digestEmbed.ts new file mode 100644 index 000000000..cbf1ee461 --- /dev/null +++ b/packages/bot/src/utils/moderation/digestEmbed.ts @@ -0,0 +1,95 @@ +import { EmbedBuilder } from 'discord.js' + +export type DigestStats = { + totalCases: number + activeCases: number +} + +export type DigestCase = { + type: string + moderatorName: string + createdAt: Date +} + +export type BuildDigestEmbedInput = { + stats: DigestStats + cases: DigestCase[] + days: number +} + +const PERIOD_DAYS_MAP: Record = { + '7d': 7, + '30d': 30, + '90d': 90, +} + +export function resolveDigestPeriodDays(period: string | null | undefined): number { + if (!period) return 7 + return PERIOD_DAYS_MAP[period] ?? 7 +} + +export function filterCasesSince(cases: DigestCase[], days: number): DigestCase[] { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + return cases.filter((c) => c.createdAt >= since) +} + +export function buildDigestEmbed({ + stats, + cases, + days, +}: BuildDigestEmbedInput): EmbedBuilder { + const periodCases = filterCasesSince(cases, days) + + const periodByType: Record = {} + for (const c of periodCases) { + periodByType[c.type] = (periodByType[c.type] ?? 0) + 1 + } + const typeLines = Object.entries(periodByType) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => `â€ĸ **${type.toUpperCase()}**: ${count}`) + .join('\n') + + const topModerators: Record = {} + for (const c of periodCases) { + topModerators[c.moderatorName] = (topModerators[c.moderatorName] ?? 0) + 1 + } + const topModLines = Object.entries(topModerators) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([name, count]) => `â€ĸ **${name}**: ${count} action${count !== 1 ? 's' : ''}`) + .join('\n') + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`📊 Moderation Digest — Last ${days} days`) + .addFields( + { + name: '📈 All-time totals', + value: [ + `Total cases: **${stats.totalCases}**`, + `Active cases: **${stats.activeCases}**`, + ].join('\n'), + inline: false, + }, + { + name: `đŸ—‚ī¸ Actions in the last ${days} days`, + value: + periodCases.length > 0 + ? `**${periodCases.length}** total\n${typeLines}` + : 'No actions recorded.', + inline: false, + }, + ) + + if (topModLines) { + embed.addFields({ + name: '🏅 Top moderators', + value: topModLines, + inline: false, + }) + } + + embed.setTimestamp().setFooter({ text: `Period: last ${days} days` }) + + return embed +} diff --git a/packages/bot/src/utils/moderation/modDigestConfig.spec.ts b/packages/bot/src/utils/moderation/modDigestConfig.spec.ts new file mode 100644 index 000000000..a254119d6 --- /dev/null +++ b/packages/bot/src/utils/moderation/modDigestConfig.spec.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +jest.mock('@lucky/shared/services', () => ({ + redisClient: { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + sadd: jest.fn(), + srem: jest.fn(), + smembers: jest.fn(), + }, +})) + +jest.mock('@lucky/shared/utils', () => ({ + errorLog: jest.fn(), +})) + +import { redisClient } from '@lucky/shared/services' +import { ModDigestConfigService } from './modDigestConfig' + +const redisMock = redisClient as unknown as Record + +function createService() { + return new ModDigestConfigService() +} + +describe('ModDigestConfigService.enable', () => { + beforeEach(() => { + jest.clearAllMocks() + redisMock.get.mockResolvedValue(null) + redisMock.set.mockResolvedValue(true) + redisMock.sadd.mockResolvedValue(1) + }) + + it('writes the config and adds the guild to the index', async () => { + const service = createService() + const config = await service.enable('guild-1', 'channel-1') + + expect(config.guildId).toBe('guild-1') + expect(config.channelId).toBe('channel-1') + expect(config.enabled).toBe(true) + expect(config.lastSentAt).toBeNull() + expect(redisMock.set).toHaveBeenCalledWith( + 'mod-digest:config:guild-1', + expect.any(String), + ) + expect(redisMock.sadd).toHaveBeenCalledWith( + 'mod-digest:enabled-guilds', + 'guild-1', + ) + }) + + it('preserves existing lastSentAt and createdAt when re-enabling', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ + guildId: 'guild-1', + channelId: 'old-channel', + enabled: true, + lastSentAt: 1700000000000, + createdAt: 1690000000000, + }), + ) + + const config = await service.enable('guild-1', 'new-channel') + expect(config.channelId).toBe('new-channel') + expect(config.lastSentAt).toBe(1700000000000) + expect(config.createdAt).toBe(1690000000000) + }) +}) + +describe('ModDigestConfigService.disable', () => { + beforeEach(() => { + jest.clearAllMocks() + redisMock.del.mockResolvedValue(true) + redisMock.srem.mockResolvedValue(1) + }) + + it('removes the config and the index entry', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ + guildId: 'guild-1', + channelId: 'c', + enabled: true, + lastSentAt: null, + createdAt: 1, + }), + ) + + const result = await service.disable('guild-1') + expect(result).toBe(true) + expect(redisMock.del).toHaveBeenCalledWith('mod-digest:config:guild-1') + expect(redisMock.srem).toHaveBeenCalledWith( + 'mod-digest:enabled-guilds', + 'guild-1', + ) + }) + + it('returns false when no config exists', async () => { + const service = createService() + redisMock.get.mockResolvedValue(null) + + const result = await service.disable('guild-1') + expect(result).toBe(false) + expect(redisMock.del).not.toHaveBeenCalled() + }) +}) + +describe('ModDigestConfigService.get', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('parses stored JSON', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ + guildId: 'g', + channelId: 'c', + enabled: true, + lastSentAt: 123, + createdAt: 1, + }), + ) + const config = await service.get('g') + expect(config?.channelId).toBe('c') + expect(config?.lastSentAt).toBe(123) + }) + + it('returns null when key is missing', async () => { + const service = createService() + redisMock.get.mockResolvedValue(null) + const config = await service.get('missing') + expect(config).toBeNull() + }) + + it('returns null and logs on parse error', async () => { + const service = createService() + redisMock.get.mockResolvedValue('not-json') + const config = await service.get('g') + expect(config).toBeNull() + }) +}) + +describe('ModDigestConfigService.listEnabledGuildIds', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns Redis set members', async () => { + const service = createService() + redisMock.smembers.mockResolvedValue(['guild-a', 'guild-b']) + const ids = await service.listEnabledGuildIds() + expect(ids).toEqual(['guild-a', 'guild-b']) + }) + + it('returns empty array on Redis failure', async () => { + const service = createService() + redisMock.smembers.mockRejectedValue(new Error('boom')) + const ids = await service.listEnabledGuildIds() + expect(ids).toEqual([]) + }) +}) + +describe('ModDigestConfigService.markSent', () => { + beforeEach(() => { + jest.clearAllMocks() + redisMock.set.mockResolvedValue(true) + }) + + it('updates lastSentAt when config exists', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ + guildId: 'g', + channelId: 'c', + enabled: true, + lastSentAt: null, + createdAt: 1, + }), + ) + + await service.markSent('g', 999) + + const setCall = redisMock.set.mock.calls[0] + expect(setCall[0]).toBe('mod-digest:config:g') + const stored = JSON.parse(setCall[1] as string) + expect(stored.lastSentAt).toBe(999) + }) + + it('does nothing when config is missing', async () => { + const service = createService() + redisMock.get.mockResolvedValue(null) + await service.markSent('g', 999) + expect(redisMock.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/bot/src/utils/moderation/modDigestConfig.ts b/packages/bot/src/utils/moderation/modDigestConfig.ts new file mode 100644 index 000000000..e2544e0dc --- /dev/null +++ b/packages/bot/src/utils/moderation/modDigestConfig.ts @@ -0,0 +1,80 @@ +import { redisClient } from '@lucky/shared/services' +import { errorLog } from '@lucky/shared/utils' + +export type ModDigestConfig = { + guildId: string + channelId: string + enabled: boolean + lastSentAt: number | null + createdAt: number +} + +const CONFIG_KEY_PREFIX = 'mod-digest:config:' +const INDEX_KEY = 'mod-digest:enabled-guilds' + +export class ModDigestConfigService { + private getConfigKey(guildId: string): string { + return `${CONFIG_KEY_PREFIX}${guildId}` + } + + async enable(guildId: string, channelId: string): Promise { + const existing = await this.get(guildId) + const config: ModDigestConfig = { + guildId, + channelId, + enabled: true, + lastSentAt: existing?.lastSentAt ?? null, + createdAt: existing?.createdAt ?? Date.now(), + } + + await redisClient.set(this.getConfigKey(guildId), JSON.stringify(config)) + await redisClient.sadd(INDEX_KEY, guildId) + return config + } + + async disable(guildId: string): Promise { + const existing = await this.get(guildId) + if (!existing) return false + + await redisClient.del(this.getConfigKey(guildId)) + await redisClient.srem(INDEX_KEY, guildId) + return true + } + + async get(guildId: string): Promise { + try { + const raw = await redisClient.get(this.getConfigKey(guildId)) + if (!raw) return null + return JSON.parse(raw) as ModDigestConfig + } catch (error) { + errorLog({ + message: 'Failed to read mod digest config', + error, + data: { guildId }, + }) + return null + } + } + + async listEnabledGuildIds(): Promise { + try { + return await redisClient.smembers(INDEX_KEY) + } catch (error) { + errorLog({ + message: 'Failed to list enabled mod digest guilds', + error, + }) + return [] + } + } + + async markSent(guildId: string, sentAt: number = Date.now()): Promise { + const existing = await this.get(guildId) + if (!existing) return + + const updated: ModDigestConfig = { ...existing, lastSentAt: sentAt } + await redisClient.set(this.getConfigKey(guildId), JSON.stringify(updated)) + } +} + +export const modDigestConfigService = new ModDigestConfigService() diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts new file mode 100644 index 000000000..d176252cc --- /dev/null +++ b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const moderationServiceMock = { + getStats: jest.fn(), + getRecentCases: jest.fn(), +} + +const modDigestConfigServiceMock = { + listEnabledGuildIds: jest.fn(), + get: jest.fn(), + markSent: jest.fn(), +} + +jest.mock('@lucky/shared/services', () => ({ + moderationService: moderationServiceMock, +})) + +jest.mock('@lucky/shared/utils', () => ({ + debugLog: jest.fn(), + errorLog: jest.fn(), + infoLog: jest.fn(), +})) + +jest.mock('./modDigestConfig', () => ({ + modDigestConfigService: modDigestConfigServiceMock, +})) + +import { ModDigestSchedulerService } from './modDigestScheduler' + +function createTextChannelMock() { + return { + type: 0, // ChannelType.GuildText + send: jest.fn().mockResolvedValue(undefined), + } +} + +function createClientMock(channels: Map = new Map()) { + const guildId = 'guild-1' + const channelId = 'channel-1' + if (!channels.has(channelId)) { + channels.set(channelId, createTextChannelMock()) + } + + return { + guilds: { + cache: { + get: jest.fn((id: string) => + id === guildId + ? { + id: guildId, + channels: { + fetch: jest.fn(async (cid: string) => + channels.get(cid) ?? null, + ), + }, + } + : null, + ), + }, + }, + } +} + +describe('ModDigestSchedulerService.isDue', () => { + it('returns true when lastSentAt is null', () => { + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 0, + }) + expect( + service.isDue({ + guildId: 'g', + channelId: 'c', + enabled: true, + lastSentAt: null, + createdAt: 0, + }), + ).toBe(true) + }) + + it('returns true when more than periodDays have passed', () => { + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000 + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => sevenDaysMs + 1000, + }) + expect( + service.isDue({ + guildId: 'g', + channelId: 'c', + enabled: true, + lastSentAt: 0, + createdAt: 0, + }), + ).toBe(true) + }) + + it('returns false when less than periodDays have passed', () => { + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 60_000, + }) + expect( + service.isDue({ + guildId: 'g', + channelId: 'c', + enabled: true, + lastSentAt: 0, + createdAt: 0, + }), + ).toBe(false) + }) +}) + +describe('ModDigestSchedulerService.tick', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.getStats.mockResolvedValue({ + totalCases: 5, + activeCases: 1, + }) + moderationServiceMock.getRecentCases.mockResolvedValue([ + { + type: 'warn', + moderatorName: 'Alice', + createdAt: new Date(), + }, + ]) + }) + + it('returns 0 when no enabled guilds exist', async () => { + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([]) + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 0, + }) + service.start(createClientMock() as any) + try { + await expect(service.tick()).resolves.toBe(0) + } finally { + service.stop() + } + }) + + it('sends a digest and marks sent for due guilds', async () => { + const channels = new Map() + const client = createClientMock(channels) + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-1', + ]) + modDigestConfigServiceMock.get.mockResolvedValue({ + guildId: 'guild-1', + channelId: 'channel-1', + enabled: true, + lastSentAt: null, + createdAt: 0, + }) + modDigestConfigServiceMock.markSent.mockResolvedValue(undefined) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 1000, + }) + service.start(client as any) + try { + const sent = await service.tick() + expect(sent).toBe(1) + expect(channels.get('channel-1').send).toHaveBeenCalledTimes(1) + expect(modDigestConfigServiceMock.markSent).toHaveBeenCalledWith( + 'guild-1', + 1000, + ) + } finally { + service.stop() + } + }) + + it('skips disabled guilds', async () => { + const channels = new Map() + const client = createClientMock(channels) + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-1', + ]) + modDigestConfigServiceMock.get.mockResolvedValue({ + guildId: 'guild-1', + channelId: 'channel-1', + enabled: false, + lastSentAt: null, + createdAt: 0, + }) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 0, + }) + service.start(client as any) + try { + const sent = await service.tick() + expect(sent).toBe(0) + expect(channels.get('channel-1').send).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it('skips guilds that are not yet due', async () => { + const channels = new Map() + const client = createClientMock(channels) + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-1', + ]) + modDigestConfigServiceMock.get.mockResolvedValue({ + guildId: 'guild-1', + channelId: 'channel-1', + enabled: true, + lastSentAt: 100, + createdAt: 0, + }) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 200, + }) + service.start(client as any) + try { + const sent = await service.tick() + expect(sent).toBe(0) + } finally { + service.stop() + } + }) + + it('returns 0 when started without a client', async () => { + const service = new ModDigestSchedulerService() + await expect(service.tick()).resolves.toBe(0) + }) +}) + +describe('ModDigestSchedulerService.sendDigestForGuild', () => { + beforeEach(() => { + jest.clearAllMocks() + moderationServiceMock.getStats.mockResolvedValue({ + totalCases: 5, + activeCases: 1, + }) + moderationServiceMock.getRecentCases.mockResolvedValue([]) + }) + + it('returns false when client is not started', async () => { + const service = new ModDigestSchedulerService() + const result = await service.sendDigestForGuild('g', 'c') + expect(result).toBe(false) + }) + + it('returns false when guild is not in cache', async () => { + const service = new ModDigestSchedulerService() + service.start({ + guilds: { cache: { get: () => null } }, + } as any) + try { + const result = await service.sendDigestForGuild('missing', 'c') + expect(result).toBe(false) + } finally { + service.stop() + } + }) + + it('returns false when channel fetch returns null', async () => { + const service = new ModDigestSchedulerService() + service.start({ + guilds: { + cache: { + get: () => ({ + channels: { + fetch: jest.fn().mockResolvedValue(null), + }, + }), + }, + }, + } as any) + try { + const result = await service.sendDigestForGuild('g', 'c') + expect(result).toBe(false) + } finally { + service.stop() + } + }) + + it('returns false and swallows errors when send fails', async () => { + const channels = new Map() + channels.set('channel-1', { + type: 0, + send: jest.fn().mockRejectedValue(new Error('discord boom')), + }) + const service = new ModDigestSchedulerService() + service.start(createClientMock(channels) as any) + try { + const result = await service.sendDigestForGuild('guild-1', 'channel-1') + expect(result).toBe(false) + } finally { + service.stop() + } + }) +}) diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.ts b/packages/bot/src/utils/moderation/modDigestScheduler.ts new file mode 100644 index 000000000..be5796f9b --- /dev/null +++ b/packages/bot/src/utils/moderation/modDigestScheduler.ts @@ -0,0 +1,156 @@ +import type { Client, TextChannel } from 'discord.js' +import { ChannelType } from 'discord.js' +import { moderationService } from '@lucky/shared/services' +import { debugLog, errorLog, infoLog } from '@lucky/shared/utils' +import { + modDigestConfigService, + type ModDigestConfig, +} from './modDigestConfig' +import { buildDigestEmbed } from './digestEmbed' + +const DEFAULT_TICK_INTERVAL_MS = 60 * 60 * 1000 +const DEFAULT_PERIOD_DAYS = 7 +const DEFAULT_RECENT_CASE_LIMIT = 500 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +type ModDigestSchedulerOptions = { + tickIntervalMs?: number + periodDays?: number + recentCaseLimit?: number + clock?: () => number +} + +export class ModDigestSchedulerService { + private readonly tickIntervalMs: number + private readonly periodDays: number + private readonly recentCaseLimit: number + private readonly clock: () => number + private timer: ReturnType | null = null + private client: Client | null = null + + constructor(options: ModDigestSchedulerOptions = {}) { + this.tickIntervalMs = + options.tickIntervalMs ?? + parseInt( + process.env.MOD_DIGEST_TICK_INTERVAL_MS ?? + `${DEFAULT_TICK_INTERVAL_MS}`, + 10, + ) + this.periodDays = + options.periodDays ?? + parseInt( + process.env.MOD_DIGEST_PERIOD_DAYS ?? `${DEFAULT_PERIOD_DAYS}`, + 10, + ) + this.recentCaseLimit = options.recentCaseLimit ?? DEFAULT_RECENT_CASE_LIMIT + this.clock = options.clock ?? (() => Date.now()) + } + + start(client: Client): void { + if (this.timer) return + + this.client = client + infoLog({ + message: `Mod digest scheduler started (interval: ${this.tickIntervalMs}ms, period: ${this.periodDays}d)`, + }) + + 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.client) return 0 + + const guildIds = await modDigestConfigService.listEnabledGuildIds() + if (guildIds.length === 0) return 0 + + let sent = 0 + for (const guildId of guildIds) { + const config = await modDigestConfigService.get(guildId) + if (!config?.enabled) continue + if (!this.isDue(config)) continue + + const delivered = await this.sendDigestForGuild(guildId, config.channelId) + if (delivered) { + await modDigestConfigService.markSent(guildId, this.clock()) + sent += 1 + } + } + return sent + } + + isDue(config: ModDigestConfig): boolean { + if (config.lastSentAt === null) return true + const elapsedMs = this.clock() - config.lastSentAt + return elapsedMs >= this.periodDays * MS_PER_DAY + } + + async sendDigestForGuild( + guildId: string, + channelId: string, + ): Promise { + if (!this.client) return false + + try { + const channel = await this.resolveTextChannel(guildId, channelId) + if (!channel) { + errorLog({ + message: 'Mod digest channel unavailable', + data: { guildId, channelId }, + }) + return false + } + + const [stats, recentCases] = await Promise.all([ + moderationService.getStats(guildId), + moderationService.getRecentCases(guildId, this.recentCaseLimit), + ]) + + const embed = buildDigestEmbed({ + stats, + cases: recentCases, + days: this.periodDays, + }) + + await channel.send({ embeds: [embed] }) + debugLog({ + message: 'Mod digest sent', + data: { guildId, channelId, days: this.periodDays }, + }) + return true + } catch (error) { + errorLog({ + message: 'Failed to send mod digest', + error, + data: { guildId, channelId }, + }) + return false + } + } + + private async resolveTextChannel( + guildId: string, + channelId: string, + ): Promise { + if (!this.client) return null + + const guild = this.client.guilds.cache.get(guildId) + if (!guild) return null + + const channel = await guild.channels.fetch(channelId).catch(() => null) + if (!channel || channel.type !== ChannelType.GuildText) return null + + return channel as TextChannel + } +} + +export const modDigestSchedulerService = new ModDigestSchedulerService() From 3c282c7c0bc3c9155a8b65613f93f8a65dba5a0e Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Tue, 7 Apr 2026 19:04:04 -0300 Subject: [PATCH 2/3] fix(mod-digest): address CodeRabbit review on PR #497 - Eliminate enable() lost-update by writing config atomically with optional lastSentAt instead of read-modify-write. - Add type guard for Redis JSON payloads in modDigestConfig.get to reject malformed/missing fields. - Restructure /digest schedule to send-then-enable so the scheduler tick cannot race the sample digest into a duplicate post. - Isolate per-guild failures in scheduler tick() so one bad guild cannot break the whole batch loop. - Add ModerationService.getCasesSince and use it in the scheduler so the weekly window is bounded by date instead of a 500-case truncation. - Move scheduler startup outside the main ready-handler try block so an unrelated upstream failure cannot suppress weekly digests for the entire process; cover the new behavior with two new clientHandler tests. - Use createUserFriendlyError for all /digest catch responses instead of hard-coded strings. - Strengthen the "defaults to 7d period" test with cases that straddle the boundary so 30d/90d regressions would actually fail. --- .../moderation/commands/digest.spec.ts | 70 +++++++---- .../functions/moderation/commands/digest.ts | 28 +++-- .../handlers/clientHandler/service.spec.ts | 68 +++++++++++ .../bot/src/handlers/clientHandler/service.ts | 13 +- .../utils/moderation/modDigestConfig.spec.ts | 55 ++++++--- .../src/utils/moderation/modDigestConfig.ts | 56 +++++++-- .../moderation/modDigestScheduler.spec.ts | 112 +++++++++++++++++- .../utils/moderation/modDigestScheduler.ts | 40 ++++--- .../shared/src/services/ModerationService.ts | 10 ++ 9 files changed, 376 insertions(+), 76 deletions(-) diff --git a/packages/bot/src/functions/moderation/commands/digest.spec.ts b/packages/bot/src/functions/moderation/commands/digest.spec.ts index 202fae27a..5fe6ab6a7 100644 --- a/packages/bot/src/functions/moderation/commands/digest.spec.ts +++ b/packages/bot/src/functions/moderation/commands/digest.spec.ts @@ -10,6 +10,7 @@ const enableMock = jest.fn() const disableMock = jest.fn() const markSentMock = jest.fn() const sendDigestForGuildMock = jest.fn() +const createUserFriendlyErrorMock = jest.fn() jest.mock('@lucky/shared/services', () => ({ moderationService: { @@ -27,6 +28,11 @@ jest.mock('../../../utils/general/interactionReply', () => ({ interactionReply: (...args: unknown[]) => interactionReplyMock(...args), })) +jest.mock('../../../utils/general/errorSanitizer', () => ({ + createUserFriendlyError: (...args: unknown[]) => + createUserFriendlyErrorMock(...args), +})) + jest.mock('../../../utils/moderation/modDigestConfig', () => ({ modDigestConfigService: { enable: (...args: unknown[]) => enableMock(...args), @@ -78,6 +84,7 @@ describe('digest command', () => { interactionReplyMock.mockResolvedValue(undefined) infoLogMock.mockReturnValue(undefined) errorLogMock.mockReturnValue(undefined) + createUserFriendlyErrorMock.mockReturnValue('Friendly error') }) it('has correct command name and description', () => { @@ -117,14 +124,24 @@ describe('digest command', () => { }) it('defaults to 7d period when no option provided', async () => { + // Two cases straddle the 7-day boundary: one inside (3d) and one outside (10d). + // A 7d default should count exactly 1; 30d/90d would count 2. getStatsMock.mockResolvedValue({ totalCases: 5, activeCases: 0 }) - getRecentCasesMock.mockResolvedValue([makeCase('KICK', 'Mod1', 2)]) + getRecentCasesMock.mockResolvedValue([ + makeCase('KICK', 'Mod1', 3), + makeCase('BAN', 'Mod2', 10), + ]) const interaction = createInteraction({ subcommand: 'view' }) await digestCommand.execute({ interaction, client: {} as any }) const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.embeds).toHaveLength(1) + const embed = replyArg.content.embeds[0] + expect(embed.data?.title).toContain('7 days') + const fields = embed.data?.fields ?? [] + const actionsField = fields.find((f: any) => f.name.includes('Actions')) + // "**1** total" — only the 3-day-old case is in window + expect(actionsField?.value).toContain('**1**') }) it('falls back to view when no subcommand is supplied', async () => { @@ -138,7 +155,7 @@ describe('digest command', () => { expect(interactionReplyMock).toHaveBeenCalledTimes(1) }) - it('replies with error message when service throws', async () => { + it('replies with friendly error when service throws', async () => { getStatsMock.mockRejectedValue(new Error('DB error')) getRecentCasesMock.mockResolvedValue([]) @@ -146,44 +163,55 @@ describe('digest command', () => { await digestCommand.execute({ interaction, client: {} as any }) const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.content).toContain('Failed') + expect(replyArg.content.content).toBe('Friendly error') expect(errorLogMock).toHaveBeenCalledTimes(1) }) }) describe('schedule subcommand', () => { - it('enables config and posts a sample digest', async () => { - enableMock.mockResolvedValue({}) - sendDigestForGuildMock.mockResolvedValue(true) - markSentMock.mockResolvedValue(undefined) + it('sends the sample digest first, then enables with lastSentAt set', async () => { + const callOrder: string[] = [] + sendDigestForGuildMock.mockImplementation(async () => { + callOrder.push('send') + return true + }) + enableMock.mockImplementation(async () => { + callOrder.push('enable') + return {} + }) const channel = { id: 'channel-1', type: 0 } const interaction = createInteraction({ subcommand: 'schedule', channel }) await digestCommand.execute({ interaction, client: {} as any }) - expect(enableMock).toHaveBeenCalledWith( - '123456789012345678', - 'channel-1', - ) + expect(callOrder).toEqual(['send', 'enable']) expect(sendDigestForGuildMock).toHaveBeenCalledWith( '123456789012345678', 'channel-1', ) - expect(markSentMock).toHaveBeenCalledWith('123456789012345678') + const enableArg = enableMock.mock.calls[0][0] as any + expect(enableArg.guildId).toBe('123456789012345678') + expect(enableArg.channelId).toBe('channel-1') + expect(typeof enableArg.lastSentAt).toBe('number') + // markSent is no longer needed — enable wrote lastSentAt atomically + expect(markSentMock).not.toHaveBeenCalled() + const replyArg = interactionReplyMock.mock.calls[0][0] as any expect(replyArg.content.content).toContain('scheduled') expect(replyArg.content.content).toContain('sample digest') }) - it('reports a partial success when sample digest fails to post', async () => { - enableMock.mockResolvedValue({}) + it('still enables (with lastSentAt=null) when the sample digest fails', async () => { sendDigestForGuildMock.mockResolvedValue(false) + enableMock.mockResolvedValue({}) const channel = { id: 'channel-2', type: 0 } const interaction = createInteraction({ subcommand: 'schedule', channel }) await digestCommand.execute({ interaction, client: {} as any }) - expect(markSentMock).not.toHaveBeenCalled() + const enableArg = enableMock.mock.calls[0][0] as any + expect(enableArg.lastSentAt).toBeNull() + const replyArg = interactionReplyMock.mock.calls[0][0] as any expect(replyArg.content.content).toContain('schedule is active') }) @@ -194,11 +222,13 @@ describe('digest command', () => { await digestCommand.execute({ interaction, client: {} as any }) expect(enableMock).not.toHaveBeenCalled() + expect(sendDigestForGuildMock).not.toHaveBeenCalled() const replyArg = interactionReplyMock.mock.calls[0][0] as any expect(replyArg.content.content).toContain('text channel') }) - it('replies with error when enable throws', async () => { + it('replies with friendly error when enable throws', async () => { + sendDigestForGuildMock.mockResolvedValue(true) enableMock.mockRejectedValue(new Error('redis down')) const channel = { id: 'channel-1', type: 0 } const interaction = createInteraction({ subcommand: 'schedule', channel }) @@ -206,7 +236,7 @@ describe('digest command', () => { await digestCommand.execute({ interaction, client: {} as any }) const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.content).toContain('Failed') + expect(replyArg.content.content).toBe('Friendly error') expect(errorLogMock).toHaveBeenCalled() }) }) @@ -233,14 +263,14 @@ describe('digest command', () => { expect(replyArg.content.content).toContain('No active digest') }) - it('replies with error when disable throws', async () => { + it('replies with friendly error when disable throws', async () => { disableMock.mockRejectedValue(new Error('redis down')) const interaction = createInteraction({ subcommand: 'unschedule' }) await digestCommand.execute({ interaction, client: {} as any }) const replyArg = interactionReplyMock.mock.calls[0][0] as any - expect(replyArg.content.content).toContain('Failed') + expect(replyArg.content.content).toBe('Friendly error') expect(errorLogMock).toHaveBeenCalled() }) }) diff --git a/packages/bot/src/functions/moderation/commands/digest.ts b/packages/bot/src/functions/moderation/commands/digest.ts index 0c29392f7..5be4095cf 100644 --- a/packages/bot/src/functions/moderation/commands/digest.ts +++ b/packages/bot/src/functions/moderation/commands/digest.ts @@ -9,6 +9,7 @@ import Command from '../../../models/Command.js' import { moderationService } from '@lucky/shared/services' import { infoLog, errorLog } from '@lucky/shared/utils' import { interactionReply } from '../../../utils/general/interactionReply.js' +import { createUserFriendlyError } from '../../../utils/general/errorSanitizer.js' import { buildDigestEmbed, resolveDigestPeriodDays, @@ -16,6 +17,8 @@ import { import { modDigestConfigService } from '../../../utils/moderation/modDigestConfig.js' import { modDigestSchedulerService } from '../../../utils/moderation/modDigestScheduler.js' +const VIEW_RECENT_CASE_LIMIT = 500 + export default new Command({ data: new SlashCommandBuilder() .setName('digest') @@ -92,7 +95,7 @@ async function handleView( const guildId = interaction.guild!.id const [stats, recentCases] = await Promise.all([ moderationService.getStats(guildId), - moderationService.getRecentCases(guildId, 500), + moderationService.getRecentCases(guildId, VIEW_RECENT_CASE_LIMIT), ]) const embed = buildDigestEmbed({ stats, cases: recentCases, days }) @@ -106,7 +109,7 @@ async function handleView( errorLog({ message: 'Failed to generate mod digest', error: error as Error }) await interactionReply({ interaction, - content: { content: '❌ Failed to generate digest. Please try again.' }, + content: { content: createUserFriendlyError(error) }, }) } } @@ -127,14 +130,19 @@ async function handleSchedule( const channelId = (channel as TextChannel).id try { - await modDigestConfigService.enable(guildId, channelId) + // Send the sample digest BEFORE persisting the schedule. This guarantees + // that the scheduler tick can never see the guild as enabled+due-now + // until we've already accounted for the sample post by writing + // lastSentAt atomically with enable() below. const sent = await modDigestSchedulerService.sendDigestForGuild( guildId, channelId, ) - if (sent) { - await modDigestConfigService.markSent(guildId) - } + await modDigestConfigService.enable({ + guildId, + channelId, + lastSentAt: sent ? Date.now() : null, + }) await interactionReply({ interaction, @@ -154,9 +162,7 @@ async function handleSchedule( errorLog({ message: 'Failed to schedule mod digest', error: error as Error }) await interactionReply({ interaction, - content: { - content: '❌ Failed to schedule the digest. Please try again.', - }, + content: { content: createUserFriendlyError(error) }, }) } } @@ -185,9 +191,7 @@ async function handleUnschedule( errorLog({ message: 'Failed to unschedule mod digest', error: error as Error }) await interactionReply({ interaction, - content: { - content: '❌ Failed to disable the digest. Please try again.', - }, + content: { content: createUserFriendlyError(error) }, }) } } diff --git a/packages/bot/src/handlers/clientHandler/service.spec.ts b/packages/bot/src/handlers/clientHandler/service.spec.ts index 29a8d99e5..bad5ce9ec 100644 --- a/packages/bot/src/handlers/clientHandler/service.spec.ts +++ b/packages/bot/src/handlers/clientHandler/service.spec.ts @@ -274,5 +274,73 @@ describe('service', () => { error: expect.any(Error), }) }) + + it('starts the mod digest scheduler in the ready handler', async () => { + const { modDigestSchedulerService } = await import( + '../../utils/moderation/modDigestScheduler' + ) + ;(modDigestSchedulerService.start as jest.Mock).mockClear() + + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockReturnValue([]), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + const startPromise = startClient({ client: mockClient as any }) + await new Promise((resolve) => setImmediate(resolve)) + await startPromise + + expect(modDigestSchedulerService.start).toHaveBeenCalledWith( + mockClient, + ) + }) + + it('still starts the scheduler when an upstream ready step fails', async () => { + const { modDigestSchedulerService } = await import( + '../../utils/moderation/modDigestScheduler' + ) + ;(modDigestSchedulerService.start as jest.Mock).mockClear() + + const mockClient = { + login: jest.fn().mockResolvedValue('client'), + once: jest.fn((event, handler) => { + if (event === 'ready') { + Promise.resolve().then(() => handler()) + } + }), + user: null, + commands: { + map: jest.fn().mockImplementation(() => { + throw new Error('upstream boom') + }), + }, + guilds: { + cache: { + values: jest.fn().mockReturnValue([]), + }, + }, + } + + const startPromise = startClient({ client: mockClient as any }) + await new Promise((resolve) => setImmediate(resolve)) + await startPromise + + expect(modDigestSchedulerService.start).toHaveBeenCalledWith( + mockClient, + ) + }) }) }) diff --git a/packages/bot/src/handlers/clientHandler/service.ts b/packages/bot/src/handlers/clientHandler/service.ts index 48d2c9073..a2c479074 100644 --- a/packages/bot/src/handlers/clientHandler/service.ts +++ b/packages/bot/src/handlers/clientHandler/service.ts @@ -80,14 +80,25 @@ export async function startClient({ const { startTwitchService } = await import('../../twitch/index.js') await startTwitchService(client) + } catch (error) { + errorLog({ + message: 'Error in ready handler:', + error, + }) + } + // Run the digest scheduler startup independently so an upstream + // failure (command registration, twitch service) cannot suppress + // weekly digests for the entire process. + try { modDigestSchedulerService.start(client) } catch (error) { errorLog({ - message: 'Error in ready handler:', + message: 'Failed to start mod digest scheduler', error, }) } + resolve() }) }) diff --git a/packages/bot/src/utils/moderation/modDigestConfig.spec.ts b/packages/bot/src/utils/moderation/modDigestConfig.spec.ts index a254119d6..5b7ece526 100644 --- a/packages/bot/src/utils/moderation/modDigestConfig.spec.ts +++ b/packages/bot/src/utils/moderation/modDigestConfig.spec.ts @@ -34,7 +34,10 @@ describe('ModDigestConfigService.enable', () => { it('writes the config and adds the guild to the index', async () => { const service = createService() - const config = await service.enable('guild-1', 'channel-1') + const config = await service.enable({ + guildId: 'guild-1', + channelId: 'channel-1', + }) expect(config.guildId).toBe('guild-1') expect(config.channelId).toBe('channel-1') @@ -50,22 +53,18 @@ describe('ModDigestConfigService.enable', () => { ) }) - it('preserves existing lastSentAt and createdAt when re-enabling', async () => { + it('persists the supplied lastSentAt and createdAt without read-back', async () => { const service = createService() - redisMock.get.mockResolvedValue( - JSON.stringify({ - guildId: 'guild-1', - channelId: 'old-channel', - enabled: true, - lastSentAt: 1700000000000, - createdAt: 1690000000000, - }), - ) - - const config = await service.enable('guild-1', 'new-channel') - expect(config.channelId).toBe('new-channel') - expect(config.lastSentAt).toBe(1700000000000) - expect(config.createdAt).toBe(1690000000000) + const config = await service.enable({ + guildId: 'guild-1', + channelId: 'channel-1', + lastSentAt: 1234, + createdAt: 5678, + }) + expect(config.lastSentAt).toBe(1234) + expect(config.createdAt).toBe(5678) + // No read of the existing config — enable now writes atomically. + expect(redisMock.get).not.toHaveBeenCalled() }) }) @@ -141,6 +140,30 @@ describe('ModDigestConfigService.get', () => { const config = await service.get('g') expect(config).toBeNull() }) + + it('rejects payloads with missing fields', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ guildId: 'g', channelId: 'c' }), + ) + const config = await service.get('g') + expect(config).toBeNull() + }) + + it('rejects payloads with wrong field types', async () => { + const service = createService() + redisMock.get.mockResolvedValue( + JSON.stringify({ + guildId: 'g', + channelId: 'c', + enabled: 'yes', + lastSentAt: null, + createdAt: 1, + }), + ) + const config = await service.get('g') + expect(config).toBeNull() + }) }) describe('ModDigestConfigService.listEnabledGuildIds', () => { diff --git a/packages/bot/src/utils/moderation/modDigestConfig.ts b/packages/bot/src/utils/moderation/modDigestConfig.ts index e2544e0dc..00ccc4b31 100644 --- a/packages/bot/src/utils/moderation/modDigestConfig.ts +++ b/packages/bot/src/utils/moderation/modDigestConfig.ts @@ -9,26 +9,55 @@ export type ModDigestConfig = { createdAt: number } +export type EnableModDigestInput = { + guildId: string + channelId: string + lastSentAt?: number | null + createdAt?: number +} + const CONFIG_KEY_PREFIX = 'mod-digest:config:' const INDEX_KEY = 'mod-digest:enabled-guilds' +function isModDigestConfig(value: unknown): value is ModDigestConfig { + if (!value || typeof value !== 'object') return false + + const candidate = value as Record + return ( + typeof candidate.guildId === 'string' && + typeof candidate.channelId === 'string' && + typeof candidate.enabled === 'boolean' && + (candidate.lastSentAt === null || + typeof candidate.lastSentAt === 'number') && + typeof candidate.createdAt === 'number' + ) +} + export class ModDigestConfigService { private getConfigKey(guildId: string): string { return `${CONFIG_KEY_PREFIX}${guildId}` } - async enable(guildId: string, channelId: string): Promise { - const existing = await this.get(guildId) + /** + * Persist a guild's digest config and add it to the enabled-guilds index. + * Callers can pre-populate `lastSentAt` (used by /digest schedule to write + * the post-sample timestamp atomically with enabling, eliminating the + * scheduler-tick race window). + */ + async enable(input: EnableModDigestInput): Promise { const config: ModDigestConfig = { - guildId, - channelId, + guildId: input.guildId, + channelId: input.channelId, enabled: true, - lastSentAt: existing?.lastSentAt ?? null, - createdAt: existing?.createdAt ?? Date.now(), + lastSentAt: input.lastSentAt ?? null, + createdAt: input.createdAt ?? Date.now(), } - await redisClient.set(this.getConfigKey(guildId), JSON.stringify(config)) - await redisClient.sadd(INDEX_KEY, guildId) + await redisClient.set( + this.getConfigKey(input.guildId), + JSON.stringify(config), + ) + await redisClient.sadd(INDEX_KEY, input.guildId) return config } @@ -45,7 +74,16 @@ export class ModDigestConfigService { try { const raw = await redisClient.get(this.getConfigKey(guildId)) if (!raw) return null - return JSON.parse(raw) as ModDigestConfig + + const parsed: unknown = JSON.parse(raw) + if (!isModDigestConfig(parsed)) { + errorLog({ + message: 'Mod digest config payload failed validation', + data: { guildId }, + }) + return null + } + return parsed } catch (error) { errorLog({ message: 'Failed to read mod digest config', diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts index d176252cc..aaf11ca7b 100644 --- a/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts +++ b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' const moderationServiceMock = { getStats: jest.fn(), - getRecentCases: jest.fn(), + getCasesSince: jest.fn(), } const modDigestConfigServiceMock = { @@ -119,7 +119,7 @@ describe('ModDigestSchedulerService.tick', () => { totalCases: 5, activeCases: 1, }) - moderationServiceMock.getRecentCases.mockResolvedValue([ + moderationServiceMock.getCasesSince.mockResolvedValue([ { type: 'warn', moderatorName: 'Alice', @@ -234,6 +234,112 @@ describe('ModDigestSchedulerService.tick', () => { const service = new ModDigestSchedulerService() await expect(service.tick()).resolves.toBe(0) }) + + it('continues processing remaining guilds when one fails', async () => { + const channels = new Map() + const goodChannel = createTextChannelMock() + channels.set('channel-good', goodChannel) + + const client = { + guilds: { + cache: { + get: jest.fn((id: string) => { + if (id === 'guild-bad') { + return { + channels: { + fetch: jest.fn(async () => null), + }, + } + } + if (id === 'guild-good') { + return { + channels: { + fetch: jest.fn(async () => goodChannel), + }, + } + } + return null + }), + }, + }, + } + + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-bad', + 'guild-good', + ]) + modDigestConfigServiceMock.get.mockImplementation(async (id: string) => ({ + guildId: id, + channelId: id === 'guild-good' ? 'channel-good' : 'channel-bad', + enabled: true, + lastSentAt: null, + createdAt: 0, + })) + modDigestConfigServiceMock.markSent.mockResolvedValue(undefined) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 1000, + }) + service.start(client as any) + try { + const sent = await service.tick() + expect(sent).toBe(1) + expect(goodChannel.send).toHaveBeenCalledTimes(1) + expect(modDigestConfigServiceMock.markSent).toHaveBeenCalledWith( + 'guild-good', + 1000, + ) + } finally { + service.stop() + } + }) + + it('isolates per-guild errors so the loop keeps running', async () => { + const channels = new Map() + const goodChannel = createTextChannelMock() + channels.set('channel-good', goodChannel) + + const client = { + guilds: { + cache: { + get: () => ({ + channels: { + fetch: jest.fn(async () => goodChannel), + }, + }), + }, + }, + } + + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-throw', + 'guild-ok', + ]) + modDigestConfigServiceMock.get.mockImplementation(async (id: string) => { + if (id === 'guild-throw') throw new Error('lookup boom') + return { + guildId: id, + channelId: 'channel-good', + enabled: true, + lastSentAt: null, + createdAt: 0, + } + }) + modDigestConfigServiceMock.markSent.mockResolvedValue(undefined) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 1000, + }) + service.start(client as any) + try { + const sent = await service.tick() + expect(sent).toBe(1) + } finally { + service.stop() + } + }) }) describe('ModDigestSchedulerService.sendDigestForGuild', () => { @@ -243,7 +349,7 @@ describe('ModDigestSchedulerService.sendDigestForGuild', () => { totalCases: 5, activeCases: 1, }) - moderationServiceMock.getRecentCases.mockResolvedValue([]) + moderationServiceMock.getCasesSince.mockResolvedValue([]) }) it('returns false when client is not started', async () => { diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.ts b/packages/bot/src/utils/moderation/modDigestScheduler.ts index be5796f9b..851ec6d97 100644 --- a/packages/bot/src/utils/moderation/modDigestScheduler.ts +++ b/packages/bot/src/utils/moderation/modDigestScheduler.ts @@ -10,20 +10,17 @@ import { buildDigestEmbed } from './digestEmbed' const DEFAULT_TICK_INTERVAL_MS = 60 * 60 * 1000 const DEFAULT_PERIOD_DAYS = 7 -const DEFAULT_RECENT_CASE_LIMIT = 500 const MS_PER_DAY = 24 * 60 * 60 * 1000 type ModDigestSchedulerOptions = { tickIntervalMs?: number periodDays?: number - recentCaseLimit?: number clock?: () => number } export class ModDigestSchedulerService { private readonly tickIntervalMs: number private readonly periodDays: number - private readonly recentCaseLimit: number private readonly clock: () => number private timer: ReturnType | null = null private client: Client | null = null @@ -42,7 +39,6 @@ export class ModDigestSchedulerService { process.env.MOD_DIGEST_PERIOD_DAYS ?? `${DEFAULT_PERIOD_DAYS}`, 10, ) - this.recentCaseLimit = options.recentCaseLimit ?? DEFAULT_RECENT_CASE_LIMIT this.clock = options.clock ?? (() => Date.now()) } @@ -75,14 +71,25 @@ export class ModDigestSchedulerService { let sent = 0 for (const guildId of guildIds) { - const config = await modDigestConfigService.get(guildId) - if (!config?.enabled) continue - if (!this.isDue(config)) continue - - const delivered = await this.sendDigestForGuild(guildId, config.channelId) - if (delivered) { - await modDigestConfigService.markSent(guildId, this.clock()) - sent += 1 + try { + const config = await modDigestConfigService.get(guildId) + if (!config?.enabled) continue + if (!this.isDue(config)) continue + + const delivered = await this.sendDigestForGuild( + guildId, + config.channelId, + ) + if (delivered) { + await modDigestConfigService.markSent(guildId, this.clock()) + sent += 1 + } + } catch (error) { + errorLog({ + message: 'Mod digest tick failed for guild', + error, + data: { guildId }, + }) } } return sent @@ -110,14 +117,17 @@ export class ModDigestSchedulerService { return false } - const [stats, recentCases] = await Promise.all([ + const since = new Date( + this.clock() - this.periodDays * MS_PER_DAY, + ) + const [stats, periodCases] = await Promise.all([ moderationService.getStats(guildId), - moderationService.getRecentCases(guildId, this.recentCaseLimit), + moderationService.getCasesSince(guildId, since), ]) const embed = buildDigestEmbed({ stats, - cases: recentCases, + cases: periodCases, days: this.periodDays, }) diff --git a/packages/shared/src/services/ModerationService.ts b/packages/shared/src/services/ModerationService.ts index 2a9098d3e..f2bb6fa61 100644 --- a/packages/shared/src/services/ModerationService.ts +++ b/packages/shared/src/services/ModerationService.ts @@ -125,6 +125,16 @@ export class ModerationService { }) } + async getCasesSince( + guildId: string, + since: Date, + ): Promise { + return await prisma.moderationCase.findMany({ + where: { guildId, createdAt: { gte: since } }, + orderBy: { createdAt: 'desc' }, + }) + } + async getActiveWarningsCount( guildId: string, userId: string, From a01c18821aee779234b6ef9b5191fbee238785e7 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Tue, 7 Apr 2026 19:18:22 -0300 Subject: [PATCH 3/3] fix(mod-digest): address second CodeRabbit pass on PR #497 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Serialize tick() with a tickInProgress guard so two overlapping ticks cannot both pass the isDue check and double-deliver a digest. Add a regression test that fires two concurrent tick() calls and asserts exactly one delivery + one markSent. - Validate MOD_DIGEST_TICK_INTERVAL_MS / MOD_DIGEST_PERIOD_DAYS env values via parsePositiveIntEnv: NaN or non-positive parses fall back to the default and emit an errorLog instead of poisoning the timer. - Switch /digest view to moderationService.getCasesSince(cutoff) so the command and the scheduler share the same cutoff-shaped query — kills the 500-row truncation in the view path. - Add Prisma composite index moderation_cases_guildId_createdAt_idx so the new getCasesSince query is index-served instead of doing a heap scan filtered in memory. - createInteraction test helper now preserves an explicit { subcommand: null } so the missing-subcommand fallback (getSubcommand(false) ?? 'view') is actually exercised, not silently rewritten to 'view' by ??. --- .../moderation/commands/digest.spec.ts | 59 +++++++++--- .../functions/moderation/commands/digest.ts | 9 +- .../moderation/modDigestScheduler.spec.ts | 39 ++++++++ .../utils/moderation/modDigestScheduler.ts | 93 ++++++++++++------- .../migration.sql | 2 + prisma/schema.prisma | 1 + 6 files changed, 156 insertions(+), 47 deletions(-) create mode 100644 prisma/migrations/20260407000000_add_moderation_cases_guild_created_at_index/migration.sql diff --git a/packages/bot/src/functions/moderation/commands/digest.spec.ts b/packages/bot/src/functions/moderation/commands/digest.spec.ts index 5fe6ab6a7..bab070dac 100644 --- a/packages/bot/src/functions/moderation/commands/digest.spec.ts +++ b/packages/bot/src/functions/moderation/commands/digest.spec.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' import digestCommand from './digest' const getStatsMock = jest.fn() -const getRecentCasesMock = jest.fn() +const getCasesSinceMock = jest.fn() const interactionReplyMock = jest.fn() const infoLogMock = jest.fn() const errorLogMock = jest.fn() @@ -15,7 +15,7 @@ const createUserFriendlyErrorMock = jest.fn() jest.mock('@lucky/shared/services', () => ({ moderationService: { getStats: (...args: unknown[]) => getStatsMock(...args), - getRecentCases: (...args: unknown[]) => getRecentCasesMock(...args), + getCasesSince: (...args: unknown[]) => getCasesSinceMock(...args), }, })) @@ -61,7 +61,14 @@ type InteractionOverrides = { } function createInteraction(overrides: InteractionOverrides = {}) { - const subcommand = overrides.subcommand ?? 'view' + // Preserve explicit `null` so the missing-subcommand fallback (which the + // command resolves with `getSubcommand(false) ?? 'view'`) is exercised. + const subcommand = Object.prototype.hasOwnProperty.call( + overrides, + 'subcommand', + ) + ? overrides.subcommand + : 'view' return { guild: overrides.guild === undefined @@ -104,7 +111,7 @@ describe('digest command', () => { describe('view subcommand', () => { it('replies with embed when cases exist in period', async () => { getStatsMock.mockResolvedValue({ totalCases: 10, activeCases: 2 }) - getRecentCasesMock.mockResolvedValue([ + getCasesSinceMock.mockResolvedValue([ makeCase('WARN', 'Mod1', 3), makeCase('BAN', 'Mod2', 5), ]) @@ -113,21 +120,49 @@ describe('digest command', () => { await digestCommand.execute({ interaction, client: {} as any }) expect(getStatsMock).toHaveBeenCalledWith('123456789012345678') - expect(getRecentCasesMock).toHaveBeenCalledWith( - '123456789012345678', - 500, - ) + // getCasesSince is called with a Date cutoff derived from `days`, + // not a hard-coded limit. Verify guildId + that arg 2 is a Date. + expect(getCasesSinceMock).toHaveBeenCalledTimes(1) + const [guildArg, sinceArg] = getCasesSinceMock.mock.calls[0] + expect(guildArg).toBe('123456789012345678') + expect(sinceArg).toBeInstanceOf(Date) expect(interactionReplyMock).toHaveBeenCalledTimes(1) const replyArg = interactionReplyMock.mock.calls[0][0] as any expect(replyArg.content.embeds).toHaveLength(1) expect(infoLogMock).toHaveBeenCalledTimes(1) }) + it('uses a 7-day cutoff when period is 7d', async () => { + // The view path now bounds the query by date, not by row count. + // This test pins the behaviour: cutoff ≈ now - 7 days. + getStatsMock.mockResolvedValue({ totalCases: 0, activeCases: 0 }) + getCasesSinceMock.mockResolvedValue([]) + + const before = Date.now() + const interaction = createInteraction({ + subcommand: 'view', + period: '7d', + }) + await digestCommand.execute({ interaction, client: {} as any }) + const after = Date.now() + + const sinceArg = getCasesSinceMock.mock.calls[0][1] as Date + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000 + expect(sinceArg.getTime()).toBeGreaterThanOrEqual( + before - sevenDaysMs, + ) + expect(sinceArg.getTime()).toBeLessThanOrEqual( + after - sevenDaysMs, + ) + }) + it('defaults to 7d period when no option provided', async () => { // Two cases straddle the 7-day boundary: one inside (3d) and one outside (10d). - // A 7d default should count exactly 1; 30d/90d would count 2. + // The DB query is mocked to return both; buildDigestEmbed re-filters + // by days, so a 7d default should count exactly 1 (the 3d case). + // 30d/90d would count 2 — this test pins the default at 7. getStatsMock.mockResolvedValue({ totalCases: 5, activeCases: 0 }) - getRecentCasesMock.mockResolvedValue([ + getCasesSinceMock.mockResolvedValue([ makeCase('KICK', 'Mod1', 3), makeCase('BAN', 'Mod2', 10), ]) @@ -146,7 +181,7 @@ describe('digest command', () => { it('falls back to view when no subcommand is supplied', async () => { getStatsMock.mockResolvedValue({ totalCases: 0, activeCases: 0 }) - getRecentCasesMock.mockResolvedValue([]) + getCasesSinceMock.mockResolvedValue([]) const interaction = createInteraction({ subcommand: null }) await digestCommand.execute({ interaction, client: {} as any }) @@ -157,7 +192,7 @@ describe('digest command', () => { it('replies with friendly error when service throws', async () => { getStatsMock.mockRejectedValue(new Error('DB error')) - getRecentCasesMock.mockResolvedValue([]) + getCasesSinceMock.mockResolvedValue([]) const interaction = createInteraction({ subcommand: 'view', period: '7d' }) await digestCommand.execute({ interaction, client: {} as any }) diff --git a/packages/bot/src/functions/moderation/commands/digest.ts b/packages/bot/src/functions/moderation/commands/digest.ts index 5be4095cf..de0b62590 100644 --- a/packages/bot/src/functions/moderation/commands/digest.ts +++ b/packages/bot/src/functions/moderation/commands/digest.ts @@ -17,7 +17,7 @@ import { import { modDigestConfigService } from '../../../utils/moderation/modDigestConfig.js' import { modDigestSchedulerService } from '../../../utils/moderation/modDigestScheduler.js' -const VIEW_RECENT_CASE_LIMIT = 500 +const MS_PER_DAY = 24 * 60 * 60 * 1000 export default new Command({ data: new SlashCommandBuilder() @@ -93,12 +93,13 @@ async function handleView( try { const guildId = interaction.guild!.id - const [stats, recentCases] = await Promise.all([ + const since = new Date(Date.now() - days * MS_PER_DAY) + const [stats, periodCases] = await Promise.all([ moderationService.getStats(guildId), - moderationService.getRecentCases(guildId, VIEW_RECENT_CASE_LIMIT), + moderationService.getCasesSince(guildId, since), ]) - const embed = buildDigestEmbed({ stats, cases: recentCases, days }) + const embed = buildDigestEmbed({ stats, cases: periodCases, days }) await interactionReply({ interaction, content: { embeds: [embed] } }) diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts index aaf11ca7b..9cc845ba7 100644 --- a/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts +++ b/packages/bot/src/utils/moderation/modDigestScheduler.spec.ts @@ -340,6 +340,45 @@ describe('ModDigestSchedulerService.tick', () => { service.stop() } }) + + it('serializes overlapping ticks so a guild is not double-sent', async () => { + const channels = new Map() + const channel = createTextChannelMock() + channels.set('channel-1', channel) + const client = createClientMock(channels) + + modDigestConfigServiceMock.listEnabledGuildIds.mockResolvedValue([ + 'guild-1', + ]) + modDigestConfigServiceMock.get.mockResolvedValue({ + guildId: 'guild-1', + channelId: 'channel-1', + enabled: true, + lastSentAt: null, + createdAt: 0, + }) + modDigestConfigServiceMock.markSent.mockResolvedValue(undefined) + + const service = new ModDigestSchedulerService({ + periodDays: 7, + clock: () => 1000, + }) + service.start(client as any) + try { + // Fire two ticks back-to-back without awaiting the first. + // The second one must short-circuit (return 0) because the + // first is still in flight, otherwise both would deliver. + const [first, second] = await Promise.all([ + service.tick(), + service.tick(), + ]) + expect(first + second).toBe(1) + expect(channel.send).toHaveBeenCalledTimes(1) + expect(modDigestConfigServiceMock.markSent).toHaveBeenCalledTimes(1) + } finally { + service.stop() + } + }) }) describe('ModDigestSchedulerService.sendDigestForGuild', () => { diff --git a/packages/bot/src/utils/moderation/modDigestScheduler.ts b/packages/bot/src/utils/moderation/modDigestScheduler.ts index 851ec6d97..44a35fd22 100644 --- a/packages/bot/src/utils/moderation/modDigestScheduler.ts +++ b/packages/bot/src/utils/moderation/modDigestScheduler.ts @@ -18,26 +18,45 @@ type ModDigestSchedulerOptions = { clock?: () => 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 ModDigestSchedulerService { private readonly tickIntervalMs: number private readonly periodDays: number private readonly clock: () => number private timer: ReturnType | null = null private client: Client | null = null + private tickInProgress = false constructor(options: ModDigestSchedulerOptions = {}) { this.tickIntervalMs = options.tickIntervalMs ?? - parseInt( - process.env.MOD_DIGEST_TICK_INTERVAL_MS ?? - `${DEFAULT_TICK_INTERVAL_MS}`, - 10, + parsePositiveIntEnv( + process.env.MOD_DIGEST_TICK_INTERVAL_MS, + DEFAULT_TICK_INTERVAL_MS, + 'MOD_DIGEST_TICK_INTERVAL_MS', ) this.periodDays = options.periodDays ?? - parseInt( - process.env.MOD_DIGEST_PERIOD_DAYS ?? `${DEFAULT_PERIOD_DAYS}`, - 10, + parsePositiveIntEnv( + process.env.MOD_DIGEST_PERIOD_DAYS, + DEFAULT_PERIOD_DAYS, + 'MOD_DIGEST_PERIOD_DAYS', ) this.clock = options.clock ?? (() => Date.now()) } @@ -65,34 +84,46 @@ export class ModDigestSchedulerService { async tick(): Promise { if (!this.client) return 0 + // Single-flight: a slow tick must not overlap a fresh interval fire, + // otherwise two ticks could both pass the isDue check and both deliver + // the digest before either calls markSent(). + if (this.tickInProgress) return 0 + this.tickInProgress = true - const guildIds = await modDigestConfigService.listEnabledGuildIds() - if (guildIds.length === 0) return 0 - - let sent = 0 - for (const guildId of guildIds) { - try { - const config = await modDigestConfigService.get(guildId) - if (!config?.enabled) continue - if (!this.isDue(config)) continue - - const delivered = await this.sendDigestForGuild( - guildId, - config.channelId, - ) - if (delivered) { - await modDigestConfigService.markSent(guildId, this.clock()) - sent += 1 + try { + const guildIds = await modDigestConfigService.listEnabledGuildIds() + if (guildIds.length === 0) return 0 + + let sent = 0 + for (const guildId of guildIds) { + try { + const config = await modDigestConfigService.get(guildId) + if (!config?.enabled) continue + if (!this.isDue(config)) continue + + const delivered = await this.sendDigestForGuild( + guildId, + config.channelId, + ) + if (delivered) { + await modDigestConfigService.markSent( + guildId, + this.clock(), + ) + sent += 1 + } + } catch (error) { + errorLog({ + message: 'Mod digest tick failed for guild', + error, + data: { guildId }, + }) } - } catch (error) { - errorLog({ - message: 'Mod digest tick failed for guild', - error, - data: { guildId }, - }) } + return sent + } finally { + this.tickInProgress = false } - return sent } isDue(config: ModDigestConfig): boolean { diff --git a/prisma/migrations/20260407000000_add_moderation_cases_guild_created_at_index/migration.sql b/prisma/migrations/20260407000000_add_moderation_cases_guild_created_at_index/migration.sql new file mode 100644 index 000000000..5ab104bb1 --- /dev/null +++ b/prisma/migrations/20260407000000_add_moderation_cases_guild_created_at_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "moderation_cases_guildId_createdAt_idx" ON "moderation_cases"("guildId", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a6a2c31dd..1b5601775 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -419,6 +419,7 @@ model ModerationCase { @@unique([guildId, caseNumber]) @@index([guildId]) + @@index([guildId, createdAt]) @@index([userId]) @@index([moderatorId]) @@map("moderation_cases")