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:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "commandAllowedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
ALTER TABLE "GuildSettings" ADD COLUMN "commandDeniedChannelIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -64,6 +64,10 @@ model GuildSettings {
|
||||
moderationEnabled Boolean @default(true)
|
||||
/// Snipe/editsnipe storage and commands (SPEC: default off for privacy).
|
||||
snipeEnabled Boolean @default(false)
|
||||
/// Global command channel whitelist (empty = all channels unless denied).
|
||||
commandAllowedChannelIds String[] @default([])
|
||||
/// Global command channel blacklist.
|
||||
commandDeniedChannelIds String[] @default([])
|
||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
|
||||
38
apps/bot/src/command-gate-rules.ts
Normal file
38
apps/bot/src/command-gate-rules.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type CommandGateReason =
|
||||
| 'disabled'
|
||||
| 'channel_denied'
|
||||
| 'channel_not_allowed'
|
||||
| 'role_denied'
|
||||
| 'role_not_allowed'
|
||||
| 'cooldown';
|
||||
|
||||
export function isChannelAllowed(
|
||||
channelId: string | null,
|
||||
allowed: string[],
|
||||
denied: string[]
|
||||
): CommandGateReason | null {
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
if (denied.includes(channelId)) {
|
||||
return 'channel_denied';
|
||||
}
|
||||
if (allowed.length > 0 && !allowed.includes(channelId)) {
|
||||
return 'channel_not_allowed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRoleAllowed(
|
||||
roleIds: string[],
|
||||
allowed: string[],
|
||||
denied: string[]
|
||||
): CommandGateReason | null {
|
||||
if (denied.some((id) => roleIds.includes(id))) {
|
||||
return 'role_denied';
|
||||
}
|
||||
if (allowed.length > 0 && !allowed.some((id) => roleIds.includes(id))) {
|
||||
return 'role_not_allowed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
32
apps/bot/src/command-gates.test.ts
Normal file
32
apps/bot/src/command-gates.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isChannelAllowed, isRoleAllowed } from './command-gate-rules.js';
|
||||
|
||||
describe('command-gates channel rules', () => {
|
||||
it('allows any channel when allow and deny are empty', () => {
|
||||
expect(isChannelAllowed('1', [], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks denied channels even when allow is empty', () => {
|
||||
expect(isChannelAllowed('1', [], ['1'])).toBe('channel_denied');
|
||||
});
|
||||
|
||||
it('requires membership when allow list is set', () => {
|
||||
expect(isChannelAllowed('1', ['2'], [])).toBe('channel_not_allowed');
|
||||
expect(isChannelAllowed('2', ['2'], [])).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers deny over allow', () => {
|
||||
expect(isChannelAllowed('1', ['1'], ['1'])).toBe('channel_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('command-gates role rules', () => {
|
||||
it('blocks when any denied role is present', () => {
|
||||
expect(isRoleAllowed(['a', 'b'], [], ['b'])).toBe('role_denied');
|
||||
});
|
||||
|
||||
it('requires an allowed role when allow list is set', () => {
|
||||
expect(isRoleAllowed(['a'], ['b'], [])).toBe('role_not_allowed');
|
||||
expect(isRoleAllowed(['a', 'b'], ['b'], [])).toBeNull();
|
||||
});
|
||||
});
|
||||
92
apps/bot/src/command-gates.ts
Normal file
92
apps/bot/src/command-gates.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ChatInputCommandInteraction, GuildMember } from 'discord.js';
|
||||
import type { BotContext } from './types.js';
|
||||
import { redis } from './redis.js';
|
||||
import {
|
||||
isChannelAllowed,
|
||||
isRoleAllowed,
|
||||
type CommandGateReason
|
||||
} from './command-gate-rules.js';
|
||||
|
||||
export type { CommandGateReason };
|
||||
export type CommandGateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: CommandGateReason; retryAfterSeconds?: number };
|
||||
|
||||
function memberRoleIds(member: GuildMember | null): string[] {
|
||||
if (!member) {
|
||||
return [];
|
||||
}
|
||||
return [...member.roles.cache.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies guild-wide command channel rules and per-command overrides.
|
||||
* Empty allow-lists mean "no restriction"; deny-lists always block.
|
||||
* Per-command allow/deny is evaluated after global channel rules.
|
||||
*/
|
||||
export async function evaluateCommandGate(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
context: BotContext
|
||||
): Promise<CommandGateResult> {
|
||||
if (!interaction.guildId) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const [settings, override] = await Promise.all([
|
||||
context.prisma.guildSettings.findUnique({ where: { guildId: interaction.guildId } }),
|
||||
context.prisma.commandOverride.findUnique({
|
||||
where: {
|
||||
guildId_commandName: {
|
||||
guildId: interaction.guildId,
|
||||
commandName: interaction.commandName
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
if (override && !override.enabled) {
|
||||
return { ok: false, reason: 'disabled' };
|
||||
}
|
||||
|
||||
const channelId = interaction.channelId;
|
||||
const globalChannelBlock = isChannelAllowed(
|
||||
channelId,
|
||||
settings?.commandAllowedChannelIds ?? [],
|
||||
settings?.commandDeniedChannelIds ?? []
|
||||
);
|
||||
if (globalChannelBlock) {
|
||||
return { ok: false, reason: globalChannelBlock };
|
||||
}
|
||||
|
||||
if (override) {
|
||||
const perCommandChannelBlock = isChannelAllowed(
|
||||
channelId,
|
||||
override.allowedChannelIds,
|
||||
override.deniedChannelIds
|
||||
);
|
||||
if (perCommandChannelBlock) {
|
||||
return { ok: false, reason: perCommandChannelBlock };
|
||||
}
|
||||
|
||||
const member =
|
||||
interaction.member && 'roles' in interaction.member
|
||||
? (interaction.member as GuildMember)
|
||||
: null;
|
||||
const roleIds = memberRoleIds(member);
|
||||
const roleBlock = isRoleAllowed(roleIds, override.allowedRoleIds, override.deniedRoleIds);
|
||||
if (roleBlock) {
|
||||
return { ok: false, reason: roleBlock };
|
||||
}
|
||||
|
||||
if (override.cooldownSeconds && override.cooldownSeconds > 0) {
|
||||
const key = `command:cooldown:${interaction.guildId}:${interaction.commandName}:${interaction.user.id}`;
|
||||
const existing = await redis.ttl(key);
|
||||
if (existing > 0) {
|
||||
return { ok: false, reason: 'cooldown', retryAfterSeconds: existing };
|
||||
}
|
||||
await redis.set(key, '1', 'EX', override.cooldownSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
type ChatInputCommandInteraction,
|
||||
type MessageContextMenuCommandInteraction
|
||||
} from 'discord.js';
|
||||
import { t } from '@nexumi/shared';
|
||||
import { t, tf, type Locale } from '@nexumi/shared';
|
||||
import { env } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getGuildLocale } from './i18n.js';
|
||||
import { evaluateCommandGate, type CommandGateReason } from './command-gates.js';
|
||||
import { isMaintenanceMode } from './presence.js';
|
||||
import { moderationCommands } from './modules/moderation/commands.js';
|
||||
import { automodCommands } from './modules/automod/commands.js';
|
||||
@@ -95,6 +96,25 @@ export async function registerCommands() {
|
||||
);
|
||||
}
|
||||
|
||||
function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string {
|
||||
switch (reason) {
|
||||
case 'disabled':
|
||||
return t(locale, 'generic.commandDisabled');
|
||||
case 'channel_denied':
|
||||
case 'channel_not_allowed':
|
||||
return t(locale, 'generic.commandChannelBlocked');
|
||||
case 'role_denied':
|
||||
case 'role_not_allowed':
|
||||
return t(locale, 'generic.commandRoleBlocked');
|
||||
case 'cooldown':
|
||||
return tf(locale, 'generic.commandCooldown', {
|
||||
seconds: retryAfterSeconds ?? 1
|
||||
});
|
||||
default:
|
||||
return t(locale, 'generic.noPermission');
|
||||
}
|
||||
}
|
||||
|
||||
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
const maintenance = await isMaintenanceMode(context);
|
||||
@@ -112,6 +132,16 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
|
||||
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const gate = await evaluateCommandGate(interaction, context);
|
||||
if (!gate.ok) {
|
||||
await interaction.reply({
|
||||
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
|
||||
ephemeral: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await command.execute(interaction, context);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user