deploy #1

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

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) moderationEnabled Boolean @default(true)
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy). /// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
snipeEnabled Boolean @default(false) 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) 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 ChatInputCommandInteraction,
type MessageContextMenuCommandInteraction type MessageContextMenuCommandInteraction
} from 'discord.js'; } from 'discord.js';
import { t } from '@nexumi/shared'; import { t, tf, type Locale } from '@nexumi/shared';
import { env } from './env.js'; import { env } from './env.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { getGuildLocale } from './i18n.js'; import { getGuildLocale } from './i18n.js';
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
import { isMaintenanceMode } from './presence.js'; import { isMaintenanceMode } from './presence.js';
import { moderationCommands } from './modules/moderation/commands.js'; import { moderationCommands } from './modules/moderation/commands.js';
import { automodCommands } from './modules/automod/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) { export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
const maintenance = await isMaintenanceMode(context); 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 }); await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
return; 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); await command.execute(interaction, context);
} }

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.20", "@radix-ui/react-dialog": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.21", "@radix-ui/react-dropdown-menu": "^2.1.21",
"@radix-ui/react-label": "^2.1.12", "@radix-ui/react-label": "^2.1.12",
"@radix-ui/react-popover": "^1.1.20",
"@radix-ui/react-select": "^2.3.4", "@radix-ui/react-select": "^2.3.4",
"@radix-ui/react-separator": "^1.1.12", "@radix-ui/react-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0", "@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 { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager'; import { CommandsManager } from '@/components/modules/commands-manager';
import { listGuildChannelsForDashboard, listGuildRolesForDashboard } from '@/lib/discord-guild-resources';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
interface CommandsPageProps { interface CommandsPageProps {
@@ -9,7 +11,13 @@ interface CommandsPageProps {
export default async function CommandsPage({ params }: CommandsPageProps) { export default async function CommandsPage({ params }: CommandsPageProps) {
const { guildId } = await params; 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 ( return (
<div className="space-y-6"> <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> <p className="text-sm text-muted-foreground">{t(locale, 'modulePages.commands.description')}</p>
</div> </div>
<Suspense fallback={null}> <Suspense fallback={null}>
<CommandsManager guildId={guildId} initialCommands={commands} /> <CommandsManager
guildId={guildId}
initialCommands={commands}
initialGlobalRules={globalRules}
channels={channels}
roles={roles}
/>
</Suspense> </Suspense>
</div> </div>
); );

View File

@@ -1,6 +1,11 @@
'use client'; 'use client';
import type { CommandOverrideDashboard } from '@nexumi/shared'; import type {
CommandGlobalRules,
CommandOverrideDashboard,
DiscordChannelOption,
DiscordRoleOption
} from '@nexumi/shared';
import { RotateCcw } from 'lucide-react'; import { RotateCcw } from 'lucide-react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -8,20 +13,10 @@ import { toast } from 'sonner';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; 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 { interface LocalOverride extends CommandOverrideDashboard {
saving?: boolean; saving?: boolean;
} }
@@ -29,13 +24,34 @@ interface LocalOverride extends CommandOverrideDashboard {
interface CommandsManagerProps { interface CommandsManagerProps {
guildId: string; guildId: string;
initialCommands: CommandOverrideDashboard[]; 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 t = useTranslations();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands); const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
const [search, setSearch] = useState(''); 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(() => { useEffect(() => {
const q = searchParams.get('q'); 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) { async function handleSave(entry: LocalOverride) {
patchLocal(entry.commandName, { saving: true }); patchLocal(entry.commandName, { saving: true });
try { try {
@@ -110,122 +157,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
} }
return ( return (
<Card> <div className="space-y-6">
<CardHeader> <Card>
<CardTitle>{t('modulePages.commands.title')}</CardTitle> <CardHeader>
<CardDescription>{t('modulePages.commands.description')}</CardDescription> <CardTitle>{t('modulePages.commands.globalTitle')}</CardTitle>
</CardHeader> <CardDescription>{t('modulePages.commands.globalDescription')}</CardDescription>
<CardContent className="space-y-4"> </CardHeader>
<Input <CardContent className="space-y-4">
value={search} <div className="space-y-2">
onChange={(event) => setSearch(event.target.value)} <div id="field-commands-global-allow" className="scroll-mt-24 space-y-2">
placeholder={t('modulePages.commands.searchPlaceholder')} <p className="text-sm font-medium">{t('modulePages.commands.globalAllowedChannels')}</p>
className="max-w-sm" <p className="text-xs text-muted-foreground">{t('modulePages.commands.globalAllowedHint')}</p>
/> <DiscordResourceMultiSelect
<div className="space-y-3"> options={channelOptions}
{filtered.length === 0 && ( value={globalRules.allowedChannelIds}
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p> onChange={(allowedChannelIds) => patchGlobal({ allowedChannelIds })}
)} placeholder={t('modulePages.commands.channelSearchPlaceholder')}
{filtered.map((entry) => ( disabled={globalSaving}
<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> </div>
))} </div>
</div> <div className="space-y-2">
</CardContent> <div id="field-commands-global-deny" className="scroll-mt-24 space-y-2">
</Card> <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', hash: 'field-access-modules',
keywords: ['access'] 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 // Moderation
{ {
id: 'setting-mod-enabled', 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", "optional": "Optional",
"back": "Zurück", "back": "Zurück",
"comingSoon": "Demnächst verfügbar", "comingSoon": "Demnächst verfügbar",
"close": "Schließen" "close": "Schließen",
"searchSelect": "Suchen und auswählen…",
"noResults": "Keine Treffer"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -584,12 +586,20 @@
"searchPlaceholder": "Commands durchsuchen…", "searchPlaceholder": "Commands durchsuchen…",
"empty": "Keine Commands entsprechen der Suche.", "empty": "Keine Commands entsprechen der Suche.",
"cooldownSeconds": "Cooldown (Sekunden)", "cooldownSeconds": "Cooldown (Sekunden)",
"allowedRoleIds": "Erlaubte Rollen-IDs", "allowedRoleIds": "Erlaubte Rollen",
"deniedRoleIds": "Verbotene Rollen-IDs", "deniedRoleIds": "Verbotene Rollen",
"allowedChannelIds": "Erlaubte Kanal-IDs", "allowedChannelIds": "Erlaubte Kanäle",
"deniedChannelIds": "Verbotene Kanal-IDs", "deniedChannelIds": "Verbotene Kanäle",
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", "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": { "userMenu": {

View File

@@ -18,7 +18,9 @@
"back": "Back", "back": "Back",
"comingSoon": "Coming soon", "comingSoon": "Coming soon",
"close": "Close", "close": "Close",
"optional": "Optional" "optional": "Optional",
"searchSelect": "Search and select…",
"noResults": "No results"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -584,12 +586,20 @@
"searchPlaceholder": "Search commands…", "searchPlaceholder": "Search commands…",
"empty": "No commands match your search.", "empty": "No commands match your search.",
"cooldownSeconds": "Cooldown (seconds)", "cooldownSeconds": "Cooldown (seconds)",
"allowedRoleIds": "Allowed role IDs", "allowedRoleIds": "Allowed roles",
"deniedRoleIds": "Denied role IDs", "deniedRoleIds": "Denied roles",
"allowedChannelIds": "Allowed channel IDs", "allowedChannelIds": "Allowed channels",
"deniedChannelIds": "Denied channel IDs", "deniedChannelIds": "Denied channels",
"idsPlaceholder": "One or more IDs, comma-separated", "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": { "userMenu": {

View File

@@ -88,6 +88,22 @@
- Musik- und KI-Modul (nur auf Zuruf) - Musik- und KI-Modul (nur auf Zuruf)
- Premium-Zahlungsanbindung - Premium-Zahlungsanbindung
## Post-Phase Command-Global-Rules + Channel-Picker (Status: implementiert)
### Abgeschlossen (Code)
- Globale Command Channel-Whitelist/-Blacklist in `GuildSettings` + Commands-UI
- Searchable Multi-Select für Kanäle/Rollen (statt Raw-IDs) auf der Commands-Seite
- APIs: `/commands/globals`, `/channels`, `/roles`
- Bot erzwingt globale + per-Command Channel/Role/Cooldown-Regeln in `routeCommand`
- Migration `20260722210000_command_global_channels`
### Manuell testen
- [ ] Commands: globale Whitelist/Blacklist speichern, in Discord prüfen
- [ ] Per-Command Channel/Rollen per Dropdown setzen
- [ ] Deaktivierter Command / Cooldown antwortet ephemeral
## 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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 431 KiB

View File

@@ -5,9 +5,9 @@
<stop offset="0.55" stop-color="#12141F"/> <stop offset="0.55" stop-color="#12141F"/>
<stop offset="1" stop-color="#0A0A0D"/> <stop offset="1" stop-color="#0A0A0D"/>
</linearGradient> </linearGradient>
<radialGradient id="glow" cx="28%" cy="48%" r="45%"> <radialGradient id="glow" cx="48%" cy="42%" r="42%">
<stop offset="0" stop-color="#6366F1" stop-opacity="0.35"/> <stop offset="0" stop-color="#6366F1" stop-opacity="0.32"/>
<stop offset="0.55" stop-color="#6366F1" stop-opacity="0.12"/> <stop offset="0.55" stop-color="#6366F1" stop-opacity="0.10"/>
<stop offset="1" stop-color="#6366F1" stop-opacity="0"/> <stop offset="1" stop-color="#6366F1" stop-opacity="0"/>
</radialGradient> </radialGradient>
<linearGradient id="mark" x1="0" y1="1" x2="1" y2="0"> <linearGradient id="mark" x1="0" y1="1" x2="1" y2="0">
@@ -19,40 +19,42 @@
<rect width="1200" height="480" fill="url(#bg)"/> <rect width="1200" height="480" fill="url(#bg)"/>
<rect width="1200" height="480" fill="url(#glow)"/> <rect width="1200" height="480" fill="url(#glow)"/>
<!-- faint network dots --> <!-- faint network dots (keep away from avatar corner) -->
<g fill="#6366F1" fill-opacity="0.18"> <g fill="#6366F1" fill-opacity="0.18">
<circle cx="620" cy="90" r="3"/> <circle cx="180" cy="70" r="2.5"/>
<circle cx="760" cy="140" r="2.5"/> <circle cx="980" cy="80" r="3"/>
<circle cx="900" cy="100" r="3"/> <circle cx="1080" cy="160" r="2.5"/>
<circle cx="980" cy="200" r="2"/> <circle cx="1020" cy="280" r="2"/>
<circle cx="840" cy="280" r="2.5"/> <circle cx="160" cy="160" r="2"/>
<circle cx="1040" cy="320" r="3"/>
<circle cx="700" cy="360" r="2"/>
<circle cx="1120" cy="160" r="2.5"/>
</g> </g>
<g stroke="#6366F1" stroke-opacity="0.12" stroke-width="1.5" fill="none"> <g stroke="#6366F1" stroke-opacity="0.10" stroke-width="1.5" fill="none">
<path d="M620 90 L760 140 L900 100 L980 200"/> <path d="M180 70 L160 160"/>
<path d="M760 140 L840 280 L700 360"/> <path d="M980 80 L1080 160 L1020 280"/>
<path d="M900 100 L1120 160 L1040 320"/>
</g> </g>
<!-- N mark — shifted up so Discord avatar does not cover it --> <!--
<g transform="translate(90,-70) scale(0.92)"> Centered lockup: N + wordmark.
<g fill="none" stroke="url(#mark)" stroke-linecap="round" stroke-linejoin="round"> Shifted right so Discord's bottom-left avatar does not cover the N.
<path d="M170 362 V150 L342 362 V150" stroke-width="78" stroke-opacity="0.10"/> Local N bounds ~170..342 x 150..362; scale 0.78 → ~134×165.
<path d="M170 362 V150 L342 362 V150" stroke-width="52" stroke-opacity="0.16"/> -->
<path d="M170 362 V150 L342 362 V150" stroke-width="30"/> <g transform="translate(310,-20)">
<g transform="scale(0.78)">
<g fill="none" stroke="url(#mark)" stroke-linecap="round" stroke-linejoin="round">
<path d="M170 362 V150 L342 362 V150" stroke-width="78" stroke-opacity="0.10"/>
<path d="M170 362 V150 L342 362 V150" stroke-width="52" stroke-opacity="0.16"/>
<path d="M170 362 V150 L342 362 V150" stroke-width="30"/>
</g>
<g fill="url(#mark)">
<circle cx="170" cy="150" r="34"/>
<circle cx="170" cy="362" r="34"/>
<circle cx="342" cy="362" r="34"/>
<circle cx="342" cy="150" r="34"/>
</g>
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
</g> </g>
<g fill="url(#mark)">
<circle cx="170" cy="150" r="34"/>
<circle cx="170" cy="362" r="34"/>
<circle cx="342" cy="362" r="34"/>
<circle cx="342" cy="150" r="34"/>
</g>
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
</g>
<!-- wordmark --> <!-- wordmark to the right of N, vertically centered to mark -->
<text x="560" y="200" fill="#E6E6EA" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="92" font-weight="700" letter-spacing="-2">Nexumi</text> <text x="320" y="195" fill="#E6E6EA" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="88" font-weight="700" letter-spacing="-2">Nexumi</text>
<text x="564" y="250" fill="#9A9AA5" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="28" font-weight="500">nexumi.de</text> <text x="324" y="245" fill="#9A9AA5" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="26" font-weight="500">nexumi.de</text>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -601,10 +601,10 @@ export type GuildBackupCreateDashboard = z.infer<typeof GuildBackupCreateDashboa
export const CommandOverrideDashboardSchema = z.object({ export const CommandOverrideDashboardSchema = z.object({
commandName: z.string().min(1).max(100), commandName: z.string().min(1).max(100),
enabled: z.boolean(), enabled: z.boolean(),
allowedRoleIds: z.array(z.string()), allowedRoleIds: z.array(SnowflakeSchema),
deniedRoleIds: z.array(z.string()), deniedRoleIds: z.array(SnowflakeSchema),
allowedChannelIds: z.array(z.string()), allowedChannelIds: z.array(SnowflakeSchema),
deniedChannelIds: z.array(z.string()), deniedChannelIds: z.array(SnowflakeSchema),
cooldownSeconds: z.number().int().nonnegative().nullable() cooldownSeconds: z.number().int().nonnegative().nullable()
}); });
export type CommandOverrideDashboard = z.infer<typeof CommandOverrideDashboardSchema>; export type CommandOverrideDashboard = z.infer<typeof CommandOverrideDashboardSchema>;
@@ -612,14 +612,42 @@ export type CommandOverrideDashboard = z.infer<typeof CommandOverrideDashboardSc
export const CommandOverrideDashboardUpsertSchema = z.object({ export const CommandOverrideDashboardUpsertSchema = z.object({
commandName: z.string().min(1).max(100), commandName: z.string().min(1).max(100),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
allowedRoleIds: z.array(z.string()).default([]), allowedRoleIds: z.array(SnowflakeSchema).default([]),
deniedRoleIds: z.array(z.string()).default([]), deniedRoleIds: z.array(SnowflakeSchema).default([]),
allowedChannelIds: z.array(z.string()).default([]), allowedChannelIds: z.array(SnowflakeSchema).default([]),
deniedChannelIds: z.array(z.string()).default([]), deniedChannelIds: z.array(SnowflakeSchema).default([]),
cooldownSeconds: z.number().int().nonnegative().nullable().optional() cooldownSeconds: z.number().int().nonnegative().nullable().optional()
}); });
export type CommandOverrideDashboardUpsert = z.infer<typeof CommandOverrideDashboardUpsertSchema>; export type CommandOverrideDashboardUpsert = z.infer<typeof CommandOverrideDashboardUpsertSchema>;
export const CommandGlobalRulesSchema = z.object({
allowedChannelIds: z.array(SnowflakeSchema),
deniedChannelIds: z.array(SnowflakeSchema)
});
export type CommandGlobalRules = z.infer<typeof CommandGlobalRulesSchema>;
export const CommandGlobalRulesPatchSchema = CommandGlobalRulesSchema.partial().refine(
(value) => Object.keys(value).length > 0,
{ message: 'At least one field is required' }
);
export type CommandGlobalRulesPatch = z.infer<typeof CommandGlobalRulesPatchSchema>;
export const DiscordChannelOptionSchema = z.object({
id: SnowflakeSchema,
name: z.string(),
type: z.number().int(),
parentId: SnowflakeSchema.nullable().optional()
});
export type DiscordChannelOption = z.infer<typeof DiscordChannelOptionSchema>;
export const DiscordRoleOptionSchema = z.object({
id: SnowflakeSchema,
name: z.string(),
color: z.number().int(),
position: z.number().int()
});
export type DiscordRoleOption = z.infer<typeof DiscordRoleOptionSchema>;
/** /**
* Known slash command names, aggregated from every module's * Known slash command names, aggregated from every module's
* `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the * `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the

View File

@@ -54,6 +54,10 @@ const de: Dictionary = {
'generic.error': 'Es ist ein Fehler aufgetreten.', 'generic.error': 'Es ist ein Fehler aufgetreten.',
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.', 'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
'generic.unknownCommand': 'Unbekannter Befehl.', 'generic.unknownCommand': 'Unbekannter Befehl.',
'generic.commandDisabled': 'Dieser Befehl ist auf diesem Server deaktiviert.',
'generic.commandChannelBlocked': 'Dieser Befehl ist in diesem Kanal nicht erlaubt.',
'generic.commandRoleBlocked': 'Du darfst diesen Befehl mit deinen Rollen nicht nutzen.',
'generic.commandCooldown': 'Bitte warte noch {seconds} Sekunden, bevor du diesen Befehl erneut nutzt.',
'core.help.title': 'Nexumi Hilfe', 'core.help.title': 'Nexumi Hilfe',
'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.', 'core.help.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}', 'core.help.moduleLine': '**{module}**: {commands}',
@@ -587,6 +591,10 @@ const en: Dictionary = {
'generic.error': 'An error occurred.', 'generic.error': 'An error occurred.',
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.', 'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
'generic.unknownCommand': 'Unknown command.', 'generic.unknownCommand': 'Unknown command.',
'generic.commandDisabled': 'This command is disabled on this server.',
'generic.commandChannelBlocked': 'This command is not allowed in this channel.',
'generic.commandRoleBlocked': 'You are not allowed to use this command with your roles.',
'generic.commandCooldown': 'Please wait {seconds} more seconds before using this command again.',
'core.help.title': 'Nexumi Help', 'core.help.title': 'Nexumi Help',
'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.', 'core.help.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
'core.help.moduleLine': '**{module}**: {commands}', 'core.help.moduleLine': '**{module}**: {commands}',