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 { env } from './env.js';
|
||||
import { refreshPhishingDomains } from './modules/automod/filters.js';
|
||||
import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js';
|
||||
import { completeVerification } from './modules/verification/handlers.js';
|
||||
import { closePoll } from './modules/utility/poll.js';
|
||||
import { sendReminder } from './modules/utility/reminders.js';
|
||||
@@ -212,11 +213,28 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
const automodWorker = new Worker(
|
||||
automodQueueName,
|
||||
async (job) => {
|
||||
if (job.name !== 'refreshPhishingList') {
|
||||
if (job.name === 'refreshPhishingList') {
|
||||
const count = await refreshPhishingDomains(redis);
|
||||
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
||||
return;
|
||||
}
|
||||
const count = await refreshPhishingDomains(redis);
|
||||
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
|
||||
if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
|
||||
const guildId = (job.data as { guildId?: string }).guildId;
|
||||
if (!guildId) {
|
||||
return;
|
||||
}
|
||||
const guild = await context.client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) {
|
||||
return;
|
||||
}
|
||||
if (job.name === 'deactivateLockdown') {
|
||||
await deactivateLockdown(guild);
|
||||
logger.info({ guildId }, 'Lockdown deactivated via dashboard');
|
||||
return;
|
||||
}
|
||||
await activateLockdown(guild);
|
||||
logger.info({ guildId }, 'Lockdown activated via dashboard');
|
||||
}
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
@@ -204,6 +204,16 @@ export async function handleAntiRaidJoin(
|
||||
return;
|
||||
}
|
||||
|
||||
// While VERIFY raid-response is active, keep gating new joins with the unverified role.
|
||||
if (config.lockdownActive && config.antiRaidAction === 'VERIFY') {
|
||||
await applyRaidVerifyRole(context, member);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.lockdownActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `automod:raid:${member.guild.id}`;
|
||||
const count = await context.redis.incr(key);
|
||||
if (count === 1) {
|
||||
@@ -214,10 +224,6 @@ export async function handleAntiRaidJoin(
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.lockdownActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.prisma.autoModConfig.update({
|
||||
where: { guildId: member.guild.id },
|
||||
data: { lockdownActive: true }
|
||||
@@ -225,16 +231,45 @@ export async function handleAntiRaidJoin(
|
||||
|
||||
if (config.antiRaidAction === 'LOCKDOWN') {
|
||||
await activateLockdown(member.guild);
|
||||
} else if (config.antiRaidAction === 'VERIFY') {
|
||||
await applyRaidVerifyRole(context, member);
|
||||
}
|
||||
|
||||
const alertChannel = member.guild.systemChannel ?? member.guild.publicUpdatesChannel;
|
||||
if (alertChannel && alertChannel.isTextBased()) {
|
||||
await (alertChannel as TextChannel).send(
|
||||
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s.`
|
||||
`Anti-raid triggered: ${count} joins in ${config.antiRaidWindowSeconds}s (${config.antiRaidAction}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRaidVerifyRole(context: BotContext, member: GuildMember): Promise<void> {
|
||||
const verification = await context.prisma.verificationConfig.findUnique({
|
||||
where: { guildId: member.guild.id }
|
||||
});
|
||||
const roleId = verification?.unverifiedRoleId;
|
||||
if (!roleId) {
|
||||
logger.warn(
|
||||
{ guildId: member.guild.id },
|
||||
'Anti-raid VERIFY triggered but no unverified role is configured'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const me = member.guild.members.me;
|
||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||
return;
|
||||
}
|
||||
const role = member.guild.roles.cache.get(roleId);
|
||||
if (!role || role.position >= me.roles.highest.position) {
|
||||
return;
|
||||
}
|
||||
if (!member.roles.cache.has(roleId)) {
|
||||
await member.roles.add(roleId).catch((error) => {
|
||||
logger.warn({ error, memberId: member.id }, 'Failed to apply unverified role during raid verify');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAntiNukeAction(
|
||||
context: BotContext,
|
||||
guildId: string,
|
||||
|
||||
@@ -18,12 +18,17 @@ export async function getAutoModConfig(prisma: PrismaClient, guildId: string) {
|
||||
|
||||
export async function ensureDefaultAutoModRules(prisma: PrismaClient, guildId: string) {
|
||||
await ensureGuild(prisma, guildId);
|
||||
const existing = await prisma.autoModRule.count({ where: { guildId } });
|
||||
if (existing > 0) {
|
||||
const existing = await prisma.autoModRule.findMany({
|
||||
where: { guildId },
|
||||
select: { ruleType: true }
|
||||
});
|
||||
const have = new Set(existing.map((row) => row.ruleType));
|
||||
const missing = DEFAULT_AUTOMOD_RULES.filter((rule) => !have.has(rule.ruleType));
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
await prisma.autoModRule.createMany({
|
||||
data: DEFAULT_AUTOMOD_RULES.map((rule) => ({
|
||||
data: missing.map((rule) => ({
|
||||
guildId,
|
||||
ruleType: rule.ruleType,
|
||||
action: rule.action,
|
||||
|
||||
@@ -164,14 +164,20 @@ export const slowmodeCommandData = applyCommandDescription(
|
||||
export const lockCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('lock')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||
.addBooleanOption((o) =>
|
||||
applyOptionDescription(o.setName('server'), 'moderation.lock.options.server')
|
||||
),
|
||||
'moderation.lock.description'
|
||||
);
|
||||
|
||||
export const unlockCommandData = applyCommandDescription(
|
||||
new SlashCommandBuilder()
|
||||
.setName('unlock')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
|
||||
.addBooleanOption((o) =>
|
||||
applyOptionDescription(o.setName('server'), 'moderation.unlock.options.server')
|
||||
),
|
||||
'moderation.unlock.description'
|
||||
);
|
||||
|
||||
|
||||
@@ -456,18 +456,31 @@ const lockCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
||||
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
}
|
||||
await channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||
SendMessages: false
|
||||
});
|
||||
}
|
||||
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||
SendMessages: false
|
||||
});
|
||||
}
|
||||
|
||||
const modCase = await createCase(context, {
|
||||
guildId: interaction.guildId!,
|
||||
targetUserId: interaction.user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
...auditFrom(interaction),
|
||||
action: 'LOCK',
|
||||
reason: 'Channel locked'
|
||||
reason: serverWide ? 'Server locked' : 'Channel locked'
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
}
|
||||
@@ -478,18 +491,31 @@ const unlockCommand: SlashCommand = {
|
||||
async execute(interaction, context) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
if (!(await ensureMod(interaction, PermissionFlagsBits.ManageChannels, locale))) return;
|
||||
if (interaction.channel?.type === ChannelType.GuildText) {
|
||||
await interaction.channel.permissionOverwrites.edit(interaction.guild!.roles.everyone, {
|
||||
const serverWide = interaction.options.getBoolean('server') ?? false;
|
||||
const guild = interaction.guild!;
|
||||
|
||||
if (serverWide) {
|
||||
for (const channel of guild.channels.cache.values()) {
|
||||
if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) {
|
||||
continue;
|
||||
}
|
||||
await channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||
SendMessages: null
|
||||
});
|
||||
}
|
||||
} else if (interaction.channel?.type === ChannelType.GuildText) {
|
||||
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
|
||||
SendMessages: null
|
||||
});
|
||||
}
|
||||
|
||||
const modCase = await createCase(context, {
|
||||
guildId: interaction.guildId!,
|
||||
targetUserId: interaction.user.id,
|
||||
moderatorId: interaction.user.id,
|
||||
...auditFrom(interaction),
|
||||
action: 'UNLOCK',
|
||||
reason: 'Channel unlocked'
|
||||
reason: serverWide ? 'Server unlocked' : 'Channel unlocked'
|
||||
});
|
||||
await replySuccessCase(interaction, locale, modCase.caseNumber);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CaseListQuerySchema } from '@nexumi/shared';
|
||||
import { CaseListQuerySchema, CaseUpdateDashboardSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
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 {
|
||||
params: Promise<{ guildId: string }>;
|
||||
@@ -11,15 +13,63 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
await requireGuildAccess(guildId);
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = CaseListQuerySchema.parse({
|
||||
search: searchParams.get('search') ?? undefined,
|
||||
action: searchParams.get('action') ?? undefined,
|
||||
limit: searchParams.get('limit') ?? undefined
|
||||
});
|
||||
const query = CaseListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||
const cases = await listCases(guildId, query);
|
||||
return NextResponse.json({ cases });
|
||||
} catch (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 { type NextRequest, NextResponse } from 'next/server';
|
||||
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 {
|
||||
params: Promise<{ guildId: string }>;
|
||||
@@ -11,13 +13,36 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
await requireGuildAccess(guildId);
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = WarningListQuerySchema.parse({
|
||||
userId: searchParams.get('userId') ?? undefined
|
||||
});
|
||||
const query = WarningListQuerySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
|
||||
const warnings = await listWarnings(guildId, query);
|
||||
return NextResponse.json({ warnings });
|
||||
} 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 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 { ModerationForm } from '@/components/modules/moderation-form';
|
||||
import { WarningsTable } from '@/components/modules/warnings-table';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { listCases } from '@/lib/module-configs/cases';
|
||||
import { getModerationDashboard } from '@/lib/module-configs/moderation';
|
||||
import { listWarnings } from '@/lib/module-configs/warnings';
|
||||
|
||||
interface ModerationPageProps {
|
||||
params: Promise<{ guildId: string }>;
|
||||
@@ -11,10 +13,11 @@ interface ModerationPageProps {
|
||||
|
||||
export default async function ModerationPage({ params }: ModerationPageProps) {
|
||||
const { guildId } = await params;
|
||||
const [locale, moderation, cases] = await Promise.all([
|
||||
const [locale, moderation, cases, warnings] = await Promise.all([
|
||||
getLocale(),
|
||||
getModerationDashboard(guildId),
|
||||
listCases(guildId, CaseListQuerySchema.parse({}))
|
||||
listCases(guildId, CaseListQuerySchema.parse({})),
|
||||
listWarnings(guildId, WarningListQuerySchema.parse({}))
|
||||
]);
|
||||
|
||||
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>
|
||||
</div>
|
||||
<ModerationForm guildId={guildId} initialValue={moderation} />
|
||||
<WarningsTable guildId={guildId} initialWarnings={warnings} />
|
||||
<CasesTable guildId={guildId} initialCases={cases} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,48 @@
|
||||
'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 { useTranslations } from '@/components/locale-provider';
|
||||
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 { 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 type { SettingsSaveResult } 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> {
|
||||
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`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
body: JSON.stringify(cleaned)
|
||||
});
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
guildId: string;
|
||||
initialValue: AutomodConfigDashboard;
|
||||
@@ -36,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
|
||||
<SettingsForm<AutomodConfigDashboard>
|
||||
initialValue={initialValue}
|
||||
onSave={(value) => saveAutomod(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -60,112 +365,181 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldAnchor id="field-anti-raid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiRaidEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
|
||||
<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>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidJoinThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
|
||||
))}
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-anti-raid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiRaidEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidJoinThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiRaidJoinThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiRaidWindowSeconds: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-anti-nuke">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiNukeEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeBanThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeChannelThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FieldAnchor id="field-lockdown">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
|
||||
</div>
|
||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.lockdownActive}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
||||
checked={value.antiNukeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeBanThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeBanThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeChannelThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeChannelThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeWindowSeconds: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FieldAnchor id="field-lockdown">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('modulePages.automod.lockdownActiveHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.lockdownActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, lockdownActive: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { CaseDashboard } from '@nexumi/shared';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Pencil, Search, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
@@ -26,6 +26,8 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
||||
const [action, setAction] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editReason, setEditReason] = useState('');
|
||||
|
||||
const fetchCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -48,86 +50,158 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
||||
}
|
||||
}, [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 (
|
||||
<FieldAnchor id="field-mod-cases">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void fetchCases();
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.actionPlaceholder')}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" disabled={loading}>
|
||||
<Search className="size-4" />
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void fetchCases();
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.actionPlaceholder')}
|
||||
className="w-40"
|
||||
/>
|
||||
</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>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : cases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</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">#</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{cases.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</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.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>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : cases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</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">#</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{cases.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</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.moderatorId}</td>
|
||||
<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')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||
{formatDate(entry.createdAt)}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
keywords: ['cases', 'fälle']
|
||||
},
|
||||
{
|
||||
id: 'setting-mod-warnings',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.moderation.warningsTitle',
|
||||
href: 'moderation',
|
||||
hash: 'field-mod-warnings',
|
||||
keywords: ['warn', 'verwarnung']
|
||||
},
|
||||
// Automod
|
||||
{
|
||||
id: 'setting-automod-enabled',
|
||||
@@ -138,6 +146,14 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'automod',
|
||||
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',
|
||||
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 { getAutomodQueue } from '../queues';
|
||||
|
||||
async function ensureAutoModConfig(guildId: string) {
|
||||
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 {
|
||||
enabled: config.enabled,
|
||||
antiRaidEnabled: config.antiRaidEnabled,
|
||||
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
|
||||
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
|
||||
antiRaidAction: 'LOCKDOWN',
|
||||
antiRaidAction: (config.antiRaidAction === 'VERIFY' ? 'VERIFY' : 'LOCKDOWN') as AutomodConfigDashboard['antiRaidAction'],
|
||||
antiNukeEnabled: config.antiNukeEnabled,
|
||||
antiNukeBanThreshold: config.antiNukeBanThreshold,
|
||||
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
|
||||
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
|
||||
lockdownActive: config.lockdownActive
|
||||
lockdownActive: config.lockdownActive,
|
||||
rules: ordered
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
|
||||
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(
|
||||
guildId: string,
|
||||
patch: AutomodConfigDashboardPatch
|
||||
): Promise<AutomodConfigDashboard> {
|
||||
await ensureAutoModConfig(guildId);
|
||||
const updated = await prisma.autoModConfig.update({
|
||||
where: { guildId },
|
||||
data: patch
|
||||
const before = await getAutomodDashboard(guildId);
|
||||
|
||||
if (patch.rules) {
|
||||
for (const rule of patch.rules) {
|
||||
const bad = validateAutomodRegexPatterns(rule.wordList?.regexPatterns ?? []);
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return toDashboard(updated);
|
||||
|
||||
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 { prisma } from '../prisma';
|
||||
|
||||
@@ -37,3 +37,44 @@ export async function listCases(guildId: string, query: CaseListQuery): Promise<
|
||||
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()
|
||||
}));
|
||||
}
|
||||
|
||||
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 messagesQueueName = 'messages';
|
||||
export const verificationQueueName = 'verification';
|
||||
export const automodQueueName = 'automod';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
@@ -28,6 +29,7 @@ const globalForQueues = globalThis as unknown as {
|
||||
__nexumiSuggestionsQueue?: Queue;
|
||||
__nexumiMessagesQueue?: Queue;
|
||||
__nexumiVerificationQueue?: Queue;
|
||||
__nexumiAutomodQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
@@ -85,6 +87,13 @@ export function getVerificationQueue(): Queue {
|
||||
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
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
|
||||
@@ -347,34 +347,121 @@
|
||||
"reason": "Grund",
|
||||
"reasonPlaceholder": "Optionaler Standardgrund",
|
||||
"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",
|
||||
"actionPlaceholder": "Aktion filtern, z. B. BAN",
|
||||
"casesEmpty": "Keine Fälle gefunden.",
|
||||
"target": "Ziel",
|
||||
"moderator": "Moderator",
|
||||
"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": {
|
||||
"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",
|
||||
"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",
|
||||
"antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen und aktiviert automatisch einen Lockdown.",
|
||||
"antiRaidDescription": "Erkennt ungewöhnlich schnelle Join-Wellen.",
|
||||
"antiRaidEnabled": "Anti-Raid aktiviert",
|
||||
"antiRaidJoinThreshold": "Schwellenwert (Joins)",
|
||||
"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",
|
||||
"antiNukeDescription": "Schützt vor Massen-Bans oder Massen-Kanal-Löschungen durch kompromittierte Accounts.",
|
||||
"antiNukeEnabled": "Anti-Nuke aktiviert",
|
||||
"antiNukeBanThreshold": "Schwellenwert (Bans)",
|
||||
"antiNukeChannelThreshold": "Schwellenwert (Kanal-Löschungen)",
|
||||
"antiNukeWindowSeconds": "Zeitfenster (Sekunden)",
|
||||
"lockdownActive": "Lockdown aktiv",
|
||||
"lockdownActiveHint": "Wird automatisch bei einem erkannten Raid gesetzt. Manuell zurücksetzen, um den Lockdown zu beenden."
|
||||
"lockdownActive": "Raid-Reaktion aktiv",
|
||||
"lockdownActiveHint": "Bei Lockdown: Kanäle entsperren beim Ausschalten. Bei Verify: nur Status zurücksetzen."
|
||||
},
|
||||
"logging": {
|
||||
"title": "Logging",
|
||||
|
||||
@@ -347,34 +347,121 @@
|
||||
"reason": "Reason",
|
||||
"reasonPlaceholder": "Optional default reason",
|
||||
"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",
|
||||
"actionPlaceholder": "Filter by action, e.g. BAN",
|
||||
"casesEmpty": "No cases found.",
|
||||
"target": "Target",
|
||||
"moderator": "Moderator",
|
||||
"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": {
|
||||
"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",
|
||||
"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",
|
||||
"antiRaidDescription": "Detects unusually fast join waves and automatically activates a lockdown.",
|
||||
"antiRaidDescription": "Detects unusually fast join waves.",
|
||||
"antiRaidEnabled": "Anti-raid enabled",
|
||||
"antiRaidJoinThreshold": "Join threshold",
|
||||
"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",
|
||||
"antiNukeDescription": "Protects against mass bans or mass channel deletions by compromised accounts.",
|
||||
"antiNukeEnabled": "Anti-nuke enabled",
|
||||
"antiNukeBanThreshold": "Ban threshold",
|
||||
"antiNukeChannelThreshold": "Channel deletion threshold",
|
||||
"antiNukeWindowSeconds": "Time window (seconds)",
|
||||
"lockdownActive": "Lockdown active",
|
||||
"lockdownActiveHint": "Automatically set when a raid is detected. Toggle off manually to end the lockdown."
|
||||
"lockdownActive": "Raid response active",
|
||||
"lockdownActiveHint": "Lockdown: unlock channels when turned off. Verify: only clears the status flag."
|
||||
},
|
||||
"logging": {
|
||||
"title": "Logging",
|
||||
|
||||
Reference in New Issue
Block a user