- 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.
214 lines
6.0 KiB
TypeScript
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');
|
|
}
|