Files
Nexumi/apps/bot/src/modules/moderation/service.ts
TheOnlyMace 8513848440 Implement command cooldown management and enhance command execution flow
- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting.
- Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status.
- Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback.
- Improved the handling of interaction responses across various commands, ensuring consistent user experience.
- Added permission checks for new commands, ensuring only authorized users can execute specific actions.
2026-07-25 15:37:56 +02:00

214 lines
6.0 KiB
TypeScript

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<string, unknown>;
};
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<string | null> {
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');
}