Add global command channel rules and enhance command handling

- Introduced global command channel whitelist and blacklist in the GuildSettings model for improved command access control.
- Implemented command gate logic in the command routing to handle channel and role restrictions, providing user feedback for denied commands.
- Enhanced the CommandsManager component to support global rules and channel/role selection, improving the user interface for managing command permissions.
- Updated localization files to include new keys for global command rules and related messages, ensuring clarity in user interactions.
This commit is contained in:
TheOnlyMace
2026-07-22 20:26:40 +02:00
parent 0fb8195236
commit d31660f813
24 changed files with 963 additions and 185 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -64,6 +64,10 @@ model GuildSettings {
moderationEnabled Boolean @default(true)
/// 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)
}

View File

@@ -0,0 +1,38 @@
export type CommandGateReason =
| 'disabled'
| 'channel_denied'
| 'channel_not_allowed'
| 'role_denied'
| 'role_not_allowed'
| 'cooldown';
export function isChannelAllowed(
channelId: string | null,
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (!channelId) {
return null;
}
if (denied.includes(channelId)) {
return 'channel_denied';
}
if (allowed.length > 0 && !allowed.includes(channelId)) {
return 'channel_not_allowed';
}
return null;
}
export function isRoleAllowed(
roleIds: string[],
allowed: string[],
denied: string[]
): CommandGateReason | null {
if (denied.some((id) => roleIds.includes(id))) {
return 'role_denied';
}
if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) {
return 'role_not_allowed';
}
return null;
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js';
describe('command-gates channel rules', () => {
it('allows any channel when allow and deny are empty', () => {
expect(isChannelAllowed('1', [], [])).toBeNull();
});
it('blocks denied channels even when allow is empty', () => {
expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied');
});
it('requires membership when allow list is set', () => {
expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed');
expect(isChannelAllowed('2', ['2'], [])).toBeNull();
});
it('prefers deny over allow', () => {
expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied');
});
});
describe('command-gates role rules', () => {
it('blocks when any denied role is present', () => {
expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied');
});
it('requires an allowed role when allow list is set', () => {
expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed');
expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull();
});
});

View File

@@ -0,0 +1,92 @@
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
import type { BotContext } from './types.js';
import { redis } from './redis.js';
import {
isChannelAllowed,
isRoleAllowed,
type CommandGateReason
} from './command-gate-rules.js';
export type { CommandGateReason };
export type CommandGateResult =
| { ok: true }
| { ok: false; reason: CommandGateReason; retryAfterSeconds?: number };
function memberRoleIds(member: GuildMember | null): string[] {
if (!member) {
return [];
}
return [...member.roles.cache.keys()];
}
/**
* Applies guild-wide command channel rules and per-command overrides.
* Empty allow-lists mean "no restriction"; deny-lists always block.
* Per-command allow/deny is evaluated after global channel rules.
*/
export async function evaluateCommandGate(
interaction: ChatInputCommandInteraction,
context: BotContext
): Promise<CommandGateResult> {
if (!interaction.guildId) {
return { ok: true };
}
const [settings, override] = await Promise.all([
context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }),
context.prisma.commandOverride.findUnique({
where: {
guildId_commandName: {
guildId: interaction.guildId,
commandName: interaction.commandName
}
}
})
]);
if (override && !override.enabled) {
return { ok: false, reason: 'disabled' };
}
const channelId = interaction.channelId;
const globalChannelBlock = isChannelAllowed(
channelId,
settings?.commandAllowedChannelIds ?? [],
settings?.commandDeniedChannelIds ?? []
);
if (globalChannelBlock) {
return { ok: false, reason: globalChannelBlock };
}
if (override) {
const perCommandChannelBlock = isChannelAllowed(
channelId,
override.allowedChannelIds,
override.deniedChannelIds
);
if (perCommandChannelBlock) {
return { ok: false, reason: perCommandChannelBlock };
}
const member =
interaction.member && 'roles' in interaction.member
? (interaction.member as GuildMember)
: null;
const roleIds = memberRoleIds(member);
const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds);
if (roleBlock) {
return { ok: false, reason: roleBlock };
}
if (override.cooldownSeconds && override.cooldownSeconds > 0) {
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
const existing = await redis.ttl(key);
if (existing > 0) {
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
}
await redis.set(key, '1', 'EX', override.cooldownSeconds);
}
}
return { ok: true };
}

View File

@@ -4,10 +4,11 @@ import {
type ChatInputCommandInteraction,
type 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);
}