Implement leave announcement for banned members and enhance moderation notifications
- 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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "WelcomeConfig" ADD COLUMN "leaveAnnounceBan" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -254,6 +254,8 @@ model WelcomeConfig {
|
||||
leaveContent String?
|
||||
leaveEmbed Json?
|
||||
leaveComponents Json?
|
||||
/// When true, leave channel also posts a notice if the member was banned.
|
||||
leaveAnnounceBan Boolean @default(false)
|
||||
welcomeDmEnabled Boolean @default(false)
|
||||
welcomeDmContent String?
|
||||
userAutoroleIds String[] @default([])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChannelType, PermissionFlagsBits, type Guild, type GuildMember, type Message, type TextChannel } from 'discord.js';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { createCase, createWarning } from '../moderation/service.js';
|
||||
import { createCase, createWarning, notifyModerationTargetDm } from '../moderation/service.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import {
|
||||
getAutoModConfig,
|
||||
@@ -85,6 +85,14 @@ async function applyAutoModAction(
|
||||
|
||||
if (action === 'KICK') {
|
||||
if (me.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
await notifyModerationTargetDm({
|
||||
user: message.author,
|
||||
guildName: guild.name,
|
||||
reason: baseReason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await message.member.kick(baseReason);
|
||||
await createCase(context, {
|
||||
guildId: guild.id,
|
||||
@@ -102,6 +110,14 @@ async function applyAutoModAction(
|
||||
|
||||
if (action === 'BAN') {
|
||||
if (me.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
const locale = await getGuildLocale(context.prisma, guild.id);
|
||||
await notifyModerationTargetDm({
|
||||
user: message.author,
|
||||
guildName: guild.name,
|
||||
reason: baseReason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await guild.members.ban(message.author.id, { reason: baseReason });
|
||||
await createCase(context, {
|
||||
guildId: guild.id,
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { createCase, notifyModerationTargetDm, scheduleTempBanExpire } from './service.js';
|
||||
import { tryParseDuration } from './duration.js';
|
||||
|
||||
type BanPayload = {
|
||||
@@ -55,6 +55,18 @@ export async function executeBan(
|
||||
|
||||
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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t, tf } from '@nexumi/shared';
|
||||
import type { SlashCommand } from '../../types.js';
|
||||
import { requirePermission } from '../../permissions.js';
|
||||
import { assertModerableMember } from '../../hierarchy.js';
|
||||
import { applyWarnEscalation, createCase, createWarning } from './service.js';
|
||||
import { applyWarnEscalation, createCase, createWarning, notifyModerationTargetDm } from './service.js';
|
||||
import { parseDuration, parseTimeoutDuration, tryParseDuration } from './duration.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { promptDestructiveConfirmation } from './confirmations.js';
|
||||
@@ -133,6 +133,13 @@ const kickCommand: SlashCommand = {
|
||||
const reason = reasonFrom(interaction, locale);
|
||||
const hierarchy = await assertModerableMember(interaction, user.id, locale, 'kick');
|
||||
if (!hierarchy.ok || !hierarchy.member) return;
|
||||
await notifyModerationTargetDm({
|
||||
user,
|
||||
guildName: interaction.guild!.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await hierarchy.member.kick(reason);
|
||||
const modCase = await createCase(context, {
|
||||
guildId: interaction.guildId!,
|
||||
|
||||
@@ -64,6 +64,37 @@ async function notifyWarnedUser(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyModerationTargetDm(params: {
|
||||
user: User;
|
||||
guildName: string;
|
||||
reason: string;
|
||||
locale: Locale;
|
||||
action: 'ban' | 'kick';
|
||||
duration?: string | null;
|
||||
}): Promise<void> {
|
||||
const reason = params.reason || t(params.locale, 'moderation.reason.none');
|
||||
const key =
|
||||
params.action === 'kick'
|
||||
? 'moderation.kick.dm'
|
||||
: params.duration
|
||||
? 'moderation.ban.dm.temporary'
|
||||
: 'moderation.ban.dm';
|
||||
try {
|
||||
await params.user.send(
|
||||
tf(params.locale, key, {
|
||||
guild: params.guildName,
|
||||
reason,
|
||||
...(params.duration ? { duration: params.duration } : {})
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, userId: params.user.id, action: params.action },
|
||||
'Failed to DM moderation target'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWarning(
|
||||
context: BotContext,
|
||||
input: CreateWarningInput
|
||||
@@ -298,6 +329,13 @@ export async function applyWarnEscalation(
|
||||
String(warningCount)
|
||||
);
|
||||
}
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: interaction.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await member.kick(reason);
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
@@ -318,6 +356,13 @@ export async function applyWarnEscalation(
|
||||
String(warningCount)
|
||||
);
|
||||
}
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: interaction.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await interaction.guild.members.ban(userId, { reason });
|
||||
await createCase(context, {
|
||||
guildId,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { detectAltAccount } from './alt-detection.js';
|
||||
import { env } from '../../env.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { ephemeral } from '../../interaction-reply.js';
|
||||
import { notifyModerationTargetDm } from '../moderation/service.js';
|
||||
|
||||
export class VerificationPanelError extends Error {
|
||||
constructor(public readonly code: 'not_configured' | 'missing_permission') {
|
||||
@@ -57,16 +58,35 @@ export type CompleteVerificationOptions = {
|
||||
};
|
||||
|
||||
async function applyFailAction(
|
||||
context: BotContext,
|
||||
member: GuildMember,
|
||||
failAction: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const me = member.guild.members.me;
|
||||
if (failAction !== 'KICK' && failAction !== 'BAN') {
|
||||
return;
|
||||
}
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
if (failAction === 'KICK' && me?.permissions.has(PermissionFlagsBits.KickMembers)) {
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: member.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'kick'
|
||||
});
|
||||
await member.kick(reason);
|
||||
return;
|
||||
}
|
||||
if (failAction === 'BAN' && me?.permissions.has(PermissionFlagsBits.BanMembers)) {
|
||||
await notifyModerationTargetDm({
|
||||
user: member.user,
|
||||
guildName: member.guild.name,
|
||||
reason,
|
||||
locale,
|
||||
action: 'ban'
|
||||
});
|
||||
await member.guild.members.ban(member.id, { reason });
|
||||
}
|
||||
}
|
||||
@@ -118,7 +138,7 @@ export async function completeVerification(
|
||||
detail: `Account age ${ageDays}d (required ${config.minAccountAgeDays}d)`,
|
||||
failAction: config.failAction
|
||||
});
|
||||
await applyFailAction(member, config.failAction, failReason);
|
||||
await applyFailAction(context, member, config.failAction, failReason);
|
||||
return { ok: false, reason: 'account_too_young' };
|
||||
}
|
||||
|
||||
@@ -201,7 +221,7 @@ export async function completeVerification(
|
||||
failAction: config.failAction,
|
||||
matchedUserId
|
||||
});
|
||||
await applyFailAction(member, config.failAction, failReason);
|
||||
await applyFailAction(context, member, config.failAction, failReason);
|
||||
return { ok: false, reason: 'alt_detected' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
|
||||
import { t, tf } from '@nexumi/shared';
|
||||
import type { BotContext } from '../../types.js';
|
||||
import { getGuildLocale } from '../../i18n.js';
|
||||
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||
import { logger } from '../../logger.js';
|
||||
@@ -106,6 +108,33 @@ export async function handleMemberLeave(context: BotContext, member: GuildMember
|
||||
return;
|
||||
}
|
||||
await sendLeaveMessage(channel, resolveLeavePayload(config), member, member.guild);
|
||||
|
||||
if (!config.leaveAnnounceBan || member.user.bot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ban = await member.guild.bans.fetch(member.id).catch(() => null);
|
||||
if (!ban) {
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, member.guild.id);
|
||||
const reason = ban.reason ?? t(locale, 'moderation.reason.none');
|
||||
if ('send' in channel) {
|
||||
await channel
|
||||
.send(
|
||||
tf(locale, 'welcome.leave.bannedNotice', {
|
||||
user: member.user.tag,
|
||||
reason
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
{ error, guildId: member.guild.id, userId: member.id },
|
||||
'Failed to send leave ban notice'
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWelcomeEvents(context: BotContext): void {
|
||||
|
||||
Reference in New Issue
Block a user