deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
22 changed files with 1517 additions and 242 deletions
Showing only changes of commit bd1b55fff7 - Show all commits

View File

@@ -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') {
const count = await refreshPhishingDomains(redis);
logger.info({ domainCount: count }, 'Phishing domain list refreshed');
return; return;
} }
const count = await refreshPhishingDomains(redis); if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') {
logger.info({ domainCount: count }, 'Phishing domain list refreshed'); 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 }
); );

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'
); );

View File

@@ -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
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: false 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
});
}
} else if (interaction.channel?.type === ChannelType.GuildText) {
await interaction.channel.permissionOverwrites.edit(guild.roles.everyone, {
SendMessages: null 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);
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>
); );

View File

@@ -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,112 +365,181 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
</CardContent> </CardContent>
</Card> </Card>
<FieldAnchor id="field-anti-raid"> <FieldAnchor id="field-automod-rules">
<Card> <div className="space-y-4">
<CardHeader> <div>
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle> <h2 className="text-lg font-semibold">{t('modulePages.automod.rulesTitle')}</h2>
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription> <p className="text-sm text-muted-foreground">{t('modulePages.automod.rulesDescription')}</p>
</CardHeader> </div>
<CardContent className="space-y-4"> {value.rules.map((rule) => (
<div className="flex items-center justify-between rounded-md border border-border p-4"> <RuleCard
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label> key={rule.ruleType}
<Switch guildId={guildId}
checked={value.antiRaidEnabled} rule={rule}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))} onChange={(patch) =>
setValue((prev) => ({
...prev,
rules: updateRule(prev.rules, rule.ruleType, patch)
}))
}
/> />
</div> ))}
<div className="grid gap-4 sm:grid-cols-2"> </div>
<div className="space-y-2"> </FieldAnchor>
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
<Input <FieldAnchor id="field-anti-raid">
type="number" <Card>
min={1} <CardHeader>
value={value.antiRaidJoinThreshold} <CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
onChange={(event) => <CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 })) </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>
<div className="space-y-2"> <div className="grid gap-4 sm:grid-cols-3">
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label> <div className="space-y-2">
<Input <Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
type="number" <Input
min={1} type="number"
value={value.antiRaidWindowSeconds} min={1}
onChange={(event) => value={value.antiRaidJoinThreshold}
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 })) 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>
</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> </Card>
</Card>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-anti-nuke"> <FieldAnchor id="field-anti-nuke">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle> <CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription> <CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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">
<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"> <Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
<Label>{t('modulePages.automod.lockdownActive')}</Label>
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
</div>
<Switch <Switch
checked={value.lockdownActive} checked={value.antiNukeEnabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))} onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
}
/> />
</div> </div>
</FieldAnchor> <div className="grid gap-4 sm:grid-cols-3">
</CardContent> <div className="space-y-2">
</Card> <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> </FieldAnchor>
</div> </div>
)} )}

View File

@@ -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,86 +50,158 @@ 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>
<CardHeader> <CardHeader>
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle> <CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription> <CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<form <form
className="flex flex-wrap items-end gap-3" className="flex flex-wrap items-end gap-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void fetchCases(); void fetchCases();
}} }}
> >
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Input <Input
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.moderation.searchPlaceholder')} placeholder={t('modulePages.moderation.searchPlaceholder')}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Input <Input
value={action} value={action}
onChange={(event) => setAction(event.target.value)} onChange={(event) => setAction(event.target.value)}
placeholder={t('modulePages.moderation.actionPlaceholder')} placeholder={t('modulePages.moderation.actionPlaceholder')}
className="w-40" className="w-40"
/> />
</div> </div>
<Button type="submit" variant="outline" disabled={loading}> <Button type="submit" variant="outline" disabled={loading}>
<Search className="size-4" /> <Search className="size-4" />
{t('common.confirm')} {t('common.confirm')}
</Button> </Button>
</form> </form>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
{loading ? ( {loading ? (
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-12 rounded-md" /> <Skeleton key={index} className="h-12 rounded-md" />
))} ))}
</div> </div>
) : cases.length === 0 ? ( ) : cases.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p> <p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground"> <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">#</th>
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</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.target')}</th>
<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>
</tr> <th className="py-2 pr-4" />
</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>
</tr> </tr>
))} </thead>
</tbody> <tbody className="divide-y divide-border">
</table> {cases.map((entry) => (
</div> <tr key={entry.id}>
)} <td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
</CardContent> <td className="py-2 pr-4">{entry.action}</td>
</Card> <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> </FieldAnchor>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@@ -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',

View File

@@ -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 ?? []);
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;
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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).

View File

@@ -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',

View File

@@ -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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------