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:
@@ -8,6 +8,7 @@ import { logger } from './logger.js';
|
|||||||
import type { BotContext } from './types.js';
|
import type { BotContext } from './types.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||||
|
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
||||||
import { completeVerification } from './modules/verification/handlers.js';
|
import { completeVerification } from './modules/verification/handlers.js';
|
||||||
import { closePoll } from './modules/utility/poll.js';
|
import { closePoll } from './modules/utility/poll.js';
|
||||||
import { sendReminder } from './modules/utility/reminders.js';
|
import { sendReminder } from './modules/utility/reminders.js';
|
||||||
@@ -212,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
const automodWorker = new Worker(
|
const automodWorker = new Worker(
|
||||||
automodQueueName,
|
automodQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
if (job.name !== 'refreshPhishingList') {
|
if (job.name === 'refreshPhishingList') {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const count = await refreshPhishingDomains(redis);
|
const count = await refreshPhishingDomains(redis);
|
||||||
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
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 key = `automod:raid:${member.guild.id}`;
|
||||||
const count = await context.redis.incr(key);
|
const count = await context.redis.incr(key);
|
||||||
if (count === 1) {
|
if (count === 1) {
|
||||||
@@ -214,10 +224,6 @@ export async function handleAntiRaidJoin(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.lockdownActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.prisma.autoModConfig.update({
|
await context.prisma.autoModConfig.update({
|
||||||
where: { guildId: member.guild.id },
|
where: { guildId: member.guild.id },
|
||||||
data: { lockdownActive: true }
|
data: { lockdownActive: true }
|
||||||
@@ -225,16 +231,45 @@ export async function handleAntiRaidJoin(
|
|||||||
|
|
||||||
if (config.antiRaidAction === 'LOCKDOWN') {
|
if (config.antiRaidAction === 'LOCKDOWN') {
|
||||||
await activateLockdown(member.guild);
|
await activateLockdown(member.guild);
|
||||||
|
} else if (config.antiRaidAction === 'VERIFY') {
|
||||||
|
await applyRaidVerifyRole(context, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
||||||
if (alertChannel && alertChannel.isTextBased()) {
|
if (alertChannel && alertChannel.isTextBased()) {
|
||||||
await (alertChannel as TextChannel).send(
|
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(
|
export async function handleAntiNukeAction(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
|
|||||||
@@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
|||||||
|
|
||||||
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
const existing = await prisma.autoModRule.count({ where: { guildId } });
|
const existing = await prisma.autoModRule.findMany({
|
||||||
if (existing > 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.autoModRule.createMany({
|
await prisma.autoModRule.createMany({
|
||||||
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
|
data: missing.map((rule) => ({
|
||||||
guildId,
|
guildId,
|
||||||
ruleType: rule.ruleType,
|
ruleType: rule.ruleType,
|
||||||
action: rule.action,
|
action: rule.action,
|
||||||
|
|||||||
@@ -164,14 +164,20 @@ export const slowmodeCommandData = applyCommandDescription(
|
|||||||
export const lockCommandData = applyCommandDescription(
|
export const lockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('lock')
|
.setName('lock')
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
|
||||||
|
),
|
||||||
'moderation.lock.description'
|
'moderation.lock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unlockCommandData = applyCommandDescription(
|
export const unlockCommandData = applyCommandDescription(
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('unlock')
|
.setName('unlock')
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
|
||||||
|
),
|
||||||
'moderation.unlock.description'
|
'moderation.unlock.description'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -456,18 +456,31 @@ const lockCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
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
|
SendMessages: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const modCase = await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'LOCK',
|
action: 'LOCK',
|
||||||
reason: 'Channel locked'
|
reason: serverWide ? 'Server locked' : 'Channel locked'
|
||||||
});
|
});
|
||||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
@@ -478,18 +491,31 @@ const unlockCommand: SlashCommand = {
|
|||||||
async execute(interaction, context) {
|
async execute(interaction, context) {
|
||||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
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
|
SendMessages: null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||||
|
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||||
|
SendMessages: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const modCase = await createCase(context, {
|
const modCase = await createCase(context, {
|
||||||
guildId: interaction.guildId!,
|
guildId: interaction.guildId!,
|
||||||
targetUserId: interaction.user.id,
|
targetUserId: interaction.user.id,
|
||||||
moderatorId: interaction.user.id,
|
moderatorId: interaction.user.id,
|
||||||
...auditFrom(interaction),
|
...auditFrom(interaction),
|
||||||
action: 'UNLOCK',
|
action: 'UNLOCK',
|
||||||
reason: 'Channel unlocked'
|
reason: serverWide ? 'Server unlocked' : 'Channel unlocked'
|
||||||
});
|
});
|
||||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CaseListQuerySchema } from '@nexumi/shared';
|
import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { listCases } from '@/lib/module-configs/cases';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
|
import { listCases, softDeleteCase, updateCaseReason } from '@/lib/module-configs/cases';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
const query = CaseListQuerySchema.parse({
|
|
||||||
search: searchParams.get('search') ?? undefined,
|
|
||||||
action: searchParams.get('action') ?? undefined,
|
|
||||||
limit: searchParams.get('limit') ?? undefined
|
|
||||||
});
|
|
||||||
const cases = await listCases(guildId, query);
|
const cases = await listCases(guildId, query);
|
||||||
return NextResponse.json({ cases });
|
return NextResponse.json({ cases });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
try {
|
||||||
|
const session = await requireGuildAccess(guildId);
|
||||||
|
const body = (await request.json()) as { id?: string; reason?: string };
|
||||||
|
if (!body.id) {
|
||||||
|
return NextResponse.json({ error: 'Missing case id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const patch = CaseUpdateDashboardSchema.parse({ reason: body.reason });
|
||||||
|
const updated = await updateCaseReason(guildId, body.id, patch);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: 'Case not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await writeDashboardAudit(prisma, {
|
||||||
|
guildId,
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'case.update',
|
||||||
|
path: `/dashboard/${guildId}/moderation`,
|
||||||
|
before: { id: body.id },
|
||||||
|
after: updated
|
||||||
|
});
|
||||||
|
return NextResponse.json({ case: updated });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
try {
|
||||||
|
const session = await requireGuildAccess(guildId);
|
||||||
|
const id = request.nextUrl.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Missing case id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const ok = await softDeleteCase(guildId, id);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Case not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await writeDashboardAudit(prisma, {
|
||||||
|
guildId,
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'case.delete',
|
||||||
|
path: `/dashboard/${guildId}/moderation`,
|
||||||
|
before: { id },
|
||||||
|
after: { deleted: true }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { WarningListQuerySchema } from '@nexumi/shared';
|
import { WarningListQuerySchema } from '@nexumi/shared';
|
||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { listWarnings } from '@/lib/module-configs/warnings';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
|
import { deleteWarning, listWarnings } from '@/lib/module-configs/warnings';
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||||
const query = WarningListQuerySchema.parse({
|
|
||||||
userId: searchParams.get('userId') ?? undefined
|
|
||||||
});
|
|
||||||
const warnings = await listWarnings(guildId, query);
|
const warnings = await listWarnings(guildId, query);
|
||||||
return NextResponse.json({ warnings });
|
return NextResponse.json({ warnings });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { guildId } = await params;
|
||||||
|
try {
|
||||||
|
const session = await requireGuildAccess(guildId);
|
||||||
|
const id = request.nextUrl.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Missing warning id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const ok = await deleteWarning(guildId, id);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Warning not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
await writeDashboardAudit(prisma, {
|
||||||
|
guildId,
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
action: 'warning.delete',
|
||||||
|
path: `/dashboard/${guildId}/moderation`,
|
||||||
|
before: { id },
|
||||||
|
after: { deleted: true }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return toApiErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CaseListQuerySchema } from '@nexumi/shared';
|
import { CaseListQuerySchema, WarningListQuerySchema } from '@nexumi/shared';
|
||||||
import { CasesTable } from '@/components/modules/cases-table';
|
import { CasesTable } from '@/components/modules/cases-table';
|
||||||
import { ModerationForm } from '@/components/modules/moderation-form';
|
import { ModerationForm } from '@/components/modules/moderation-form';
|
||||||
|
import { WarningsTable } from '@/components/modules/warnings-table';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { listCases } from '@/lib/module-configs/cases';
|
import { listCases } from '@/lib/module-configs/cases';
|
||||||
import { getModerationDashboard } from '@/lib/module-configs/moderation';
|
import { getModerationDashboard } from '@/lib/module-configs/moderation';
|
||||||
|
import { listWarnings } from '@/lib/module-configs/warnings';
|
||||||
|
|
||||||
interface ModerationPageProps {
|
interface ModerationPageProps {
|
||||||
params: Promise<{ guildId: string }>;
|
params: Promise<{ guildId: string }>;
|
||||||
@@ -11,10 +13,11 @@ interface ModerationPageProps {
|
|||||||
|
|
||||||
export default async function ModerationPage({ params }: ModerationPageProps) {
|
export default async function ModerationPage({ params }: ModerationPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const [locale, moderation, cases] = await Promise.all([
|
const [locale, moderation, cases, warnings] = await Promise.all([
|
||||||
getLocale(),
|
getLocale(),
|
||||||
getModerationDashboard(guildId),
|
getModerationDashboard(guildId),
|
||||||
listCases(guildId, CaseListQuerySchema.parse({}))
|
listCases(guildId, CaseListQuerySchema.parse({})),
|
||||||
|
listWarnings(guildId, WarningListQuerySchema.parse({}))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +27,7 @@ export default async function ModerationPage({ params }: ModerationPageProps) {
|
|||||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.moderation.description')}</p>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.moderation.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<ModerationForm guildId={guildId} initialValue={moderation} />
|
<ModerationForm guildId={guildId} initialValue={moderation} />
|
||||||
|
<WarningsTable guildId={guildId} initialWarnings={warnings} />
|
||||||
<CasesTable guildId={guildId} initialCases={cases} />
|
<CasesTable guildId={guildId} initialCases={cases} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { AutomodConfigDashboard } from '@nexumi/shared';
|
import type {
|
||||||
|
AutoModAction,
|
||||||
|
AutoModRuleType,
|
||||||
|
AutomodConfigDashboard,
|
||||||
|
AutomodRuleDashboard
|
||||||
|
} from '@nexumi/shared';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
||||||
|
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { StringListEditor } from '@/components/ui/string-list-editor';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
|
||||||
|
const ACTIONS: AutoModAction[] = ['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN'];
|
||||||
|
|
||||||
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
|
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
|
||||||
try {
|
try {
|
||||||
|
const cleaned: AutomodConfigDashboard = {
|
||||||
|
...value,
|
||||||
|
rules: value.rules.map((rule) => ({
|
||||||
|
...rule,
|
||||||
|
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
|
||||||
|
threshold: {
|
||||||
|
...rule.threshold,
|
||||||
|
linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean),
|
||||||
|
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
|
||||||
|
},
|
||||||
|
wordList: {
|
||||||
|
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
|
||||||
|
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(value)
|
body: JSON.stringify(cleaned)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
@@ -27,6 +54,281 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRule(
|
||||||
|
rules: AutomodRuleDashboard[],
|
||||||
|
ruleType: AutoModRuleType,
|
||||||
|
patch: Partial<AutomodRuleDashboard>
|
||||||
|
): AutomodRuleDashboard[] {
|
||||||
|
return rules.map((rule) => (rule.ruleType === ruleType ? { ...rule, ...patch } : rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleCard({
|
||||||
|
guildId,
|
||||||
|
rule,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
guildId: string;
|
||||||
|
rule: AutomodRuleDashboard;
|
||||||
|
onChange: (patch: Partial<AutomodRuleDashboard>) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const type = rule.ruleType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldAnchor id={`field-automod-rule-${type}`}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t(`modulePages.automod.rules.${type}.title`)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t(`modulePages.automod.rules.${type}.description`)}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={rule.enabled}
|
||||||
|
onCheckedChange={(enabled) => onChange({ enabled })}
|
||||||
|
aria-label={t(`modulePages.automod.rules.${type}.title`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.ruleAction')}</Label>
|
||||||
|
<Select
|
||||||
|
value={rule.action}
|
||||||
|
onValueChange={(action) => onChange({ action: action as AutoModAction })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACTIONS.map((action) => (
|
||||||
|
<SelectItem key={action} value={action}>
|
||||||
|
{t(`modulePages.automod.actions.${action}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{rule.action === 'TIMEOUT' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.timeoutMinutes')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={40320}
|
||||||
|
value={Math.max(1, Math.round((rule.durationMs ?? 60_000) / 60_000))}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
durationMs: Math.max(1, Number(event.target.value) || 1) * 60_000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'SPAM' || type === 'DUPLICATE' ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>
|
||||||
|
{type === 'SPAM'
|
||||||
|
? t('modulePages.automod.thresholds.maxMessages')
|
||||||
|
: t('modulePages.automod.thresholds.duplicateCount')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={
|
||||||
|
type === 'SPAM'
|
||||||
|
? (rule.threshold.maxMessages ?? 5)
|
||||||
|
: (rule.threshold.duplicateCount ?? 3)
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: {
|
||||||
|
...rule.threshold,
|
||||||
|
...(type === 'SPAM'
|
||||||
|
? { maxMessages: Number(event.target.value) || 1 }
|
||||||
|
: { duplicateCount: Number(event.target.value) || 1 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.thresholds.windowSeconds')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={rule.threshold.windowSeconds ?? (type === 'SPAM' ? 5 : 30)}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: {
|
||||||
|
...rule.threshold,
|
||||||
|
windowSeconds: Number(event.target.value) || 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'MASS_MENTION' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.thresholds.maxMentions')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-40"
|
||||||
|
value={rule.threshold.maxMentions ?? 5}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: { ...rule.threshold, maxMentions: Number(event.target.value) || 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'CAPS' ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.thresholds.capsPercent')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={rule.threshold.capsPercent ?? 70}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: {
|
||||||
|
...rule.threshold,
|
||||||
|
capsPercent: Number(event.target.value) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.thresholds.minLength')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={rule.threshold.minLength ?? 10}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: {
|
||||||
|
...rule.threshold,
|
||||||
|
minLength: Number(event.target.value) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'EMOJI_SPAM' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.thresholds.maxEmojis')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-40"
|
||||||
|
value={rule.threshold.maxEmojis ?? 10}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange({
|
||||||
|
threshold: { ...rule.threshold, maxEmojis: Number(event.target.value) || 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'EXTERNAL_LINK' ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.linkWhitelist')}</Label>
|
||||||
|
<StringListEditor
|
||||||
|
value={rule.threshold.linkWhitelist ?? []}
|
||||||
|
onChange={(linkWhitelist) =>
|
||||||
|
onChange({ threshold: { ...rule.threshold, linkWhitelist } })
|
||||||
|
}
|
||||||
|
placeholder="discord.com"
|
||||||
|
addLabel={t('modulePages.automod.addHost')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.linkBlacklist')}</Label>
|
||||||
|
<StringListEditor
|
||||||
|
value={rule.threshold.linkBlacklist ?? []}
|
||||||
|
onChange={(linkBlacklist) =>
|
||||||
|
onChange({ threshold: { ...rule.threshold, linkBlacklist } })
|
||||||
|
}
|
||||||
|
placeholder="example.com"
|
||||||
|
addLabel={t('modulePages.automod.addHost')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{type === 'WORD_FILTER' ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.wordList')}</Label>
|
||||||
|
<StringListEditor
|
||||||
|
value={rule.wordList.words}
|
||||||
|
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}
|
||||||
|
placeholder={t('modulePages.automod.wordPlaceholder')}
|
||||||
|
addLabel={t('modulePages.automod.addWord')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.regexList')}</Label>
|
||||||
|
<StringListEditor
|
||||||
|
value={rule.wordList.regexPatterns}
|
||||||
|
onChange={(regexPatterns) =>
|
||||||
|
onChange({ wordList: { ...rule.wordList, regexPatterns } })
|
||||||
|
}
|
||||||
|
placeholder="\\bspam\\b"
|
||||||
|
addLabel={t('modulePages.automod.addRegex')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.exceptionRoles')}</Label>
|
||||||
|
<DiscordRoleMultiSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={rule.exceptions.roleIds}
|
||||||
|
onChange={(roleIds) =>
|
||||||
|
onChange({ exceptions: { ...rule.exceptions, roleIds } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.exceptionChannels')}</Label>
|
||||||
|
<DiscordChannelMultiSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={rule.exceptions.channelIds}
|
||||||
|
onChange={(channelIds) =>
|
||||||
|
onChange({ exceptions: { ...rule.exceptions, channelIds } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface AutomodFormProps {
|
interface AutomodFormProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialValue: AutomodConfigDashboard;
|
initialValue: AutomodConfigDashboard;
|
||||||
@@ -36,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
|
<SettingsForm<AutomodConfigDashboard>
|
||||||
|
initialValue={initialValue}
|
||||||
|
onSave={(value) => saveAutomod(guildId, value)}
|
||||||
|
>
|
||||||
{({ value, setValue }) => (
|
{({ value, setValue }) => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -60,6 +365,28 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-automod-rules">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{t('modulePages.automod.rulesTitle')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('modulePages.automod.rulesDescription')}</p>
|
||||||
|
</div>
|
||||||
|
{value.rules.map((rule) => (
|
||||||
|
<RuleCard
|
||||||
|
key={rule.ruleType}
|
||||||
|
guildId={guildId}
|
||||||
|
rule={rule}
|
||||||
|
onChange={(patch) =>
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rules: updateRule(prev.rules, rule.ruleType, patch)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-anti-raid">
|
<FieldAnchor id="field-anti-raid">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -71,10 +398,12 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.antiRaidEnabled}
|
checked={value.antiRaidEnabled}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -82,7 +411,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiRaidJoinThreshold}
|
value={value.antiRaidJoinThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiRaidJoinThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,10 +425,37 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiRaidWindowSeconds}
|
value={value.antiRaidWindowSeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiRaidWindowSeconds: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.automod.antiRaidAction')}</Label>
|
||||||
|
<Select
|
||||||
|
value={value.antiRaidAction}
|
||||||
|
onValueChange={(antiRaidAction) =>
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiRaidAction: antiRaidAction as AutomodConfigDashboard['antiRaidAction']
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LOCKDOWN">
|
||||||
|
{t('modulePages.automod.antiRaidActions.LOCKDOWN')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="VERIFY">
|
||||||
|
{t('modulePages.automod.antiRaidActions.VERIFY')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -114,7 +473,9 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.antiNukeEnabled}
|
checked={value.antiNukeEnabled}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
@@ -125,7 +486,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeBanThreshold}
|
value={value.antiNukeBanThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeBanThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +500,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeChannelThreshold}
|
value={value.antiNukeChannelThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeChannelThreshold: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +514,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
min={1}
|
min={1}
|
||||||
value={value.antiNukeWindowSeconds}
|
value={value.antiNukeWindowSeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
antiNukeWindowSeconds: Number(event.target.value) || 1
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,11 +526,15 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
|||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('modulePages.automod.lockdownActiveHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.lockdownActive}
|
checked={value.lockdownActive}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
onCheckedChange={(checked) =>
|
||||||
|
setValue((prev) => ({ ...prev, lockdownActive: checked }))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CaseDashboard } from '@nexumi/shared';
|
import type { CaseDashboard } from '@nexumi/shared';
|
||||||
import { Search } from 'lucide-react';
|
import { Pencil, Search, Trash2 } from 'lucide-react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
@@ -26,6 +26,8 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
const [action, setAction] = useState('');
|
const [action, setAction] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editReason, setEditReason] = useState('');
|
||||||
|
|
||||||
const fetchCases = useCallback(async () => {
|
const fetchCases = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,6 +50,37 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
}
|
}
|
||||||
}, [action, guildId, search, t]);
|
}, [action, guildId, search, t]);
|
||||||
|
|
||||||
|
async function saveReason(caseId: string) {
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/cases`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: caseId, reason: editReason })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { case: CaseDashboard };
|
||||||
|
setCases((prev) => prev.map((entry) => (entry.id === caseId ? body.case : entry)));
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCase(caseId: string) {
|
||||||
|
if (!window.confirm(t('modulePages.moderation.confirmDeleteCase'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/cases?id=${encodeURIComponent(caseId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCases((prev) => prev.filter((entry) => entry.id !== caseId));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldAnchor id="field-mod-cases">
|
<FieldAnchor id="field-mod-cases">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -105,6 +138,7 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
||||||
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
||||||
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||||
|
<th className="py-2 pr-4" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@@ -114,12 +148,52 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
|||||||
<td className="py-2 pr-4">{entry.action}</td>
|
<td className="py-2 pr-4">{entry.action}</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
|
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||||
<td className="max-w-64 truncate py-2 pr-4">
|
<td className="max-w-64 py-2 pr-4">
|
||||||
|
{editingId === entry.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={editReason}
|
||||||
|
onChange={(event) => setEditReason(event.target.value)}
|
||||||
|
aria-label={t('modulePages.moderation.editReason')}
|
||||||
|
/>
|
||||||
|
<Button type="button" size="sm" onClick={() => void saveReason(entry.id)}>
|
||||||
|
{t('modulePages.moderation.saveReason')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="truncate block">
|
||||||
{entry.reason ?? t('modulePages.moderation.noReason')}
|
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||||
{formatDate(entry.createdAt)}
|
{formatDate(entry.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('modulePages.moderation.editReason')}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(entry.id);
|
||||||
|
setEditReason(entry.reason ?? '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('modulePages.moderation.deleteCase')}
|
||||||
|
onClick={() => void removeCase(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
147
apps/webui/src/components/modules/warnings-table.tsx
Normal file
147
apps/webui/src/components/modules/warnings-table.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { WarningDashboard } from '@nexumi/shared';
|
||||||
|
import { Search, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
interface WarningsTableProps {
|
||||||
|
guildId: string;
|
||||||
|
initialWarnings: WarningDashboard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [warnings, setWarnings] = useState<WarningDashboard[]>(initialWarnings);
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchWarnings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (userId.trim()) params.set('userId', userId.trim());
|
||||||
|
const response = await fetch(`/api/guilds/${guildId}/warnings?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { warnings: WarningDashboard[] };
|
||||||
|
setWarnings(body.warnings);
|
||||||
|
} catch {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [guildId, t, userId]);
|
||||||
|
|
||||||
|
async function removeWarning(warningId: string) {
|
||||||
|
if (!window.confirm(t('modulePages.moderation.confirmRemoveWarning'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/guilds/${guildId}/warnings?id=${encodeURIComponent(warningId)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(t('common.saveError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWarnings((prev) => prev.filter((entry) => entry.id !== warningId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldAnchor id="field-mod-warnings">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('modulePages.moderation.warningsTitle')}</CardTitle>
|
||||||
|
<CardDescription>{t('modulePages.moderation.warningsDescription')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void fetchWarnings();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Input
|
||||||
|
value={userId}
|
||||||
|
onChange={(event) => setUserId(event.target.value)}
|
||||||
|
placeholder={t('modulePages.moderation.warningsSearchUser')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline" disabled={loading}>
|
||||||
|
<Search className="size-4" />
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-12 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : warnings.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.warningsEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
|
||||||
|
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
|
||||||
|
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
||||||
|
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
||||||
|
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||||
|
<th className="py-2 pr-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{warnings.map((entry) => (
|
||||||
|
<tr key={entry.id}>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{entry.userId}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||||
|
<td className="max-w-64 truncate py-2 pr-4">
|
||||||
|
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||||
|
{formatDate(entry.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('modulePages.moderation.removeWarning')}
|
||||||
|
onClick={() => void removeWarning(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</FieldAnchor>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/webui/src/components/ui/string-list-editor.tsx
Normal file
53
apps/webui/src/components/ui/string-list-editor.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface StringListEditorProps {
|
||||||
|
value: string[];
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
addLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StringListEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
addLabel
|
||||||
|
}: StringListEditorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{value.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={entry}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = [...value];
|
||||||
|
next[index] = event.target.value;
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('common.remove')}
|
||||||
|
onClick={() => onChange(value.filter((_, i) => i !== index))}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onChange([...value, ''])}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{addLabel ?? t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,6 +130,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
|||||||
hash: 'field-mod-cases',
|
hash: 'field-mod-cases',
|
||||||
keywords: ['cases', 'fälle']
|
keywords: ['cases', 'fälle']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-mod-warnings',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.moderation.warningsTitle',
|
||||||
|
href: 'moderation',
|
||||||
|
hash: 'field-mod-warnings',
|
||||||
|
keywords: ['warn', 'verwarnung']
|
||||||
|
},
|
||||||
// Automod
|
// Automod
|
||||||
{
|
{
|
||||||
id: 'setting-automod-enabled',
|
id: 'setting-automod-enabled',
|
||||||
@@ -138,6 +146,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
|||||||
href: 'automod',
|
href: 'automod',
|
||||||
hash: 'field-automod-enabled'
|
hash: 'field-automod-enabled'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-automod-rules',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.automod.rulesTitle',
|
||||||
|
href: 'automod',
|
||||||
|
hash: 'field-automod-rules',
|
||||||
|
keywords: ['filter', 'word', 'whitelist', 'blacklist', 'regex']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'setting-anti-raid',
|
id: 'setting-anti-raid',
|
||||||
kind: 'setting',
|
kind: 'setting',
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { AutomodConfigDashboard, AutomodConfigDashboardPatch } from '@nexumi/shared';
|
import {
|
||||||
|
DEFAULT_AUTOMOD_RULES,
|
||||||
|
validateAutomodRegexPatterns,
|
||||||
|
type AutomodConfigDashboard,
|
||||||
|
type AutomodConfigDashboardPatch,
|
||||||
|
type AutomodRuleDashboard,
|
||||||
|
type AutoModRuleType
|
||||||
|
} from '@nexumi/shared';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
import { getAutomodQueue } from '../queues';
|
||||||
|
|
||||||
async function ensureAutoModConfig(guildId: string) {
|
async function ensureAutoModConfig(guildId: string) {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||||
@@ -10,34 +19,172 @@ async function ensureAutoModConfig(guildId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDashboard(config: Awaited<ReturnType<typeof ensureAutoModConfig>>): AutomodConfigDashboard {
|
async function ensureAllRules(guildId: string): Promise<void> {
|
||||||
|
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: missing.map((rule) => ({
|
||||||
|
guildId,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
action: rule.action,
|
||||||
|
threshold: rule.threshold ?? undefined,
|
||||||
|
wordList: rule.wordList ?? undefined,
|
||||||
|
enabled: ['SPAM', 'MASS_MENTION', 'INVITE_LINK', 'PHISHING'].includes(rule.ruleType)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuleRow(row: {
|
||||||
|
ruleType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
action: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
threshold: unknown;
|
||||||
|
exceptions: unknown;
|
||||||
|
wordList: unknown;
|
||||||
|
}): AutomodRuleDashboard {
|
||||||
|
const threshold =
|
||||||
|
row.threshold && typeof row.threshold === 'object' ? (row.threshold as AutomodRuleDashboard['threshold']) : {};
|
||||||
|
const exceptions =
|
||||||
|
row.exceptions && typeof row.exceptions === 'object'
|
||||||
|
? (row.exceptions as AutomodRuleDashboard['exceptions'])
|
||||||
|
: { roleIds: [], channelIds: [] };
|
||||||
|
const wordList =
|
||||||
|
row.wordList && typeof row.wordList === 'object'
|
||||||
|
? (row.wordList as AutomodRuleDashboard['wordList'])
|
||||||
|
: { words: [], regexPatterns: [] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleType: row.ruleType as AutoModRuleType,
|
||||||
|
enabled: row.enabled,
|
||||||
|
action: row.action as AutomodRuleDashboard['action'],
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
threshold: {
|
||||||
|
maxMentions: threshold.maxMentions,
|
||||||
|
capsPercent: threshold.capsPercent,
|
||||||
|
minLength: threshold.minLength,
|
||||||
|
maxMessages: threshold.maxMessages,
|
||||||
|
windowSeconds: threshold.windowSeconds,
|
||||||
|
maxEmojis: threshold.maxEmojis,
|
||||||
|
duplicateCount: threshold.duplicateCount,
|
||||||
|
linkWhitelist: threshold.linkWhitelist ?? [],
|
||||||
|
linkBlacklist: threshold.linkBlacklist ?? []
|
||||||
|
},
|
||||||
|
exceptions: {
|
||||||
|
roleIds: Array.isArray(exceptions.roleIds) ? exceptions.roleIds.map(String) : [],
|
||||||
|
channelIds: Array.isArray(exceptions.channelIds) ? exceptions.channelIds.map(String) : []
|
||||||
|
},
|
||||||
|
wordList: {
|
||||||
|
words: Array.isArray(wordList.words) ? wordList.words.map(String) : [],
|
||||||
|
regexPatterns: Array.isArray(wordList.regexPatterns) ? wordList.regexPatterns.map(String) : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDashboard(
|
||||||
|
config: Awaited<ReturnType<typeof ensureAutoModConfig>>,
|
||||||
|
rules: AutomodRuleDashboard[]
|
||||||
|
): AutomodConfigDashboard {
|
||||||
|
const order = DEFAULT_AUTOMOD_RULES.map((rule) => rule.ruleType);
|
||||||
|
const byType = new Map(rules.map((rule) => [rule.ruleType, rule]));
|
||||||
|
const ordered = order
|
||||||
|
.map((type) => byType.get(type))
|
||||||
|
.filter((rule): rule is AutomodRuleDashboard => Boolean(rule));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
antiRaidEnabled: config.antiRaidEnabled,
|
antiRaidEnabled: config.antiRaidEnabled,
|
||||||
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
|
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
|
||||||
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
|
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
|
||||||
antiRaidAction: 'LOCKDOWN',
|
antiRaidAction: (config.antiRaidAction === 'VERIFY' ? 'VERIFY' : 'LOCKDOWN') as AutomodConfigDashboard['antiRaidAction'],
|
||||||
antiNukeEnabled: config.antiNukeEnabled,
|
antiNukeEnabled: config.antiNukeEnabled,
|
||||||
antiNukeBanThreshold: config.antiNukeBanThreshold,
|
antiNukeBanThreshold: config.antiNukeBanThreshold,
|
||||||
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
|
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
|
||||||
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
|
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
|
||||||
lockdownActive: config.lockdownActive
|
lockdownActive: config.lockdownActive,
|
||||||
|
rules: ordered
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
|
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
|
||||||
const config = await ensureAutoModConfig(guildId);
|
const config = await ensureAutoModConfig(guildId);
|
||||||
return toDashboard(config);
|
await ensureAllRules(guildId);
|
||||||
|
const rows = await prisma.autoModRule.findMany({ where: { guildId } });
|
||||||
|
return toDashboard(config, rows.map(parseRuleRow));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAutomodDashboard(
|
export async function updateAutomodDashboard(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
patch: AutomodConfigDashboardPatch
|
patch: AutomodConfigDashboardPatch
|
||||||
): Promise<AutomodConfigDashboard> {
|
): Promise<AutomodConfigDashboard> {
|
||||||
await ensureAutoModConfig(guildId);
|
const before = await getAutomodDashboard(guildId);
|
||||||
const updated = await prisma.autoModConfig.update({
|
|
||||||
where: { guildId },
|
if (patch.rules) {
|
||||||
data: patch
|
for (const rule of patch.rules) {
|
||||||
});
|
const bad = validateAutomodRegexPatterns(rule.wordList?.regexPatterns ?? []);
|
||||||
return toDashboard(updated);
|
if (bad) {
|
||||||
|
throw new Error(`Invalid regex pattern: ${bad}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rules, ...configPatch } = patch;
|
||||||
|
const data: Prisma.AutoModConfigUpdateInput = { ...configPatch };
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
await tx.autoModConfig.update({ where: { guildId }, data });
|
||||||
|
}
|
||||||
|
if (rules) {
|
||||||
|
for (const rule of rules) {
|
||||||
|
await tx.autoModRule.upsert({
|
||||||
|
where: { guildId_ruleType: { guildId, ruleType: rule.ruleType } },
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
action: rule.action,
|
||||||
|
durationMs: rule.durationMs ?? null,
|
||||||
|
threshold: rule.threshold as Prisma.InputJsonValue,
|
||||||
|
exceptions: rule.exceptions as Prisma.InputJsonValue,
|
||||||
|
wordList: rule.wordList as Prisma.InputJsonValue
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: rule.enabled,
|
||||||
|
action: rule.action,
|
||||||
|
durationMs: rule.durationMs ?? null,
|
||||||
|
threshold: rule.threshold as Prisma.InputJsonValue,
|
||||||
|
exceptions: rule.exceptions as Prisma.InputJsonValue,
|
||||||
|
wordList: rule.wordList as Prisma.InputJsonValue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await getAutomodDashboard(guildId);
|
||||||
|
|
||||||
|
// Lockdown permission restore must run on the bot (discord.js), not in WebUI.
|
||||||
|
if (before.lockdownActive && after.lockdownActive === false) {
|
||||||
|
await getAutomodQueue().add(
|
||||||
|
'deactivateLockdown',
|
||||||
|
{ guildId },
|
||||||
|
{ removeOnComplete: 100, removeOnFail: 50 }
|
||||||
|
);
|
||||||
|
} else if (!before.lockdownActive && after.lockdownActive === true && after.antiRaidAction === 'LOCKDOWN') {
|
||||||
|
await getAutomodQueue().add(
|
||||||
|
'activateLockdown',
|
||||||
|
{ guildId },
|
||||||
|
{ removeOnComplete: 100, removeOnFail: 50 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return after;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CaseDashboard, CaseListQuery } from '@nexumi/shared';
|
import type { CaseDashboard, CaseListQuery, CaseUpdateDashboard } from '@nexumi/shared';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
|
||||||
@@ -37,3 +37,44 @@ export async function listCases(guildId: string, query: CaseListQuery): Promise<
|
|||||||
deletedAt: entry.deletedAt ? entry.deletedAt.toISOString() : null
|
deletedAt: entry.deletedAt ? entry.deletedAt.toISOString() : null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCaseReason(
|
||||||
|
guildId: string,
|
||||||
|
caseId: string,
|
||||||
|
patch: CaseUpdateDashboard
|
||||||
|
): Promise<CaseDashboard | null> {
|
||||||
|
const existing = await prisma.case.findFirst({
|
||||||
|
where: { id: caseId, guildId, deletedAt: null }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updated = await prisma.case.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { reason: patch.reason }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
caseNumber: updated.caseNumber,
|
||||||
|
targetUserId: updated.targetUserId,
|
||||||
|
moderatorId: updated.moderatorId,
|
||||||
|
action: updated.action,
|
||||||
|
reason: updated.reason,
|
||||||
|
createdAt: updated.createdAt.toISOString(),
|
||||||
|
deletedAt: updated.deletedAt ? updated.deletedAt.toISOString() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function softDeleteCase(guildId: string, caseId: string): Promise<boolean> {
|
||||||
|
const existing = await prisma.case.findFirst({
|
||||||
|
where: { id: caseId, guildId, deletedAt: null }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await prisma.case.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { deletedAt: new Date() }
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,3 +20,14 @@ export async function listWarnings(guildId: string, query: WarningListQuery): Pr
|
|||||||
createdAt: warning.createdAt.toISOString()
|
createdAt: warning.createdAt.toISOString()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteWarning(guildId: string, warningId: string): Promise<boolean> {
|
||||||
|
const existing = await prisma.warning.findFirst({
|
||||||
|
where: { id: warningId, guildId }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await prisma.warning.delete({ where: { id: existing.id } });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const guildBackupQueueName = 'guild-backups';
|
|||||||
export const suggestionsQueueName = 'suggestions';
|
export const suggestionsQueueName = 'suggestions';
|
||||||
export const messagesQueueName = 'messages';
|
export const messagesQueueName = 'messages';
|
||||||
export const verificationQueueName = 'verification';
|
export const verificationQueueName = 'verification';
|
||||||
|
export const automodQueueName = 'automod';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
@@ -28,6 +29,7 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiSuggestionsQueue?: Queue;
|
__nexumiSuggestionsQueue?: Queue;
|
||||||
__nexumiMessagesQueue?: Queue;
|
__nexumiMessagesQueue?: Queue;
|
||||||
__nexumiVerificationQueue?: Queue;
|
__nexumiVerificationQueue?: Queue;
|
||||||
|
__nexumiAutomodQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
@@ -85,6 +87,13 @@ export function getVerificationQueue(): Queue {
|
|||||||
return globalForQueues.__nexumiVerificationQueue;
|
return globalForQueues.__nexumiVerificationQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAutomodQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiAutomodQueue ??= new Queue(automodQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiAutomodQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
|
|||||||
@@ -347,34 +347,121 @@
|
|||||||
"reason": "Grund",
|
"reason": "Grund",
|
||||||
"reasonPlaceholder": "Optionaler Standardgrund",
|
"reasonPlaceholder": "Optionaler Standardgrund",
|
||||||
"casesTitle": "Fälle",
|
"casesTitle": "Fälle",
|
||||||
"casesDescription": "Liste der zuletzt erstellten Moderations-Fälle (nur Lesezugriff).",
|
"casesDescription": "Moderations-Fälle anzeigen, Grund bearbeiten oder soft-löschen.",
|
||||||
"searchPlaceholder": "Suche nach User- oder Moderator-ID / Grund",
|
"searchPlaceholder": "Suche nach User- oder Moderator-ID / Grund",
|
||||||
"actionPlaceholder": "Aktion filtern, z. B. BAN",
|
"actionPlaceholder": "Aktion filtern, z. B. BAN",
|
||||||
"casesEmpty": "Keine Fälle gefunden.",
|
"casesEmpty": "Keine Fälle gefunden.",
|
||||||
"target": "Ziel",
|
"target": "Ziel",
|
||||||
"moderator": "Moderator",
|
"moderator": "Moderator",
|
||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"noReason": "Kein Grund angegeben"
|
"noReason": "Kein Grund angegeben",
|
||||||
|
"editReason": "Grund bearbeiten",
|
||||||
|
"saveReason": "Speichern",
|
||||||
|
"deleteCase": "Fall löschen",
|
||||||
|
"confirmDeleteCase": "Diesen Fall wirklich löschen?",
|
||||||
|
"warningsTitle": "Verwarnungen",
|
||||||
|
"warningsDescription": "Letzte Verwarnungen. Entfernen löscht den Warn-Eintrag.",
|
||||||
|
"warningsEmpty": "Keine Verwarnungen gefunden.",
|
||||||
|
"warningsSearchUser": "User-ID filtern",
|
||||||
|
"removeWarning": "Verwarnung entfernen",
|
||||||
|
"confirmRemoveWarning": "Diese Verwarnung wirklich entfernen?"
|
||||||
},
|
},
|
||||||
"automod": {
|
"automod": {
|
||||||
"title": "Auto-Moderation",
|
"title": "Auto-Moderation",
|
||||||
"description": "Grundeinstellungen sowie Anti-Raid- und Anti-Nuke-Schutz.",
|
"description": "Filterregeln, Anti-Raid und Anti-Nuke für diesen Server.",
|
||||||
"enabledLabel": "Auto-Moderation aktiviert",
|
"enabledLabel": "Auto-Moderation aktiviert",
|
||||||
"enabledHint": "Aktiviert alle Auto-Moderations-Regeln (Spam, Wortfilter, Links, …) auf diesem Server.",
|
"enabledHint": "Master-Schalter: nur aktivierte Regeln unten greifen, wenn dieser Schalter an ist.",
|
||||||
|
"rulesTitle": "Filterregeln",
|
||||||
|
"rulesDescription": "Pro Regel: Aktion, Schwellenwerte, Wort-/Linklisten und Ausnahmen.",
|
||||||
|
"ruleAction": "Aktion",
|
||||||
|
"timeoutMinutes": "Timeout (Minuten)",
|
||||||
|
"actions": {
|
||||||
|
"DELETE": "Nachricht löschen",
|
||||||
|
"WARN": "Verwarnen",
|
||||||
|
"TIMEOUT": "Timeout",
|
||||||
|
"KICK": "Kick",
|
||||||
|
"BAN": "Ban"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"maxMessages": "Max. Nachrichten",
|
||||||
|
"windowSeconds": "Zeitfenster (Sekunden)",
|
||||||
|
"maxMentions": "Max. Mentions",
|
||||||
|
"capsPercent": "Caps-Anteil (%)",
|
||||||
|
"minLength": "Min. Nachrichtenlänge",
|
||||||
|
"maxEmojis": "Max. Emojis",
|
||||||
|
"duplicateCount": "Duplikat-Anzahl"
|
||||||
|
},
|
||||||
|
"linkWhitelist": "Link-Whitelist (Hosts)",
|
||||||
|
"linkBlacklist": "Link-Blacklist (Hosts)",
|
||||||
|
"addHost": "Host hinzufügen",
|
||||||
|
"wordList": "Wortliste",
|
||||||
|
"regexList": "Regex-Muster",
|
||||||
|
"wordPlaceholder": "verbotenes Wort",
|
||||||
|
"addWord": "Wort hinzufügen",
|
||||||
|
"addRegex": "Regex hinzufügen",
|
||||||
|
"exceptionRoles": "Ausnahme-Rollen",
|
||||||
|
"exceptionChannels": "Ausnahme-Kanäle",
|
||||||
|
"rules": {
|
||||||
|
"SPAM": {
|
||||||
|
"title": "Spam",
|
||||||
|
"description": "Zu viele Nachrichten in kurzer Zeit."
|
||||||
|
},
|
||||||
|
"MASS_MENTION": {
|
||||||
|
"title": "Massen-Mentions",
|
||||||
|
"description": "Zu viele @-Mentions in einer Nachricht."
|
||||||
|
},
|
||||||
|
"CAPS": {
|
||||||
|
"title": "Caps",
|
||||||
|
"description": "Überwiegend Großbuchstaben."
|
||||||
|
},
|
||||||
|
"INVITE_LINK": {
|
||||||
|
"title": "Invite-Links",
|
||||||
|
"description": "Discord-Invite-Links blockieren."
|
||||||
|
},
|
||||||
|
"EXTERNAL_LINK": {
|
||||||
|
"title": "Externe Links",
|
||||||
|
"description": "Links gegen Whitelist/Blacklist prüfen."
|
||||||
|
},
|
||||||
|
"WORD_FILTER": {
|
||||||
|
"title": "Wortfilter",
|
||||||
|
"description": "Wörter und Regex-Muster."
|
||||||
|
},
|
||||||
|
"DUPLICATE": {
|
||||||
|
"title": "Duplikate",
|
||||||
|
"description": "Wiederholte gleiche Nachrichten."
|
||||||
|
},
|
||||||
|
"EMOJI_SPAM": {
|
||||||
|
"title": "Emoji-Spam",
|
||||||
|
"description": "Zu viele Emojis in einer Nachricht."
|
||||||
|
},
|
||||||
|
"ZALGO": {
|
||||||
|
"title": "Zalgo",
|
||||||
|
"description": "Zalgo-/Combining-Zeichen blockieren."
|
||||||
|
},
|
||||||
|
"PHISHING": {
|
||||||
|
"title": "Phishing",
|
||||||
|
"description": "Bekannte Scam-/Phishing-Domains."
|
||||||
|
}
|
||||||
|
},
|
||||||
"antiRaidTitle": "Anti-Raid",
|
"antiRaidTitle": "Anti-Raid",
|
||||||
"antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen und aktiviert automatisch einen Lockdown.",
|
"antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen.",
|
||||||
"antiRaidEnabled": "Anti-Raid aktiviert",
|
"antiRaidEnabled": "Anti-Raid aktiviert",
|
||||||
"antiRaidJoinThreshold": "Schwellenwert (Joins)",
|
"antiRaidJoinThreshold": "Schwellenwert (Joins)",
|
||||||
"antiRaidWindowSeconds": "Zeitfenster (Sekunden)",
|
"antiRaidWindowSeconds": "Zeitfenster (Sekunden)",
|
||||||
"antiRaidActionHint": "Aktuell implementierte Reaktion: Server-Lockdown.",
|
"antiRaidAction": "Reaktion",
|
||||||
|
"antiRaidActions": {
|
||||||
|
"LOCKDOWN": "Lockdown (Kanäle sperren)",
|
||||||
|
"VERIFY": "Verify (Unverified-Rolle)"
|
||||||
|
},
|
||||||
|
"antiRaidActionHint": "Verify setzt die Unverified-Rolle aus dem Verifizierungsmodul und gilt für weitere Joins, bis der Lockdown-Status zurückgesetzt wird.",
|
||||||
"antiNukeTitle": "Anti-Nuke",
|
"antiNukeTitle": "Anti-Nuke",
|
||||||
"antiNukeDescription": "Schützt vor Massen-Bans oder Massen-Kanal-Löschungen durch kompromittierte Accounts.",
|
"antiNukeDescription": "Schützt vor Massen-Bans oder Massen-Kanal-Löschungen durch kompromittierte Accounts.",
|
||||||
"antiNukeEnabled": "Anti-Nuke aktiviert",
|
"antiNukeEnabled": "Anti-Nuke aktiviert",
|
||||||
"antiNukeBanThreshold": "Schwellenwert (Bans)",
|
"antiNukeBanThreshold": "Schwellenwert (Bans)",
|
||||||
"antiNukeChannelThreshold": "Schwellenwert (Kanal-Löschungen)",
|
"antiNukeChannelThreshold": "Schwellenwert (Kanal-Löschungen)",
|
||||||
"antiNukeWindowSeconds": "Zeitfenster (Sekunden)",
|
"antiNukeWindowSeconds": "Zeitfenster (Sekunden)",
|
||||||
"lockdownActive": "Lockdown aktiv",
|
"lockdownActive": "Raid-Reaktion aktiv",
|
||||||
"lockdownActiveHint": "Wird automatisch bei einem erkannten Raid gesetzt. Manuell zurücksetzen, um den Lockdown zu beenden."
|
"lockdownActiveHint": "Bei Lockdown: Kanäle entsperren beim Ausschalten. Bei Verify: nur Status zurücksetzen."
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"title": "Logging",
|
"title": "Logging",
|
||||||
|
|||||||
@@ -347,34 +347,121 @@
|
|||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"reasonPlaceholder": "Optional default reason",
|
"reasonPlaceholder": "Optional default reason",
|
||||||
"casesTitle": "Cases",
|
"casesTitle": "Cases",
|
||||||
"casesDescription": "List of the most recent moderation cases (read-only).",
|
"casesDescription": "View moderation cases, edit the reason, or soft-delete.",
|
||||||
"searchPlaceholder": "Search by user/moderator ID or reason",
|
"searchPlaceholder": "Search by user/moderator ID or reason",
|
||||||
"actionPlaceholder": "Filter by action, e.g. BAN",
|
"actionPlaceholder": "Filter by action, e.g. BAN",
|
||||||
"casesEmpty": "No cases found.",
|
"casesEmpty": "No cases found.",
|
||||||
"target": "Target",
|
"target": "Target",
|
||||||
"moderator": "Moderator",
|
"moderator": "Moderator",
|
||||||
"createdAt": "Created at",
|
"createdAt": "Created at",
|
||||||
"noReason": "No reason provided"
|
"noReason": "No reason provided",
|
||||||
|
"editReason": "Edit reason",
|
||||||
|
"saveReason": "Save",
|
||||||
|
"deleteCase": "Delete case",
|
||||||
|
"confirmDeleteCase": "Really delete this case?",
|
||||||
|
"warningsTitle": "Warnings",
|
||||||
|
"warningsDescription": "Recent warnings. Remove deletes the warning entry.",
|
||||||
|
"warningsEmpty": "No warnings found.",
|
||||||
|
"warningsSearchUser": "Filter by user ID",
|
||||||
|
"removeWarning": "Remove warning",
|
||||||
|
"confirmRemoveWarning": "Really remove this warning?"
|
||||||
},
|
},
|
||||||
"automod": {
|
"automod": {
|
||||||
"title": "Auto-Moderation",
|
"title": "Auto-Moderation",
|
||||||
"description": "Base settings plus anti-raid and anti-nuke protection.",
|
"description": "Filter rules, anti-raid and anti-nuke for this server.",
|
||||||
"enabledLabel": "Auto-moderation enabled",
|
"enabledLabel": "Auto-moderation enabled",
|
||||||
"enabledHint": "Enables all auto-moderation rules (spam, word filter, links, …) on this server.",
|
"enabledHint": "Master switch: only enabled rules below apply when this is on.",
|
||||||
|
"rulesTitle": "Filter rules",
|
||||||
|
"rulesDescription": "Per rule: action, thresholds, word/link lists and exceptions.",
|
||||||
|
"ruleAction": "Action",
|
||||||
|
"timeoutMinutes": "Timeout (minutes)",
|
||||||
|
"actions": {
|
||||||
|
"DELETE": "Delete message",
|
||||||
|
"WARN": "Warn",
|
||||||
|
"TIMEOUT": "Timeout",
|
||||||
|
"KICK": "Kick",
|
||||||
|
"BAN": "Ban"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"maxMessages": "Max messages",
|
||||||
|
"windowSeconds": "Time window (seconds)",
|
||||||
|
"maxMentions": "Max mentions",
|
||||||
|
"capsPercent": "Caps ratio (%)",
|
||||||
|
"minLength": "Min message length",
|
||||||
|
"maxEmojis": "Max emojis",
|
||||||
|
"duplicateCount": "Duplicate count"
|
||||||
|
},
|
||||||
|
"linkWhitelist": "Link whitelist (hosts)",
|
||||||
|
"linkBlacklist": "Link blacklist (hosts)",
|
||||||
|
"addHost": "Add host",
|
||||||
|
"wordList": "Word list",
|
||||||
|
"regexList": "Regex patterns",
|
||||||
|
"wordPlaceholder": "banned word",
|
||||||
|
"addWord": "Add word",
|
||||||
|
"addRegex": "Add regex",
|
||||||
|
"exceptionRoles": "Exception roles",
|
||||||
|
"exceptionChannels": "Exception channels",
|
||||||
|
"rules": {
|
||||||
|
"SPAM": {
|
||||||
|
"title": "Spam",
|
||||||
|
"description": "Too many messages in a short time."
|
||||||
|
},
|
||||||
|
"MASS_MENTION": {
|
||||||
|
"title": "Mass mentions",
|
||||||
|
"description": "Too many @-mentions in one message."
|
||||||
|
},
|
||||||
|
"CAPS": {
|
||||||
|
"title": "Caps",
|
||||||
|
"description": "Mostly uppercase text."
|
||||||
|
},
|
||||||
|
"INVITE_LINK": {
|
||||||
|
"title": "Invite links",
|
||||||
|
"description": "Block Discord invite links."
|
||||||
|
},
|
||||||
|
"EXTERNAL_LINK": {
|
||||||
|
"title": "External links",
|
||||||
|
"description": "Check links against whitelist/blacklist."
|
||||||
|
},
|
||||||
|
"WORD_FILTER": {
|
||||||
|
"title": "Word filter",
|
||||||
|
"description": "Words and regex patterns."
|
||||||
|
},
|
||||||
|
"DUPLICATE": {
|
||||||
|
"title": "Duplicates",
|
||||||
|
"description": "Repeated identical messages."
|
||||||
|
},
|
||||||
|
"EMOJI_SPAM": {
|
||||||
|
"title": "Emoji spam",
|
||||||
|
"description": "Too many emojis in one message."
|
||||||
|
},
|
||||||
|
"ZALGO": {
|
||||||
|
"title": "Zalgo",
|
||||||
|
"description": "Block zalgo/combining characters."
|
||||||
|
},
|
||||||
|
"PHISHING": {
|
||||||
|
"title": "Phishing",
|
||||||
|
"description": "Known scam/phishing domains."
|
||||||
|
}
|
||||||
|
},
|
||||||
"antiRaidTitle": "Anti-Raid",
|
"antiRaidTitle": "Anti-Raid",
|
||||||
"antiRaidDescription": "Detects unusually fast join waves and automatically activates a lockdown.",
|
"antiRaidDescription": "Detects unusually fast join waves.",
|
||||||
"antiRaidEnabled": "Anti-raid enabled",
|
"antiRaidEnabled": "Anti-raid enabled",
|
||||||
"antiRaidJoinThreshold": "Join threshold",
|
"antiRaidJoinThreshold": "Join threshold",
|
||||||
"antiRaidWindowSeconds": "Time window (seconds)",
|
"antiRaidWindowSeconds": "Time window (seconds)",
|
||||||
"antiRaidActionHint": "Currently implemented response: server lockdown.",
|
"antiRaidAction": "Response",
|
||||||
|
"antiRaidActions": {
|
||||||
|
"LOCKDOWN": "Lockdown (lock channels)",
|
||||||
|
"VERIFY": "Verify (unverified role)"
|
||||||
|
},
|
||||||
|
"antiRaidActionHint": "Verify applies the unverified role from the Verification module and continues for new joins until the raid status is cleared.",
|
||||||
"antiNukeTitle": "Anti-Nuke",
|
"antiNukeTitle": "Anti-Nuke",
|
||||||
"antiNukeDescription": "Protects against mass bans or mass channel deletions by compromised accounts.",
|
"antiNukeDescription": "Protects against mass bans or mass channel deletions by compromised accounts.",
|
||||||
"antiNukeEnabled": "Anti-nuke enabled",
|
"antiNukeEnabled": "Anti-nuke enabled",
|
||||||
"antiNukeBanThreshold": "Ban threshold",
|
"antiNukeBanThreshold": "Ban threshold",
|
||||||
"antiNukeChannelThreshold": "Channel deletion threshold",
|
"antiNukeChannelThreshold": "Channel deletion threshold",
|
||||||
"antiNukeWindowSeconds": "Time window (seconds)",
|
"antiNukeWindowSeconds": "Time window (seconds)",
|
||||||
"lockdownActive": "Lockdown active",
|
"lockdownActive": "Raid response active",
|
||||||
"lockdownActiveHint": "Automatically set when a raid is detected. Toggle off manually to end the lockdown."
|
"lockdownActiveHint": "Lockdown: unlock channels when turned off. Verify: only clears the status flag."
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"title": "Logging",
|
"title": "Logging",
|
||||||
|
|||||||
@@ -258,6 +258,25 @@
|
|||||||
- [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle
|
- [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle
|
||||||
- [ ] Doppeltes Level → Fehlermeldung, speichern blockiert
|
- [ ] Doppeltes Level → Fehlermeldung, speichern blockiert
|
||||||
|
|
||||||
|
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Automod: alle 10 Filterregeln im Dashboard (Aktion, Schwellen, Link-WL/BL, Wörter/Regex, Ausnahmen)
|
||||||
|
- Lockdown an/aus enqueued `activateLockdown` / `deactivateLockdown` an den Bot
|
||||||
|
- Anti-Raid-Aktion `VERIFY` (Unverified-Rolle) zusätzlich zu `LOCKDOWN`
|
||||||
|
- Moderation: Warn-Tabelle + Case edit/delete im Dashboard
|
||||||
|
- `/lock` `/unlock` Option `server` für guild-weiten Lock
|
||||||
|
- Shared: `AutomodRuleDashboardSchema`, `CaseUpdateDashboardSchema`, `AntiRaidAction` inkl. VERIFY
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Automod: WORD_FILTER Wörter setzen, EXTERNAL_LINK Whitelist, Regel speichern → Filter greift
|
||||||
|
- [ ] Lockdown-Toggle aus → Kanäle wieder beschreibbar
|
||||||
|
- [ ] Anti-Raid VERIFY: Unverified-Rolle in Verification konfiguriert, Join-Welle → Rolle
|
||||||
|
- [ ] Cases: Grund editieren / löschen; Warnings entfernen
|
||||||
|
- [ ] `/lock server:true` und `/unlock server:true`
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -140,12 +140,20 @@ export const commandLocales = {
|
|||||||
en: '0-21600'
|
en: '0-21600'
|
||||||
},
|
},
|
||||||
'moderation.lock.description': {
|
'moderation.lock.description': {
|
||||||
de: 'Aktuellen Textkanal sperren',
|
de: 'Kanal oder gesamten Server sperren',
|
||||||
en: 'Lock current text channel'
|
en: 'Lock channel or entire server'
|
||||||
|
},
|
||||||
|
'moderation.lock.options.server': {
|
||||||
|
de: 'Gesamten Server sperren (alle Textkanäle)',
|
||||||
|
en: 'Lock the entire server (all text channels)'
|
||||||
},
|
},
|
||||||
'moderation.unlock.description': {
|
'moderation.unlock.description': {
|
||||||
de: 'Aktuellen Textkanal entsperren',
|
de: 'Kanal oder gesamten Server entsperren',
|
||||||
en: 'Unlock current text channel'
|
en: 'Unlock channel or entire server'
|
||||||
|
},
|
||||||
|
'moderation.unlock.options.server': {
|
||||||
|
de: 'Gesamten Server entsperren',
|
||||||
|
en: 'Unlock the entire server'
|
||||||
},
|
},
|
||||||
'moderation.nick.description': {
|
'moderation.nick.description': {
|
||||||
de: 'Nicknames verwalten',
|
de: 'Nicknames verwalten',
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
embedHasContent,
|
embedHasContent,
|
||||||
LeaveMessageTypeSchema,
|
LeaveMessageTypeSchema,
|
||||||
WelcomeEmbedSchema
|
WelcomeEmbedSchema,
|
||||||
|
AutoModRuleTypeSchema,
|
||||||
|
AutoModActionSchema,
|
||||||
|
AutoModExceptionsSchema,
|
||||||
|
AutoModThresholdSchema,
|
||||||
|
AutoModWordListSchema
|
||||||
} from './phase2.js';
|
} from './phase2.js';
|
||||||
import {
|
import {
|
||||||
SelfRoleBehaviorSchema,
|
SelfRoleBehaviorSchema,
|
||||||
@@ -111,7 +116,18 @@ export type WarningListQuery = z.infer<typeof WarningListQuerySchema>;
|
|||||||
// AutoMod
|
// AutoMod
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const AntiRaidActionSchema = z.enum(['LOCKDOWN']);
|
export const AntiRaidActionSchema = z.enum(['LOCKDOWN', 'VERIFY']);
|
||||||
|
|
||||||
|
export const AutomodRuleDashboardSchema = z.object({
|
||||||
|
ruleType: AutoModRuleTypeSchema,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
action: AutoModActionSchema,
|
||||||
|
durationMs: z.number().int().positive().nullable().optional(),
|
||||||
|
threshold: AutoModThresholdSchema.default({}),
|
||||||
|
exceptions: AutoModExceptionsSchema.default({ roleIds: [], channelIds: [] }),
|
||||||
|
wordList: AutoModWordListSchema.default({ words: [], regexPatterns: [] })
|
||||||
|
});
|
||||||
|
export type AutomodRuleDashboard = z.infer<typeof AutomodRuleDashboardSchema>;
|
||||||
|
|
||||||
export const AutomodConfigDashboardSchema = z.object({
|
export const AutomodConfigDashboardSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -123,13 +139,30 @@ export const AutomodConfigDashboardSchema = z.object({
|
|||||||
antiNukeBanThreshold: z.number().int().positive(),
|
antiNukeBanThreshold: z.number().int().positive(),
|
||||||
antiNukeChannelThreshold: z.number().int().positive(),
|
antiNukeChannelThreshold: z.number().int().positive(),
|
||||||
antiNukeWindowSeconds: z.number().int().positive(),
|
antiNukeWindowSeconds: z.number().int().positive(),
|
||||||
lockdownActive: z.boolean()
|
lockdownActive: z.boolean(),
|
||||||
|
rules: z.array(AutomodRuleDashboardSchema)
|
||||||
});
|
});
|
||||||
export type AutomodConfigDashboard = z.infer<typeof AutomodConfigDashboardSchema>;
|
export type AutomodConfigDashboard = z.infer<typeof AutomodConfigDashboardSchema>;
|
||||||
|
|
||||||
export const AutomodConfigDashboardPatchSchema = nonEmptyPatch(AutomodConfigDashboardSchema);
|
export const AutomodConfigDashboardPatchSchema = nonEmptyPatch(AutomodConfigDashboardSchema);
|
||||||
export type AutomodConfigDashboardPatch = z.infer<typeof AutomodConfigDashboardPatchSchema>;
|
export type AutomodConfigDashboardPatch = z.infer<typeof AutomodConfigDashboardPatchSchema>;
|
||||||
|
|
||||||
|
export const CaseUpdateDashboardSchema = z.object({
|
||||||
|
reason: z.string().trim().min(1).max(1000)
|
||||||
|
});
|
||||||
|
export type CaseUpdateDashboard = z.infer<typeof CaseUpdateDashboardSchema>;
|
||||||
|
|
||||||
|
/** Returns true when every regex pattern compiles. */
|
||||||
|
export function validateAutomodRegexPatterns(patterns: string[]): string | null {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
try {
|
||||||
|
RegExp(pattern, 'iu');
|
||||||
|
} catch {
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logging
|
// Logging
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user