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

@@ -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": {