diff --git a/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql b/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql new file mode 100644 index 0000000..caa3070 --- /dev/null +++ b/apps/bot/prisma/migrations/20260722210000_command_global_channels/migration.sql @@ -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[]; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index e570a01..8e34207 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -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) } diff --git a/apps/bot/src/command-gate-rules.ts b/apps/bot/src/command-gate-rules.ts new file mode 100644 index 0000000..f0377fa --- /dev/null +++ b/apps/bot/src/command-gate-rules.ts @@ -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; +} diff --git a/apps/bot/src/command-gates.test.ts b/apps/bot/src/command-gates.test.ts new file mode 100644 index 0000000..037866c --- /dev/null +++ b/apps/bot/src/command-gates.test.ts @@ -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(); + }); +}); diff --git a/apps/bot/src/command-gates.ts b/apps/bot/src/command-gates.ts new file mode 100644 index 0000000..4d9a010 --- /dev/null +++ b/apps/bot/src/command-gates.ts @@ -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 { + 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 }; +} diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index e221e42..1609f60 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -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); } diff --git a/apps/webui/package.json b/apps/webui/package.json index 278a552..910df60 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -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", diff --git a/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts b/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts new file mode 100644 index 0000000..0e64f6a --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/channels/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts b/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts new file mode 100644 index 0000000..6ae58f0 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/commands/globals/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts b/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts new file mode 100644 index 0000000..b48d690 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/roles/route.ts @@ -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); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx index e6bcfaa..01ac972 100644 --- a/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/commands/page.tsx @@ -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 (
@@ -18,7 +26,13 @@ export default async function CommandsPage({ params }: CommandsPageProps) {

{t(locale, 'modulePages.commands.description')}

- + ); diff --git a/apps/webui/src/components/modules/commands-manager.tsx b/apps/webui/src/components/modules/commands-manager.tsx index 97cbb37..b9f4866 100644 --- a/apps/webui/src/components/modules/commands-manager.tsx +++ b/apps/webui/src/components/modules/commands-manager.tsx @@ -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(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) { + setGlobalRules((prev) => ({ ...prev, ...patch })); + setGlobalDirty(true); + } + + async function handleSaveGlobal() { + setGlobalSaving(true); + try { + const response = await fetch(`/api/guilds/${guildId}/commands/globals`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(globalRules) + }); + const body = (await response.json().catch(() => null)) as (CommandGlobalRules & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setGlobalRules({ + allowedChannelIds: body.allowedChannelIds, + deniedChannelIds: body.deniedChannelIds + }); + setGlobalDirty(false); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setGlobalSaving(false); + } + } + async function handleSave(entry: LocalOverride) { patchLocal(entry.commandName, { saving: true }); try { @@ -110,122 +157,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro } return ( - - - {t('modulePages.commands.title')} - {t('modulePages.commands.description')} - - - setSearch(event.target.value)} - placeholder={t('modulePages.commands.searchPlaceholder')} - className="max-w-sm" - /> -
- {filtered.length === 0 && ( -

{t('modulePages.commands.empty')}

- )} - {filtered.map((entry) => ( -
-
-

/{entry.commandName}

-
- patchLocal(entry.commandName, { enabled })} - /> - - {entry.enabled ? t('common.enabled') : t('common.disabled')} - -
-
-
-
-

{t('modulePages.commands.cooldownSeconds')}

- - patchLocal(entry.commandName, { - cooldownSeconds: event.target.value === '' ? null : Number(event.target.value) - }) - } - placeholder={t('common.optional')} - /> -
-
-

{t('modulePages.commands.allowedRoleIds')}

- - patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) }) - } - placeholder={t('modulePages.commands.idsPlaceholder')} - /> -
-
-

{t('modulePages.commands.deniedRoleIds')}

- - patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) }) - } - placeholder={t('modulePages.commands.idsPlaceholder')} - /> -
-
-

{t('modulePages.commands.allowedChannelIds')}

- - patchLocal(entry.commandName, { - allowedChannelIds: fromCsv(event.target.value) - }) - } - placeholder={t('modulePages.commands.idsPlaceholder')} - /> -
-
-

{t('modulePages.commands.deniedChannelIds')}

- - patchLocal(entry.commandName, { - deniedChannelIds: fromCsv(event.target.value) - }) - } - placeholder={t('modulePages.commands.idsPlaceholder')} - /> -
-
-
- - -
+
+ + + {t('modulePages.commands.globalTitle')} + {t('modulePages.commands.globalDescription')} + + +
+
+

{t('modulePages.commands.globalAllowedChannels')}

+

{t('modulePages.commands.globalAllowedHint')}

+ patchGlobal({ allowedChannelIds })} + placeholder={t('modulePages.commands.channelSearchPlaceholder')} + disabled={globalSaving} + />
- ))} -
-
-
+
+
+
+

{t('modulePages.commands.globalDeniedChannels')}

+

{t('modulePages.commands.globalDeniedHint')}

+ patchGlobal({ deniedChannelIds })} + placeholder={t('modulePages.commands.channelSearchPlaceholder')} + disabled={globalSaving} + /> +
+
+
+ +
+ + + + + + {t('modulePages.commands.title')} + {t('modulePages.commands.description')} + + + setSearch(event.target.value)} + placeholder={t('modulePages.commands.searchPlaceholder')} + className="max-w-sm" + /> +
+ {filtered.length === 0 && ( +

{t('modulePages.commands.empty')}

+ )} + {filtered.map((entry) => ( +
+
+

/{entry.commandName}

+
+ patchLocal(entry.commandName, { enabled })} + /> + + {entry.enabled ? t('common.enabled') : t('common.disabled')} + +
+
+
+
+

{t('modulePages.commands.cooldownSeconds')}

+ + patchLocal(entry.commandName, { + cooldownSeconds: event.target.value === '' ? null : Number(event.target.value) + }) + } + placeholder={t('common.optional')} + /> +
+
+

{t('modulePages.commands.allowedRoleIds')}

+ patchLocal(entry.commandName, { allowedRoleIds })} + placeholder={t('modulePages.commands.roleSearchPlaceholder')} + disabled={entry.saving} + /> +
+
+

