Add global command channel rules and enhance command handling

- Introduced global command channel whitelist and blacklist in the GuildSettings model for improved command access control.
- Implemented command gate logic in the command routing to handle channel and role restrictions, providing user feedback for denied commands.
- Enhanced the CommandsManager component to support global rules and channel/role selection, improving the user interface for managing command permissions.
- Updated localization files to include new keys for global command rules and related messages, ensuring clarity in user interactions.
This commit is contained in:
TheOnlyMace
2026-07-22 20:26:40 +02:00
parent 0fb8195236
commit d31660f813
24 changed files with 963 additions and 185 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -64,6 +64,10 @@ model GuildSettings {
moderationEnabled Boolean @default(true)
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
snipeEnabled Boolean @default(false)
/// Global command channel whitelist (empty = all channels unless denied).
commandAllowedChannelIds String[] @default([])
/// Global command channel blacklist.
commandDeniedChannelIds String[] @default([])
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
}

View File

@@ -0,0 +1,38 @@
export type CommandGateReason =
| 'disabled'
| 'channel_denied'
| 'channel_not_allowed'
| 'role_denied'
| 'role_not_allowed'
| 'cooldown';
export function isChannelAllowed(
channelId: string | null,
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (!channelId) {
return null;
}
if (denied.includes(channelId)) {
return 'channel_denied';
}
if (allowed.length > 0 && !allowed.includes(channelId)) {
return 'channel_not_allowed';
}
return null;
}
export function isRoleAllowed(
roleIds: string[],
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (denied.some((id) => roleIds.includes(id))) {
return 'role_denied';
}
if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) {
return 'role_not_allowed';
}
return null;
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js';
describe('command-gates channel rules', () => {
it('allows any channel when allow and deny are empty', () => {
expect(isChannelAllowed('1', [], [])).toBeNull();
});
it('blocks denied channels even when allow is empty', () => {
expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied');
});
it('requires membership when allow list is set', () => {
expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed');
expect(isChannelAllowed('2', ['2'], [])).toBeNull();
});
it('prefers deny over allow', () => {
expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied');
});
});
describe('command-gates role rules', () => {
it('blocks when any denied role is present', () => {
expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied');
});
it('requires an allowed role when allow list is set', () => {
expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed');
expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import type { BotContext } from './types.js';
import { redis } from './redis.js';
import {
isChannelAllowed,
isRoleAllowed,
type CommandGateReason
} from './command-gate-rules.js';
export type { CommandGateReason };
export type CommandGateResult =
| { ok: true }
| { ok: false; reason: CommandGateReason; retryAfterSeconds?: number };
function memberRoleIds(member: GuildMember | null): string[] {
if (!member) {
return [];
}
return [...member.roles.cache.keys()];
}
/**
* Applies guild-wide command channel rules and per-command overrides.
* Empty allow-lists mean "no restriction"; deny-lists always block.
* Per-command allow/deny is evaluated after global channel rules.
*/
export async function evaluateCommandGate(
interaction: ChatInputCommandInteraction,
context: BotContext
): Promise<CommandGateResult> {
if (!interaction.guildId) {
return { ok: true };
}
const [settings, override] = await Promise.all([
context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }),
context.prisma.commandOverride.findUnique({
where: {
guildId_commandName: {
guildId: interaction.guildId,
commandName: interaction.commandName
}
}
})
]);
if (override && !override.enabled) {
return { ok: false, reason: 'disabled' };
}
const channelId = interaction.channelId;
const globalChannelBlock = isChannelAllowed(
channelId,
settings?.commandAllowedChannelIds ?? [],
settings?.commandDeniedChannelIds ?? []
);
if (globalChannelBlock) {
return { ok: false, reason: globalChannelBlock };
}
if (override) {
const perCommandChannelBlock = isChannelAllowed(
channelId,
override.allowedChannelIds,
override.deniedChannelIds
);
if (perCommandChannelBlock) {
return { ok: false, reason: perCommandChannelBlock };
}
const member =
interaction.member && 'roles' in interaction.member
? (interaction.member as GuildMember)
: null;
const roleIds = memberRoleIds(member);
const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds);
if (roleBlock) {
return { ok: false, reason: roleBlock };
}
if (override.cooldownSeconds && override.cooldownSeconds > 0) {
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
const existing = await redis.ttl(key);
if (existing > 0) {
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
}
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}
}
return { ok: true };
}

View File

@@ -4,10 +4,11 @@ import {
type ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction
} from 'discord.js';
import { t } from '@nexumi/shared';
import { t, tf, type Locale } from '@nexumi/shared';
import { env } from './env.js';
import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import { isMaintenanceMode } from './presence.js';
import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/commands.js';
@@ -95,6 +96,25 @@ export async function registerCommands() {
);
}
function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string {
switch (reason) {
case 'disabled':
return t(locale, 'generic.commandDisabled');
case 'channel_denied':
case 'channel_not_allowed':
return t(locale, 'generic.commandChannelBlocked');
case 'role_denied':
case 'role_not_allowed':
return t(locale, 'generic.commandRoleBlocked');
case 'cooldown':
return tf(locale, 'generic.commandCooldown', {
seconds: retryAfterSeconds ?? 1
});
default:
return t(locale, 'generic.noPermission');
}
}
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context);
@@ -112,6 +132,16 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return;
}
const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) {
await interaction.reply({
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
ephemeral: true
});
return;
}
await command.execute(interaction, context);
}

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.21",
"@radix-ui/react-label": "^2.1.12",
"@radix-ui/react-popover": "^1.1.20",
"@radix-ui/react-select": "^2.3.4",
"@radix-ui/react-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0",

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const channels = await listGuildChannelsForDashboard(guildId);
return NextResponse.json(channels);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,46 @@
import { CommandGlobalRulesPatchSchema } from '@nexumi/shared';
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { writeDashboardAudit } from '@/lib/audit';
import { getCommandGlobalRules, updateCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { prisma } from '@/lib/prisma';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const rules = await getCommandGlobalRules(guildId);
return NextResponse.json(rules);
} 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();
const patch = CommandGlobalRulesPatchSchema.parse(body);
const before = await getCommandGlobalRules(guildId);
const after = await updateCommandGlobalRules(guildId, patch);
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'commands.global.update',
path: `/dashboard/${guildId}/commands`,
before,
after
});
return NextResponse.json(after);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server';
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
import { listGuildRolesForDashboard } from '@/lib/discord-guild-resources';
interface RouteParams {
params: Promise<{ guildId: string }>;
}
export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const roles = await listGuildRolesForDashboard(guildId);
return NextResponse.json(roles);
} catch (error) {
return toApiErrorResponse(error);
}
}

