Files
Nexumi/apps/bot/src/modules/moderation/actions.ts
smueller ffaa1e26fd 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.
2026-07-23 09:53:44 +02:00

195 lines
5.4 KiB
TypeScript

import { TextChannel, type ButtonInteraction } from 'discord.js';
import { type Locale, t, tf } from '@nexumi/shared';
import type { BotContext } from '../../types.js';
import { assertBannableForConfirm } from '../../hierarchy.js';
import { createCase, scheduleTempBanExpire } from './service.js';
import { tryParseDuration } from './duration.js';
type BanPayload = {
userId: string;
reason: string;
duration: string | null;
};
type PurgePayload = {
channelId: string;
amount: number;
targetUserId: string | null;
botsOnly: boolean;
linksOnly: boolean;
attachmentsOnly: boolean;
regex: string | null;
};
type CaseDeletePayload = {
caseNumber: number;
};
export async function executeBan(
interaction: ButtonInteraction,
context: BotContext,
locale: Locale,
payload: BanPayload
): Promise<void> {
const guild = interaction.guild;
if (!guild) {
await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] });
return;
}
if (!(await assertBannableForConfirm(interaction, locale, payload.userId))) {
return;
}
let durationMs: number | null = null;
if (payload.duration) {
durationMs = tryParseDuration(payload.duration);
if (durationMs === null || durationMs <= 0) {
await interaction.update({
content: t(locale, 'moderation.duration.invalid'),
components: []
});
return;
}
}
await guild.members.ban(payload.userId, { reason: payload.reason });
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: payload.userId,
moderatorId: interaction.user.id,
action: 'BAN',
reason: payload.reason,
channelId: interaction.channelId ?? undefined,
source: 'button_confirmation',
metadata: {
confirmed: true,
duration: payload.duration,
durationMs
}
});
if (durationMs !== null) {
await scheduleTempBanExpire(guild.id, payload.userId, durationMs, payload.reason);
}
await interaction.update({
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}
export async function executePurge(
interaction: ButtonInteraction,
context: BotContext,
locale: Locale,
payload: PurgePayload
): Promise<void> {
const guild = interaction.guild;
if (!guild) {
await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] });
return;
}
const channel = await guild.channels.fetch(payload.channelId);
let deletedCount = 0;
const regex = payload.regex ? new RegExp(payload.regex, 'i') : null;
if (channel instanceof TextChannel) {
const fetched = await channel.messages.fetch({ limit: 100 });
const filtered = fetched.filter((message) => {
if (payload.targetUserId && message.author.id !== payload.targetUserId) return false;
if (payload.botsOnly && !message.author.bot) return false;
if (payload.linksOnly && !/(https?:\/\/|discord\.gg\/)/i.test(message.content)) return false;
if (payload.attachmentsOnly && message.attachments.size === 0) return false;
if (regex && !regex.test(message.content)) return false;
return true;
});
const toDelete = filtered.first(payload.amount);
const deleted = await channel.bulkDelete(toDelete, true);
deletedCount = deleted.size;
}
const modCase = await createCase(context, {
guildId: guild.id,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
action: 'PURGE',
reason: `Deleted ${deletedCount} messages`,
channelId: payload.channelId,
source: 'button_confirmation',
metadata: {
confirmed: true,
purge: {
requestedAmount: payload.amount,
deletedCount,
filters: {
targetUserId: payload.targetUserId,
botsOnly: payload.botsOnly,
linksOnly: payload.linksOnly,
attachmentsOnly: payload.attachmentsOnly,
regex: payload.regex
}
}
}
});
await interaction.update({
content: tf(locale, 'moderation.purge.done', {
deletedCount,
caseNumber: modCase.caseNumber
}),
components: []
});
}
export async function executeCaseDelete(
interaction: ButtonInteraction,
context: BotContext,
locale: Locale,
payload: CaseDeletePayload
): Promise<void> {
const guildId = interaction.guildId;
if (!guildId) {
await interaction.update({ content: t(locale, 'generic.guildOnly'), components: [] });
return;
}
const existing = await context.prisma.case.findUnique({
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } }
});
if (!existing || existing.deletedAt) {
await interaction.update({
content: t(locale, 'moderation.case.notFound'),
components: []
});
return;
}
await context.prisma.case.update({
where: { guildId_caseNumber: { guildId, caseNumber: payload.caseNumber } },
data: { deletedAt: new Date() }
});
const modCase = await createCase(context, {
guildId,
targetUserId: existing.targetUserId,
moderatorId: interaction.user.id,
action: 'CASE_DELETE',
reason: `Soft-deleted case #${payload.caseNumber}`,
channelId: interaction.channelId ?? undefined,
source: 'button_confirmation',
metadata: {
confirmed: true,
deletedCaseId: existing.id,
deletedCaseNumber: payload.caseNumber
}
});
await interaction.update({
content: tf(locale, 'moderation.success.case', { caseNumber: modCase.caseNumber }),
components: []
});
}