{t('modulePages.commands.deniedRoleIds')}

+ patchLocal(entry.commandName, { deniedRoleIds })} + placeholder={t('modulePages.commands.roleSearchPlaceholder')} + disabled={entry.saving} + /> +
+
+

{t('modulePages.commands.allowedChannelIds')}

+ + patchLocal(entry.commandName, { allowedChannelIds }) + } + placeholder={t('modulePages.commands.channelSearchPlaceholder')} + disabled={entry.saving} + /> +
+
+

{t('modulePages.commands.deniedChannelIds')}

+ + patchLocal(entry.commandName, { deniedChannelIds }) + } + placeholder={t('modulePages.commands.channelSearchPlaceholder')} + disabled={entry.saving} + /> +
+
+
+ + +
+
+ ))} +
+
+
+
); } diff --git a/apps/webui/src/components/ui/discord-resource-multi-select.tsx b/apps/webui/src/components/ui/discord-resource-multi-select.tsx new file mode 100644 index 0000000..f980846 --- /dev/null +++ b/apps/webui/src/components/ui/discord-resource-multi-select.tsx @@ -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 ( +
+ + + + + + + + + {emptyLabel ?? t('common.noResults')} + + {options.map((option) => { + const isSelected = value.includes(option.id); + return ( + toggle(option.id)} + > + + + {option.prefix ?? ''} + {option.name} + + + {option.id.slice(-6)} + + + ); + })} + + + + + + + {selected.length > 0 ? ( +
+ {selected.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/webui/src/components/ui/popover.tsx b/apps/webui/src/components/ui/popover.tsx new file mode 100644 index 0000000..4fa01a6 --- /dev/null +++ b/apps/webui/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index cc684a7..f9633bb 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -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', diff --git a/apps/webui/src/lib/discord-guild-resources.ts b/apps/webui/src/lib/discord-guild-resources.ts new file mode 100644 index 0000000..12cff54 --- /dev/null +++ b/apps/webui/src/lib/discord-guild-resources.ts @@ -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(path: string): Promise { + 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 { + const cacheKey = `dashboard:guild:${guildId}:channels`; + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as DiscordChannelOption[]; + } + + const channels = await discordBotFetch(`/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 { + const cacheKey = `dashboard:guild:${guildId}:roles`; + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as DiscordRoleOption[]; + } + + const roles = await discordBotFetch(`/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; +} diff --git a/apps/webui/src/lib/module-configs/command-global-rules.ts b/apps/webui/src/lib/module-configs/command-global-rules.ts new file mode 100644 index 0000000..c067f04 --- /dev/null +++ b/apps/webui/src/lib/module-configs/command-global-rules.ts @@ -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 { + const settings = await ensureGuildSettings(guildId); + return toRules(settings); +} + +export async function updateCommandGlobalRules( + guildId: string, + patch: CommandGlobalRulesPatch +): Promise { + 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); +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index e7a29c9..37e86ce 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -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": { diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 02a983d..a805f52 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -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": { diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 728a0c0..7584cdb 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -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). diff --git a/docs/logo/nexumi-banner.png b/docs/logo/nexumi-banner.png index 73d3e1c..191d7e4 100644 Binary files a/docs/logo/nexumi-banner.png and b/docs/logo/nexumi-banner.png differ diff --git a/docs/logo/nexumi-banner.svg b/docs/logo/nexumi-banner.svg index aade16d..c9d6363 100644 --- a/docs/logo/nexumi-banner.svg +++ b/docs/logo/nexumi-banner.svg @@ -5,9 +5,9 @@ - - - + + + @@ -19,40 +19,42 @@ - + - - - - - - - - + + + + + - - - - + + + - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - Nexumi - nexumi.de + + Nexumi + nexumi.de + diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 29efc1c..2473ad7 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -601,10 +601,10 @@ export type GuildBackupCreateDashboard = z.infer; @@ -612,14 +612,42 @@ export type CommandOverrideDashboard = z.infer; +export const CommandGlobalRulesSchema = z.object({ + allowedChannelIds: z.array(SnowflakeSchema), + deniedChannelIds: z.array(SnowflakeSchema) +}); +export type CommandGlobalRules = z.infer; + +export const CommandGlobalRulesPatchSchema = CommandGlobalRulesSchema.partial().refine( + (value) => Object.keys(value).length > 0, + { message: 'At least one field is required' } +); +export type CommandGlobalRulesPatch = z.infer; + +export const DiscordChannelOptionSchema = z.object({ + id: SnowflakeSchema, + name: z.string(), + type: z.number().int(), + parentId: SnowflakeSchema.nullable().optional() +}); +export type DiscordChannelOption = z.infer; + +export const DiscordRoleOptionSchema = z.object({ + id: SnowflakeSchema, + name: z.string(), + color: z.number().int(), + position: z.number().int() +}); +export type DiscordRoleOption = z.infer; + /** * Known slash command names, aggregated from every module's * `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f415974..a3aa957 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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:`.', '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:`.', 'core.help.moduleLine': '**{module}**: {commands}',