Enhance bot functionality with new features and improvements

- Updated package dependencies to include `@sentry/node` for error tracking.
- Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety.
- Enhanced health server metrics to include queue waiting times, providing better insights into system performance.
- Introduced confirmation handling for guild backup commands, streamlining user interactions.
- Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities.
- Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features.
- Updated localization files to reflect new command structures and user guidance improvements.
This commit is contained in:
smueller
2026-07-23 09:53:44 +02:00
parent 4e135bcf43
commit ffaa1e26fd
31 changed files with 1831 additions and 363 deletions

View File

@@ -2,7 +2,8 @@ import { moderationQueue } from '../../queues.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';
import { buildCaseMetadata, type CaseAuditSource, t, type Locale } from '@nexumi/shared';
import { MAX_TIMEOUT_MS } from './duration.js';
type CreateCaseInput = {
guildId: string;
@@ -22,39 +23,69 @@ export async function createCase(context: BotContext, input: CreateCaseInput) {
create: { id: input.guildId }
});
const lastCase = await context.prisma.case.findFirst({
where: { guildId: input.guildId },
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,
caseNumber: (lastCase?.caseNumber ?? 0) + 1,
targetUserId: input.targetUserId,
moderatorId: input.moderatorId,
action: input.action,
reason: input.reason,
metadata: metadata as Prisma.InputJsonValue
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;
}
}
}).then(async (created) => {
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;
}
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(
@@ -63,10 +94,17 @@ export async function scheduleTempBanExpire(
durationMs: number,
reason?: string
) {
const jobId = tempBanJobId(guildId, userId);
const existing = await moderationQueue.getJob(jobId);
if (existing) {
await existing.remove();
}
await moderationQueue.add(
'tempBanExpire',
{ guildId, userId, reason: reason ?? null },
{
jobId,
delay: durationMs,
removeOnComplete: 1000,
removeOnFail: 500
@@ -78,7 +116,8 @@ export async function applyWarnEscalation(
interaction: ChatInputCommandInteraction,
context: BotContext,
userId: string,
moderatorId: string
moderatorId: string,
locale: Locale
): Promise<string | null> {
const guildId = interaction.guildId;
if (!guildId || !interaction.guild) {
@@ -98,16 +137,25 @@ export async function applyWarnEscalation(
}
const member = await interaction.guild.members.fetch(userId);
const reason = rule.reason ?? `Auto escalation at ${warningCount} warnings`;
const reason =
rule.reason ??
t(locale, 'moderation.escalation.defaultReason').replace('{warnCount}', String(warningCount));
if (rule.action === 'TIMEOUT') {
if (!rule.durationMs) {
return `Escalation rule for ${warningCount} warns skipped (missing duration).`;
return t(locale, 'moderation.escalation.skippedDuration').replace(
'{warnCount}',
String(warningCount)
);
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.ModerateMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.timeout(rule.durationMs, reason);
const durationMs = Math.min(rule.durationMs, MAX_TIMEOUT_MS);
await member.timeout(durationMs, reason);
await createCase(context, {
guildId,
targetUserId: userId,
@@ -116,14 +164,20 @@ export async function applyWarnEscalation(
reason,
channelId: interaction.channelId ?? undefined,
source: 'escalation',
metadata: { escalation: true, durationMs: rule.durationMs, warningCount }
metadata: { escalation: true, durationMs, warningCount }
});
return `Auto escalation applied: timeout (${rule.durationMs} ms).`;
return t(locale, 'moderation.escalation.appliedTimeout').replace(
'{durationMs}',
String(durationMs)
);
}
if (rule.action === 'KICK') {
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await member.kick(reason);
await createCase(context, {
@@ -136,11 +190,14 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: kick.';
return t(locale, 'moderation.escalation.appliedKick');
}
if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) {
return `Escalation rule for ${warningCount} warns skipped (missing bot permissions).`;
return t(locale, 'moderation.escalation.skippedPermission').replace(
'{warnCount}',
String(warningCount)
);
}
await interaction.guild.members.ban(userId, { reason });
await createCase(context, {
@@ -153,5 +210,5 @@ export async function applyWarnEscalation(
source: 'escalation',
metadata: { escalation: true, warningCount }
});
return 'Auto escalation applied: ban.';
return t(locale, 'moderation.escalation.appliedBan');
}