deploy #1
@@ -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[];
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
38
apps/bot/src/command-gate-rules.ts
Normal file
38
apps/bot/src/command-gate-rules.ts
Normal 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;
|
||||
}
|
||||
32
apps/bot/src/command-gates.test.ts
Normal file
32
apps/bot/src/command-gates.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
92
apps/bot/src/command-gates.ts
Normal file
92
apps/bot/src/command-gates.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
18
apps/webui/src/app/api/guilds/[guildId]/channels/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/channels/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
18
apps/webui/src/app/api/guilds/[guildId]/roles/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/roles/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,6 +157,51 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
@@ -156,48 +248,48 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<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')}
|
||||
<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">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<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')}
|
||||
<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">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<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)
|
||||
})
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={entry.allowedChannelIds}
|
||||
onChange={(allowedChannelIds) =>
|
||||
patchLocal(entry.commandName, { allowedChannelIds })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<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)
|
||||
})
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={entry.deniedChannelIds}
|
||||
onChange={(deniedChannelIds) =>
|
||||
patchLocal(entry.commandName, { deniedChannelIds })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +299,7 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => handleReset(entry)}
|
||||
onClick={() => void handleReset(entry)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('modulePages.commands.reset')}
|
||||
@@ -217,7 +309,7 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => handleSave(entry)}
|
||||
onClick={() => void handleSave(entry)}
|
||||
>
|
||||
{entry.saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
@@ -227,5 +319,6 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
apps/webui/src/components/ui/discord-resource-multi-select.tsx
Normal file
132
apps/webui/src/components/ui/discord-resource-multi-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/webui/src/components/ui/popover.tsx
Normal file
29
apps/webui/src/components/ui/popover.tsx
Normal 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 };
|
||||
@@ -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',
|
||||
|
||||
88
apps/webui/src/lib/discord-guild-resources.ts
Normal file
88
apps/webui/src/lib/discord-guild-resources.ts
Normal 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;
|
||||
}
|
||||
50
apps/webui/src/lib/module-configs/command-global-rules.ts
Normal file
50
apps/webui/src/lib/module-configs/command-global-rules.ts
Normal 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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -88,6 +88,22 @@
|
||||
- Musik- und KI-Modul (nur auf Zuruf)
|
||||
- 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
|
||||
|
||||
- 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 |
@@ -5,9 +5,9 @@
|
||||
<stop offset="0.55" stop-color="#12141F"/>
|
||||
<stop offset="1" stop-color="#0A0A0D"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="28%" cy="48%" r="45%">
|
||||
<stop offset="0" stop-color="#6366F1" stop-opacity="0.35"/>
|
||||
<stop offset="0.55" stop-color="#6366F1" stop-opacity="0.12"/>
|
||||
<radialGradient id="glow" cx="48%" cy="42%" r="42%">
|
||||
<stop offset="0" stop-color="#6366F1" stop-opacity="0.32"/>
|
||||
<stop offset="0.55" stop-color="#6366F1" stop-opacity="0.10"/>
|
||||
<stop offset="1" stop-color="#6366F1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="mark" x1="0" y1="1" x2="1" y2="0">
|
||||
@@ -19,25 +19,26 @@
|
||||
<rect width="1200" height="480" fill="url(#bg)"/>
|
||||
<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">
|
||||
<circle cx="620" cy="90" r="3"/>
|
||||
<circle cx="760" cy="140" r="2.5"/>
|
||||
<circle cx="900" cy="100" r="3"/>
|
||||
<circle cx="980" cy="200" r="2"/>
|
||||
<circle cx="840" cy="280" r="2.5"/>
|
||||
<circle cx="1040" cy="320" r="3"/>
|
||||
<circle cx="700" cy="360" r="2"/>
|
||||
<circle cx="1120" cy="160" r="2.5"/>
|
||||
<circle cx="180" cy="70" r="2.5"/>
|
||||
<circle cx="980" cy="80" r="3"/>
|
||||
<circle cx="1080" cy="160" r="2.5"/>
|
||||
<circle cx="1020" cy="280" r="2"/>
|
||||
<circle cx="160" cy="160" r="2"/>
|
||||
</g>
|
||||
<g stroke="#6366F1" stroke-opacity="0.12" stroke-width="1.5" fill="none">
|
||||
<path d="M620 90 L760 140 L900 100 L980 200"/>
|
||||
<path d="M760 140 L840 280 L700 360"/>
|
||||
<path d="M900 100 L1120 160 L1040 320"/>
|
||||
<g stroke="#6366F1" stroke-opacity="0.10" stroke-width="1.5" fill="none">
|
||||
<path d="M180 70 L160 160"/>
|
||||
<path d="M980 80 L1080 160 L1020 280"/>
|
||||
</g>
|
||||
|
||||
<!-- N mark — shifted up so Discord avatar does not cover it -->
|
||||
<g transform="translate(90,-70) scale(0.92)">
|
||||
<!--
|
||||
Centered lockup: N + wordmark.
|
||||
Shifted right so Discord's bottom-left avatar does not cover the N.
|
||||
Local N bounds ~170..342 x 150..362; scale 0.78 → ~134×165.
|
||||
-->
|
||||
<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"/>
|
||||
@@ -52,7 +53,8 @@
|
||||
<circle cx="342" cy="150" r="14" fill="#C7D2FE"/>
|
||||
</g>
|
||||
|
||||
<!-- wordmark -->
|
||||
<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="564" y="250" fill="#9A9AA5" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="28" font-weight="500">nexumi.de</text>
|
||||
<!-- wordmark to the right of N, vertically centered to mark -->
|
||||
<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="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>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -601,10 +601,10 @@ export type GuildBackupCreateDashboard = z.infer<typeof GuildBackupCreateDashboa
|
||||
export const CommandOverrideDashboardSchema = z.object({
|
||||
commandName: z.string().min(1).max(100),
|
||||
enabled: z.boolean(),
|
||||
allowedRoleIds: z.array(z.string()),
|
||||
deniedRoleIds: z.array(z.string()),
|
||||
allowedChannelIds: z.array(z.string()),
|
||||
deniedChannelIds: z.array(z.string()),
|
||||
allowedRoleIds: z.array(SnowflakeSchema),
|
||||
deniedRoleIds: z.array(SnowflakeSchema),
|
||||
allowedChannelIds: z.array(SnowflakeSchema),
|
||||
deniedChannelIds: z.array(SnowflakeSchema),
|
||||
cooldownSeconds: z.number().int().nonnegative().nullable()
|
||||
});
|
||||
export type CommandOverrideDashboard = z.infer<typeof CommandOverrideDashboardSchema>;
|
||||
@@ -612,14 +612,42 @@ export type CommandOverrideDashboard = z.infer<typeof CommandOverrideDashboardSc
|
||||
export const CommandOverrideDashboardUpsertSchema = z.object({
|
||||
commandName: z.string().min(1).max(100),
|
||||
enabled: z.boolean().default(true),
|
||||
allowedRoleIds: z.array(z.string()).default([]),
|
||||
deniedRoleIds: z.array(z.string()).default([]),
|
||||
allowedChannelIds: z.array(z.string()).default([]),
|
||||
deniedChannelIds: z.array(z.string()).default([]),
|
||||
allowedRoleIds: z.array(SnowflakeSchema).default([]),
|
||||
deniedRoleIds: z.array(SnowflakeSchema).default([]),
|
||||
allowedChannelIds: z.array(SnowflakeSchema).default([]),
|
||||
deniedChannelIds: z.array(SnowflakeSchema).default([]),
|
||||
cooldownSeconds: z.number().int().nonnegative().nullable().optional()
|
||||
});
|
||||
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
|
||||
* `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the
|
||||
|
||||
@@ -54,6 +54,10 @@ const de: Dictionary = {
|
||||
'generic.error': 'Es ist ein Fehler aufgetreten.',
|
||||
'generic.maintenance': 'Nexumi ist derzeit im Wartungsmodus. Bitte versuche es später erneut.',
|
||||
'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.subtitle': 'Commands nach Modul. Optional: `/help module:<name>`.',
|
||||
'core.help.moduleLine': '**{module}**: {commands}',
|
||||
@@ -587,6 +591,10 @@ const en: Dictionary = {
|
||||
'generic.error': 'An error occurred.',
|
||||
'generic.maintenance': 'Nexumi is currently in maintenance mode. Please try again later.',
|
||||
'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.subtitle': 'Commands by module. Optional: `/help module:<name>`.',
|
||||
'core.help.moduleLine': '**{module}**: {commands}',
|
||||
|
||||
Reference in New Issue
Block a user