import { PermissionFlagsBits, type ChatInputCommandInteraction } from 'discord.js'; import type { Prisma } from '@prisma/client'; import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared'; import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { moderationQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { MAX_TIMEOUT_MS } from './duration.js'; type CreateCaseInput = { guildId: string; targetUserId: string; moderatorId: string; action: string; reason?: string; source?: CaseAuditSource; channelId?: string; metadata?: Record; }; export async function createCase(context: BotContext, input: CreateCaseInput) { await context.prisma.guild.upsert({ where: { id: input.guildId }, update: {}, create: { id: input.guildId } }); const metadata = buildCaseMetadata({ source: input.source ?? 'slash_command', channelId: input.channelId, extras: input.metadata }); let created = null; for (let attempt = 0; attempt < 5; attempt++) { try { created = await context.prisma.$transaction( async (tx) => { const lastCase = await tx.case.findFirst({ where: { guildId: input.guildId }, orderBy: { caseNumber: 'desc' }, select: { caseNumber: true } }); return tx.case.create({ data: { guildId: input.guildId, caseNumber: (lastCase?.caseNumber ?? 0) + 1, targetUserId: input.targetUserId, moderatorId: input.moderatorId, action: input.action, reason: input.reason, metadata: metadata as Prisma.InputJsonValue } }); }, { isolationLevel: 'Serializable' } ); break; } catch (error) { const code = typeof error === 'object' && error && 'code' in error ? String((error as { code: unknown }).code) : ''; if (code !== 'P2002' && attempt === 4) { throw error; } if (attempt === 4) { throw error; } } } if (!created) { throw new Error('Failed to create case'); } const { logModAction } = await import('../logging/events.js'); await logModAction(context, { guildId: input.guildId, caseNumber: created.caseNumber, action: input.action, targetUserId: input.targetUserId, moderatorId: input.moderatorId, reason: input.reason }); return created; } export function tempBanJobId(guildId: string, userId: string): string { return `tempBan:${guildId}:${userId}`; } export async function scheduleTempBanExpire( guildId: string, userId: string, durationMs: number, reason?: string ) { const jobId = tempBanJobId(guildId, userId); const existing = await moderationQueue.getJob(jobId); await safeRemoveBullJob(existing, { label: 'tempBanExpire', jobId }); await moderationQueue.add( 'tempBanExpire', { guildId, userId, reason: reason ?? null }, { jobId, delay: durationMs, removeOnComplete: 1000, removeOnFail: 500 } ); } export async function applyWarnEscalation( interaction: ChatInputCommandInteraction, context: BotContext, userId: string, moderatorId: string, locale: Locale ): Promise { const guildId = interaction.guildId; if (!guildId || !interaction.guild) { return null; } const warningCount = await context.prisma.warning.count({ where: { guildId, userId } }); const rule = await context.prisma.escalationRule.findUnique({ where: { guildId_warnCount: { guildId, warnCount: warningCount } } }); if (!rule || !rule.enabled) { return null; } const member = await interaction.guild.members.fetch(userId); const reason = rule.reason ?? t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount)); if (rule.action === 'TIMEOUT') { if (!rule.durationMs) { return t(locale, 'moderation.escalation.skippedDuration').replace( '{warnCount}', String(warningCount) ); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) { return t(locale, 'moderation.escalation.skippedPermission').replace( '{warnCount}', String(warningCount) ); } const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS); await member.timeout(durationMs, reason); await createCase(context, { guildId, targetUserId: userId, moderatorId, action: 'TIMEOUT', reason, channelId: interaction.channelId ?? undefined, source: 'escalation', metadata: { escalation: true, durationMs, warningCount } }); return t(locale, 'moderation.escalation.appliedTimeout').replace( '{durationMs}', String(durationMs) ); } if (rule.action === 'KICK') { if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) { return t(locale, 'moderation.escalation.skippedPermission').replace( '{warnCount}', String(warningCount) ); } await member.kick(reason); await createCase(context, { guildId, targetUserId: userId, moderatorId, action: 'KICK', reason, channelId: interaction.channelId ?? undefined, source: 'escalation', metadata: { escalation: true, warningCount } }); return t(locale, 'moderation.escalation.appliedKick'); } if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) { return t(locale, 'moderation.escalation.skippedPermission').replace( '{warnCount}', String(warningCount) ); } await interaction.guild.members.ban(userId, { reason }); await createCase(context, { guildId, targetUserId: userId, moderatorId, action: 'BAN', reason, channelId: interaction.channelId ?? undefined, source: 'escalation', metadata: { escalation: true, warningCount } }); return t(locale, 'moderation.escalation.appliedBan'); }