- Added a `leaveAnnounceBan` field to the `WelcomeConfig` model, allowing for optional announcements in the leave channel when a member is banned. - Updated the leave handling logic to check for bans and send a notice in the leave channel if the `leaveAnnounceBan` toggle is enabled. - Enhanced moderation actions (kick and ban) to notify the affected user via DM with the reason for the action. - Introduced a new `notifyModerationTargetDm` function to streamline DM notifications for moderation actions. - Updated the WebUI to include a toggle for the `leaveAnnounceBan` feature, improving user configurability. - Improved localization for new messages related to moderation notifications and leave announcements in both English and German. - Documented changes in phase tracking to reflect the new leave announcement feature and moderation notification enhancements.
214 lines
5.9 KiB
TypeScript
214 lines
5.9 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, notifyModerationTargetDm, 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 interaction.deferUpdate();
|
|
|
|
const targetUser = await context.client.users.fetch(payload.userId).catch(() => null);
|
|
if (targetUser) {
|
|
await notifyModerationTargetDm({
|
|
user: targetUser,
|
|
guildName: guild.name,
|
|
reason: payload.reason,
|
|
locale,
|
|
action: 'ban',
|
|
duration: payload.duration
|
|
});
|
|
}
|
|
|
|
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.editReply({
|
|
content: tf(locale, 'moderation.success.caseWithReason', {
|
|
caseNumber: modCase.caseNumber,
|
|
reason: payload.reason
|
|
}),
|
|
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;
|
|
}
|
|
|
|
await interaction.deferUpdate();
|
|
|
|
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.editReply({
|
|
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: []
|
|
});
|
|
}
|