View File

@@ -1,6 +1,8 @@
import { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager';
import { listGuildChannelsForDashboard, listGuildRolesForDashboard } from '@/lib/discord-guild-resources';
import { getLocale, t } from '@/lib/i18n';
import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
interface CommandsPageProps {
@@ -9,7 +11,13 @@ interface CommandsPageProps {
export default async function CommandsPage({ params }: CommandsPageProps) {
const { guildId } = await params;
const [locale, commands] = await Promise.all([getLocale(), listCommandOverridesDashboard(guildId)]);
const [locale, commands, globalRules, channels, roles] = await Promise.all([
getLocale(),
listCommandOverridesDashboard(guildId),
getCommandGlobalRules(guildId),
listGuildChannelsForDashboard(guildId).catch(() => []),
listGuildRolesForDashboard(guildId).catch(() => [])
]);
return (
<div className="space-y-6">
@@ -18,7 +26,13 @@ export default async function CommandsPage({ params }: CommandsPageProps) {
<p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
</div>
<Suspense fallback={null}>
<CommandsManager guildId={guildId} initialCommands={commands} />
<CommandsManager
guildId={guildId}
initialCommands={commands}
initialGlobalRules={globalRules}
channels={channels}
roles={roles}
/>
</Suspense>
</div>
);

View File

@@ -1,6 +1,11 @@
'use client';
import type { CommandOverrideDashboard } from '@nexumi/shared';
import type {
CommandGlobalRules,
CommandOverrideDashboard,
DiscordChannelOption,
DiscordRoleOption
} from '@nexumi/shared';
import { RotateCcw } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
@@ -8,20 +13,10 @@ import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
interface LocalOverride extends CommandOverrideDashboard {
saving?: boolean;
}
@@ -29,13 +24,34 @@ interface LocalOverride extends CommandOverrideDashboard {
interface CommandsManagerProps {
guildId: string;
initialCommands: CommandOverrideDashboard[];
initialGlobalRules: CommandGlobalRules;
channels: DiscordChannelOption[];
roles: DiscordRoleOption[];
}
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
export function CommandsManager({
guildId,
initialCommands,
initialGlobalRules,
channels,
roles
}: CommandsManagerProps) {
const t = useTranslations();
const searchParams = useSearchParams();
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
const [search, setSearch] = useState('');
const [globalRules, setGlobalRules] = useState(initialGlobalRules);
const [globalSaving, setGlobalSaving] = useState(false);
const [globalDirty, setGlobalDirty] = useState(false);
const channelOptions = useMemo(
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
[channels]
);
const roleOptions = useMemo(
() => roles.map((role) => ({ id: role.id, name: role.name, prefix: '@' })),
[roles]
);
useEffect(() => {
const q = searchParams.get('q');
@@ -58,6 +74,37 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
);
}
function patchGlobal(patch: Partial<CommandGlobalRules>) {
setGlobalRules((prev) => ({ ...prev, ...patch }));
setGlobalDirty(true);
}
async function handleSaveGlobal() {
setGlobalSaving(true);
try {
const response = await fetch(`/api/guilds/${guildId}/commands/globals`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(globalRules)
});
const body = (await response.json().catch(() => null)) as (CommandGlobalRules & { error?: string }) | null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
setGlobalRules({
allowedChannelIds: body.allowedChannelIds,
deniedChannelIds: body.deniedChannelIds
});
setGlobalDirty(false);
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
} finally {
setGlobalSaving(false);
}
}
async function handleSave(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true });
try {
@@ -110,122 +157,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
}
return (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<Input
value={toCsv(entry.allowedRoleIds)}
onChange={(event) =>
patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<Input
value={toCsv(entry.deniedRoleIds)}
onChange={(event) =>
patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<Input
value={toCsv(entry.allowedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, {
allowedChannelIds: fromCsv(event.target.value)
})
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<Input
value={toCsv(entry.deniedChannelIds)}
onChange={(event) =>
patchLocal(entry.commandName, {
deniedChannelIds: fromCsv(event.target.value)
})
}
placeholder={t('modulePages.commands.idsPlaceholder')}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={entry.saving}
onClick={() => handleReset(entry)}
>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.globalTitle')}</CardTitle>
<CardDescription>{t('modulePages.commands.globalDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div id="field-commands-global-allow" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalAllowedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalAllowedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.allowedChannelIds}
onChange={(allowedChannelIds) => patchGlobal({ allowedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-2">
<div id="field-commands-global-deny" className="scroll-mt-24 space-y-2">
<p className="text-sm font-medium">{t('modulePages.commands.globalDeniedChannels')}</p>
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalDeniedHint')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={globalRules.deniedChannelIds}
onChange={(deniedChannelIds) => patchGlobal({ deniedChannelIds })}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={globalSaving}
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={!globalDirty || globalSaving}
onClick={() => void handleSaveGlobal()}
>
{globalSaving ? t('common.saving') : t('common.save')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t('modulePages.commands.searchPlaceholder')}
className="max-w-sm"
/>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
)}
{filtered.map((entry) => (
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
<div className="flex items-center gap-2">
<Switch
checked={entry.enabled}
disabled={entry.saving}
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
<Input
type="number"
min={0}
value={entry.cooldownSeconds ?? ''}
onChange={(event) =>
patchLocal(entry.commandName, {
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
})
}
placeholder={t('common.optional')}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
value={entry.allowedRoleIds}
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
value={entry.deniedRoleIds}
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.allowedChannelIds}
onChange={(allowedChannelIds) =>
patchLocal(entry.commandName, { allowedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
<DiscordResourceMultiSelect
options={channelOptions}
value={entry.deniedChannelIds}
onChange={(deniedChannelIds) =>
patchLocal(entry.commandName, { deniedChannelIds })
}
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
disabled={entry.saving}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={entry.saving}
onClick={() => void handleReset(entry)}
>
<RotateCcw className="size-4" />
{t('modulePages.commands.reset')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={entry.saving}
onClick={() => void handleSave(entry)}
>
{entry.saving ? t('common.saving') : t('common.save')}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,132 @@
'use client';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export interface ResourceOption {
id: string;
name: string;
/** Optional prefix shown before the name (e.g. # for channels). */
prefix?: string;
}
interface DiscordResourceMultiSelectProps {
options: ResourceOption[];
value: string[];
onChange: (next: string[]) => void;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
}
export function DiscordResourceMultiSelect({
options,
value,
onChange,
placeholder,
disabled = false,
emptyLabel
}: DiscordResourceMultiSelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const selected = useMemo(() => {
const byId = new Map(options.map((option) => [option.id, option]));
return value.map((id) => byId.get(id) ?? { id, name: id, prefix: '' });
}, [options, value]);
function toggle(id: string) {
if (value.includes(id)) {
onChange(value.filter((entry) => entry !== id));
} else {
onChange([...value, id]);
}
}
function remove(id: string) {
onChange(value.filter((entry) => entry !== id));
}
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="h-auto min-h-9 w-full justify-between px-3 py-2 font-normal"
>
<span className="truncate text-left text-muted-foreground">
{placeholder ?? t('common.searchSelect')}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={placeholder ?? t('common.searchSelect')} />
<CommandList>
<CommandEmpty>{emptyLabel ?? t('common.noResults')}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = value.includes(option.id);
return (
<CommandItem
key={option.id}
value={`${option.prefix ?? ''}${option.name} ${option.id}`}
onSelect={() => toggle(option.id)}
>
<Check className={cn('size-4', isSelected ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">
{option.prefix ?? ''}
{option.name}
</span>
<span className="ml-auto font-mono text-[10px] text-muted-foreground">
{option.id.slice(-6)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selected.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selected.map((option) => (
<button
key={option.id}
type="button"
disabled={disabled}
onClick={() => remove(option.id)}
className="inline-flex items-center gap-1 rounded-md border border-border bg-secondary px-2 py-0.5 text-xs hover:bg-accent"
>
<span className="max-w-40 truncate">
{option.prefix ?? ''}
{option.name}
</span>
<X className="size-3 opacity-60" />
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-[var(--radix-popover-trigger-width)] rounded-md border border-border bg-popover p-0 text-popover-foreground shadow-md outline-none',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -90,6 +90,22 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
hash: 'field-access-modules',
keywords: ['access']
},
{
id: 'setting-commands-global-allow',
kind: 'setting',
labelKey: 'modulePages.commands.globalAllowedChannels',
href: 'commands',
hash: 'field-commands-global-allow',
keywords: ['whitelist', 'channel', 'command']
},
{
id: 'setting-commands-global-deny',
kind: 'setting',
labelKey: 'modulePages.commands.globalDeniedChannels',
href: 'commands',
hash: 'field-commands-global-deny',
keywords: ['blacklist', 'channel', 'command']
},
// Moderation
{
id: 'setting-mod-enabled',

View File

@@ -0,0 +1,88 @@
import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared';
import { env } from './env';
import { redis } from './redis';
const DISCORD_API_BASE = 'https://discord.com/api/v10';
const CACHE_TTL_SECONDS = 60;
/** Discord channel types usable for slash commands. */
const COMMAND_CHANNEL_TYPES = new Set([
0, // GUILD_TEXT
5, // GUILD_ANNOUNCEMENT
10, // ANNOUNCEMENT_THREAD
11, // PUBLIC_THREAD
12, // PRIVATE_THREAD
15, // GUILD_FORUM
16 // GUILD_MEDIA
]);
async function discordBotFetch<T>(path: string): Promise<T> {
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${env.BOT_TOKEN}` },
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`Discord API request to ${path} failed with status ${response.status}`);
}
return (await response.json()) as T;
}
interface DiscordChannelRest {
id: string;
name: string;
type: number;
parent_id?: string | null;
position?: number;
}
interface DiscordRoleRest {
id: string;
name: string;
color: number;
position: number;
managed?: boolean;
}
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
const cacheKey = `dashboard:guild:${guildId}:channels`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as DiscordChannelOption[];
}
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
const options = channels
.filter((channel) => COMMAND_CHANNEL_TYPES.has(channel.type))
.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
parentId: channel.parent_id ?? null
}))
.sort((a, b) => a.name.localeCompare(b.name));
await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS);
return options;
}
export async function listGuildRolesForDashboard(guildId: string): Promise<DiscordRoleOption[]> {
const cacheKey = `dashboard:guild:${guildId}:roles`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as DiscordRoleOption[];
}
const roles = await discordBotFetch<DiscordRoleRest[]>(`/guilds/${guildId}/roles`);
const options = roles
.filter((role) => role.name !== '@everyone')
.map((role) => ({
id: role.id,
name: role.name,
color: role.color,
position: role.position
}))
.sort((a, b) => b.position - a.position);
await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS);
return options;
}

View File

@@ -0,0 +1,50 @@
import type { CommandGlobalRules, CommandGlobalRulesPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureGuildSettings(guildId: string) {
await prisma.guild.upsert({
where: { id: guildId },
update: {},
create: { id: guildId }
});
return prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toRules(settings: {
commandAllowedChannelIds: string[];
commandDeniedChannelIds: string[];
}): CommandGlobalRules {
return {
allowedChannelIds: settings.commandAllowedChannelIds,
deniedChannelIds: settings.commandDeniedChannelIds
};
}
export async function getCommandGlobalRules(guildId: string): Promise<CommandGlobalRules> {
const settings = await ensureGuildSettings(guildId);
return toRules(settings);
}
export async function updateCommandGlobalRules(
guildId: string,
patch: CommandGlobalRulesPatch
): Promise<CommandGlobalRules> {
await ensureGuildSettings(guildId);
const updated = await prisma.guildSettings.update({
where: { guildId },
data: {
...(patch.allowedChannelIds !== undefined
? { commandAllowedChannelIds: patch.allowedChannelIds }
: {}),
...(patch.deniedChannelIds !== undefined
? { commandDeniedChannelIds: patch.deniedChannelIds }
: {})
}
});
return toRules(updated);
}

View File

@@ -18,7 +18,9 @@
"optional": "Optional",
"back": "Zurück",
"comingSoon": "Demnächst verfügbar",
"close": "Schließen"
"close": "Schließen",
"searchSelect": "Suchen und auswählen…",
"noResults": "Keine Treffer"
},
"nav": {
"dashboard": "Dashboard",
@@ -584,12 +586,20 @@
"searchPlaceholder": "Commands durchsuchen…",
"empty": "Keine Commands entsprechen der Suche.",
"cooldownSeconds": "Cooldown (Sekunden)",
"allowedRoleIds": "Erlaubte Rollen-IDs",
"deniedRoleIds": "Verbotene Rollen-IDs",
"allowedChannelIds": "Erlaubte Kanal-IDs",
"deniedChannelIds": "Verbotene Kanal-IDs",
"allowedRoleIds": "Erlaubte Rollen",
"deniedRoleIds": "Verbotene Rollen",
"allowedChannelIds": "Erlaubte Kanäle",
"deniedChannelIds": "Verbotene Kanäle",
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
"reset": "Auf Standard zurücksetzen"
"reset": "Auf Standard zurücksetzen",
"globalTitle": "Globale Command-Regeln",
"globalDescription": "Kanal-Whitelist und -Blacklist für alle Slash-Commands auf diesem Server. Leere Whitelist = alle Kanäle (außer Blacklist). Pro-Command-Regeln gelten zusätzlich.",
"globalAllowedChannels": "Whitelist-Kanäle",
"globalDeniedChannels": "Blacklist-Kanäle",
"globalAllowedHint": "Wenn gesetzt, sind Commands nur in diesen Kanälen nutzbar.",
"globalDeniedHint": "In diesen Kanälen sind Commands immer gesperrt.",
"channelSearchPlaceholder": "Kanal suchen…",
"roleSearchPlaceholder": "Rolle suchen…"
}
},
"userMenu": {

View File

@@ -18,7 +18,9 @@
"back": "Back",
"comingSoon": "Coming soon",
"close": "Close",
"optional": "Optional"
"optional": "Optional",
"searchSelect": "Search and select…",
"noResults": "No results"
},
"nav": {
"dashboard": "Dashboard",
@@ -584,12 +586,20 @@
"searchPlaceholder": "Search commands…",
"empty": "No commands match your search.",
"cooldownSeconds": "Cooldown (seconds)",
"allowedRoleIds": "Allowed role IDs",
"deniedRoleIds": "Denied role IDs",
"allowedChannelIds": "Allowed channel IDs",
"deniedChannelIds": "Denied channel IDs",
"allowedRoleIds": "Allowed roles",
"deniedRoleIds": "Denied roles",
"allowedChannelIds": "Allowed channels",
"deniedChannelIds": "Denied channels",
"idsPlaceholder": "One or more IDs, comma-separated",
"reset": "Reset to default"
"reset": "Reset to default",
"globalTitle": "Global command rules",
"globalDescription": "Channel whitelist and blacklist for all slash commands on this server. Empty whitelist = all channels (except blacklist). Per-command rules apply on top.",
"globalAllowedChannels": "Whitelist channels",
"globalDeniedChannels": "Blacklist channels",
"globalAllowedHint": "When set, commands can only be used in these channels.",
"globalDeniedHint": "Commands are always blocked in these channels.",
"channelSearchPlaceholder": "Search channels…",
"roleSearchPlaceholder": "Search roles…"
}
},
"userMenu": {