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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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',
|
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',
|
||||||
|
|||||||
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",
|
"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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
Reference in New Issue
Block a user