Implement lockdown management in automod system

- Added functionality to activate and deactivate lockdowns via the automod worker, responding to specific job names.
- Enhanced the anti-raid join handler to apply a verification role when lockdown is active and the anti-raid action is set to VERIFY.
- Updated the automod configuration to ensure default rules are created if missing, improving the setup process for new guilds.
- Introduced new API endpoints for managing cases and warnings, allowing for soft deletion and reason updates.
- Enhanced the moderation dashboard to include warnings management, improving user experience in moderation tasks.
- Updated localization files to reflect new features and improve user guidance in both English and German.
This commit is contained in:
smueller
2026-07-24 10:21:49 +02:00
parent fba597a6f2
commit bd1b55fff7
22 changed files with 1517 additions and 242 deletions

View File

@@ -8,6 +8,7 @@ import { logger } from './logger.js';
import type { BotContext } from './types.js';
import { env } from './env.js';
import { refreshPhishingDomains } from './modules/automod/filters.js';
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
import { completeVerification } from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js';
@@ -212,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] {
const automodWorker = new Worker(
automodQueueName,
async (job) => {
if (job.name !== 'refreshPhishingList') {
if (job.name === 'refreshPhishingList') {
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
return;
}
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
const guildId = (job.data as { guildId?: string }).guildId;
if (!guildId) {
return;
}
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
if (!guild) {
return;
}
if (job.name === 'deactivateLockdown') {
await deactivateLockdown(guild);
logger.info({ guildId }, 'Lockdown deactivated via dashboard');
return;
}
await activateLockdown(guild);
logger.info({ guildId }, 'Lockdown activated via dashboard');
}
},
{ connection: redis }
);

View File

@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
return;
}
// While VERIFY raid-response is active, keep gating new joins with the unverified role.
if (config.lockdownActive && config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
return;
}
if (config.lockdownActive) {
return;
}
const key = `automod:raid:${member.guild.id}`;
const count = await context.redis.incr(key);
if (count === 1) {
@@ -214,10 +224,6 @@ export async function handleAntiRaidJoin(
return;
}
if (config.lockdownActive) {
return;
}
await context.prisma.autoModConfig.update({
where: { guildId: member.guild.id },
data: { lockdownActive: true }
@@ -225,16 +231,45 @@ export async function handleAntiRaidJoin(
if (config.antiRaidAction === 'LOCKDOWN') {
await activateLockdown(member.guild);
} else if (config.antiRaidAction === 'VERIFY') {
await applyRaidVerifyRole(context, member);
}
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
if (alertChannel && alertChannel.isTextBased()) {
await (alertChannel as TextChannel).send(
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.`
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).`
);
}
}
async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise<void> {
const verification = await context.prisma.verificationConfig.findUnique({
where: { guildId: member.guild.id }
});
const roleId = verification?.unverifiedRoleId;
if (!roleId) {
logger.warn(
{ guildId: member.guild.id },
'Anti-raid VERIFY triggered but no unverified role is configured'
);
return;
}
const me = member.guild.members.me;
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
return;
}
const role = member.guild.roles.cache.get(roleId);
if (!role || role.position >= me.roles.highest.position) {
return;
}
if (!member.roles.cache.has(roleId)) {
await member.roles.add(roleId).catch((error) => {
logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify');
});
}
}
export async function handleAntiNukeAction(
context: BotContext,
guildId: string,

View File

@@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
await ensureGuild(prisma, guildId);
const existing = await prisma.autoModRule.count({ where: { guildId } });
if (existing > 0) {
const existing = await prisma.autoModRule.findMany({
where: { guildId },
select: { ruleType: true }
});
const have = new Set(existing.map((row) => row.ruleType));
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
if (missing.length === 0) {
return;
}
await prisma.autoModRule.createMany({
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
data: missing.map((rule) => ({
guildId,
ruleType: rule.ruleType,
action: rule.action,

View File

@@ -164,14 +164,20 @@ export const slowmodeCommandData = applyCommandDescription(
export const lockCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('lock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
),
'moderation.lock.description'
);
export const unlockCommandData = applyCommandDescription(
new SlashCommandBuilder()
.setName('unlock')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
.addBooleanOption((o) =>
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
),
'moderation.unlock.description'
);

View File

@@ -456,18 +456,31 @@ const lockCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
const serverWide = interaction.options.getBoolean('server') ?? false;
const guild = interaction.guild!;
if (serverWide) {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
await channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: false
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: false
});
}
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'LOCK',
reason: 'Channel locked'
reason: serverWide ? 'Server locked' : 'Channel locked'
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
}
@@ -478,18 +491,31 @@ const unlockCommand: SlashCommand = {
async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
const serverWide = interaction.options.getBoolean('server') ?? false;
const guild = interaction.guild!;
if (serverWide) {
for (const channel of guild.channels.cache.values()) {
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
continue;
}
await channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: null
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: null
});
}
const modCase = await createCase(context, {
guildId: interaction.guildId!,
targetUserId: interaction.user.id,
moderatorId: interaction.user.id,
...auditFrom(interaction),
action: 'UNLOCK',
reason: 'Channel unlocked'
reason: serverWide ? 'Server unlocked' : 'Channel unlocked'
});
await replySuccessCase(interaction, locale, modCase.caseNumber);
}