Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 245 additions & 97 deletions packages/bot/src/functions/moderation/commands/digest.spec.ts

Large diffs are not rendered by default.

251 changes: 165 additions & 86 deletions packages/bot/src/functions/moderation/commands/digest.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,63 @@
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'
import { createUserFriendlyError } from '../../../utils/general/errorSanitizer.js'
import {
buildDigestEmbed,
resolveDigestPeriodDays,
} from '../../../utils/moderation/digestEmbed.js'
import { modDigestConfigService } from '../../../utils/moderation/modDigestConfig.js'
import { modDigestSchedulerService } from '../../../utils/moderation/modDigestScheduler.js'

const PERIOD_DAYS: Record<string, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
}
const MS_PER_DAY = 24 * 60 * 60 * 1000

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 }) => {
Expand All @@ -40,80 +69,130 @@
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<string, number> = {}
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<string, number> = {}
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 (subcommand === 'schedule') {
await handleSchedule(interaction)
return
}

if (topModLines) {
embed.addFields({
name: '🏅 Top moderators',
value: topModLines,
inline: false,
})
}
if (subcommand === 'unschedule') {
await handleUnschedule(interaction)
return
}

await handleView(interaction)
},
})

async function handleView(
interaction: ChatInputCommandInteraction,
): Promise<void> {
const period = interaction.options.getString('period') ?? '7d'
const days = resolveDigestPeriodDays(period)

embed.setTimestamp().setFooter({ text: `Period: last ${days} days` })
try {
const guildId = interaction.guild!.id
const since = new Date(Date.now() - days * MS_PER_DAY)
const [stats, periodCases] = await Promise.all([
moderationService.getStats(guildId),
moderationService.getCasesSince(guildId, since),
])

await interactionReply({ interaction, content: { embeds: [embed] } })
const embed = buildDigestEmbed({ stats, cases: periodCases, 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: createUserFriendlyError(error) },
})
}
}

async function handleSchedule(
interaction: ChatInputCommandInteraction,
): Promise<void> {
const channel = interaction.options.getChannel('channel')
if (!channel || channel.type !== ChannelType.GuildText) {

Check warning on line 122 in packages/bot/src/functions/moderation/commands/digest.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ1p3-GmOhvshLOe3oim&open=AZ1p3-GmOhvshLOe3oim&pullRequest=497
await interactionReply({
interaction,
content: { content: '❌ Please pick a text channel.' },
})
return
}

const guildId = interaction.guild!.id
const channelId = (channel as TextChannel).id

try {
// 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,
)
await modDigestConfigService.enable({
guildId,
channelId,
lastSentAt: sent ? Date.now() : null,
})

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: createUserFriendlyError(error) },
})
}
}

async function handleUnschedule(
interaction: ChatInputCommandInteraction,
): Promise<void> {
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: createUserFriendlyError(error) },
})
}
}
75 changes: 75 additions & 0 deletions packages/bot/src/handlers/clientHandler/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ jest.mock('../../services/MusicPresenceService', () => ({
initMusicPresence: jest.fn(),
}))

jest.mock('../../utils/moderation/modDigestScheduler', () => ({
modDigestSchedulerService: {
start: jest.fn(),
stop: jest.fn(),
},
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jest.mock('discord.js', () => {
const originalModule =
jest.requireActual<typeof import('discord.js')>('discord.js')
Expand Down Expand Up @@ -267,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,
)
})
})
})
14 changes: 14 additions & 0 deletions packages/bot/src/handlers/clientHandler/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -85,6 +86,19 @@ export async function startClient({
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: 'Failed to start mod digest scheduler',
error,
})
}

resolve()
})
})
Expand Down
Loading