diff --git a/.gitignore b/.gitignore index a29b732..5d8e852 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ pnpm-lock.yaml apps/*/dist apps/*/.next packages/*/dist -prisma/migrations diff --git a/apps/bot/src/modules/moderation/actions.ts b/apps/bot/src/modules/moderation/actions.ts index 87880b0..f68524a 100644 --- a/apps/bot/src/modules/moderation/actions.ts +++ b/apps/bot/src/modules/moderation/actions.ts @@ -43,7 +43,12 @@ export async function executeBan( moderatorId: interaction.user.id, action: 'BAN', reason: payload.reason, - metadata: { confirmed: true } + channelId: interaction.channelId ?? undefined, + source: 'button_confirmation', + metadata: { + confirmed: true, + duration: payload.duration + } }); if (payload.duration) { @@ -98,11 +103,21 @@ export async function executePurge( moderatorId: interaction.user.id, action: 'PURGE', reason: `Deleted ${deletedCount} messages`, + channelId: payload.channelId, + source: 'button_confirmation', metadata: { confirmed: true, - channelId: payload.channelId, - requestedAmount: payload.amount, - deletedCount + purge: { + requestedAmount: payload.amount, + deletedCount, + filters: { + targetUserId: payload.targetUserId, + botsOnly: payload.botsOnly, + linksOnly: payload.linksOnly, + attachmentsOnly: payload.attachmentsOnly, + regex: payload.regex + } + } } }); @@ -147,7 +162,13 @@ export async function executeCaseDelete( moderatorId: interaction.user.id, action: 'CASE_DELETE', reason: `Soft-deleted case #${payload.caseNumber}`, - metadata: { confirmed: true, deletedCaseId: existing.id } + channelId: interaction.channelId ?? undefined, + source: 'button_confirmation', + metadata: { + confirmed: true, + deletedCaseId: existing.id, + deletedCaseNumber: payload.caseNumber + } }); await interaction.update({ diff --git a/apps/bot/src/modules/moderation/commands.ts b/apps/bot/src/modules/moderation/commands.ts index a49bb9d..7a3dec5 100644 --- a/apps/bot/src/modules/moderation/commands.ts +++ b/apps/bot/src/modules/moderation/commands.ts @@ -18,6 +18,13 @@ function reasonFrom(i: ChatInputCommandInteraction, locale: 'de' | 'en'): string return i.options.getString('reason') ?? t(locale, 'moderation.reason.none'); } +function auditFrom(interaction: ChatInputCommandInteraction) { + return { + channelId: interaction.channelId ?? undefined, + source: 'slash_command' as const + }; +} + async function ensureMod( interaction: ChatInputCommandInteraction, permission: bigint, @@ -70,6 +77,7 @@ const unbanCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: userId, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'UNBAN', reason }); @@ -94,6 +102,7 @@ const kickCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'KICK', reason }); @@ -122,6 +131,7 @@ const timeoutCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'TIMEOUT', reason, metadata: { durationMs: duration } @@ -147,6 +157,7 @@ const untimeoutCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'UN_TIMEOUT', reason }); @@ -369,6 +380,7 @@ const warnCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'WARN_CLEAR', reason: 'Warnings cleared' }); @@ -446,6 +458,7 @@ const slowmodeCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'SLOWMODE', reason: `Set slowmode to ${seconds}s` }); @@ -467,6 +480,7 @@ const lockCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'LOCK', reason: 'Channel locked' }); @@ -488,6 +502,7 @@ const unlockCommand: SlashCommand = { guildId: interaction.guildId!, targetUserId: interaction.user.id, moderatorId: interaction.user.id, + ...auditFrom(interaction), action: 'UNLOCK', reason: 'Channel unlocked' }); diff --git a/apps/bot/src/modules/moderation/service.ts b/apps/bot/src/modules/moderation/service.ts index fdf7e23..917044f 100644 --- a/apps/bot/src/modules/moderation/service.ts +++ b/apps/bot/src/modules/moderation/service.ts @@ -2,6 +2,7 @@ import { moderationQueue } from '../../jobs.js'; import type { BotContext } from '../../types.js'; import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import type { Prisma } from '@prisma/client'; +import { buildCaseMetadata, type CaseAuditSource } from '@nexumi/shared'; type CreateCaseInput = { guildId: string; @@ -9,7 +10,9 @@ type CreateCaseInput = { moderatorId: string; action: string; reason?: string; - metadata?: Prisma.InputJsonValue; + source?: CaseAuditSource; + channelId?: string; + metadata?: Record; }; export async function createCase(context: BotContext, input: CreateCaseInput) { @@ -24,6 +27,12 @@ export async function createCase(context: BotContext, input: CreateCaseInput) { orderBy: { caseNumber: 'desc' } }); + const metadata = buildCaseMetadata({ + source: input.source ?? 'slash_command', + channelId: input.channelId, + extras: input.metadata + }); + return context.prisma.case.create({ data: { guildId: input.guildId, @@ -32,7 +41,7 @@ export async function createCase(context: BotContext, input: CreateCaseInput) { moderatorId: input.moderatorId, action: input.action, reason: input.reason, - metadata: input.metadata + metadata: metadata as Prisma.InputJsonValue } }); } @@ -94,6 +103,8 @@ export async function applyWarnEscalation( moderatorId, action: 'TIMEOUT', reason, + channelId: interaction.channelId ?? undefined, + source: 'escalation', metadata: { escalation: true, durationMs: rule.durationMs, warningCount } }); return `Auto escalation applied: timeout (${rule.durationMs} ms).`; @@ -110,6 +121,8 @@ export async function applyWarnEscalation( moderatorId, action: 'KICK', reason, + channelId: interaction.channelId ?? undefined, + source: 'escalation', metadata: { escalation: true, warningCount } }); return 'Auto escalation applied: kick.'; @@ -125,6 +138,8 @@ export async function applyWarnEscalation( moderatorId, action: 'BAN', reason, + channelId: interaction.channelId ?? undefined, + source: 'escalation', metadata: { escalation: true, warningCount } }); return 'Auto escalation applied: ban.'; diff --git a/packages/shared/src/audit.test.ts b/packages/shared/src/audit.test.ts new file mode 100644 index 0000000..98af99a --- /dev/null +++ b/packages/shared/src/audit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { buildCaseMetadata } from './audit.js'; + +describe('buildCaseMetadata', () => { + it('builds standardized purge metadata', () => { + const metadata = buildCaseMetadata({ + source: 'button_confirmation', + channelId: '123', + extras: { + confirmed: true, + purge: { + requestedAmount: 10, + deletedCount: 8, + filters: { botsOnly: true } + } + } + }); + + expect(metadata.source).toBe('button_confirmation'); + expect(metadata.channelId).toBe('123'); + expect(metadata.purge?.deletedCount).toBe(8); + expect(metadata.recordedAt).toBeTypeOf('string'); + }); +}); diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts new file mode 100644 index 0000000..b5449bc --- /dev/null +++ b/packages/shared/src/audit.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +export const CaseAuditSourceSchema = z.enum([ + 'slash_command', + 'button_confirmation', + 'escalation', + 'job' +]); + +export type CaseAuditSource = z.infer; + +export const PurgeAuditFiltersSchema = z.object({ + targetUserId: z.string().nullable().optional(), + botsOnly: z.boolean().optional(), + linksOnly: z.boolean().optional(), + attachmentsOnly: z.boolean().optional(), + regex: z.string().nullable().optional() +}); + +export const CaseAuditMetadataSchema = z + .object({ + source: CaseAuditSourceSchema, + recordedAt: z.string().datetime(), + channelId: z.string().optional(), + confirmed: z.boolean().optional(), + escalation: z.boolean().optional(), + warningCount: z.number().int().positive().optional(), + durationMs: z.number().int().positive().optional(), + deletedCaseId: z.string().optional(), + deletedCaseNumber: z.number().int().positive().optional(), + purge: z + .object({ + requestedAmount: z.number().int().positive(), + deletedCount: z.number().int().nonnegative(), + filters: PurgeAuditFiltersSchema.optional() + }) + .optional() + }) + .passthrough(); + +export type CaseAuditMetadata = z.infer; + +type BuildCaseMetadataInput = { + source: CaseAuditSource; + channelId?: string; + extras?: Record; +}; + +export function buildCaseMetadata(input: BuildCaseMetadataInput): CaseAuditMetadata { + return CaseAuditMetadataSchema.parse({ + source: input.source, + recordedAt: new Date().toISOString(), + channelId: input.channelId, + ...input.extras + }); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index efe2cce..be5672d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +export * from './audit.js'; + export const LocaleSchema = z.enum(['de', 'en']); export type Locale = z.infer;