feat(bot): gate admin chat commands on adminGroups with fallback + deny reply

This commit is contained in:
saopig1 committed 2026-06-26 20:40:01 +08:00
1 parent b090a8ec21
commit 72ffd44f68
2 files changed
+135 -5

No files matched your search

+51 -3
View File
@@ -9,7 +9,7 @@ import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
import type { MusicProvider, Song } from "../music/provider.js";
import {
parseCommand,
isAdminCommand,
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
@@ -24,6 +24,9 @@ import {
shouldResumeOnReturn,
} from "./auto-pause.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
export interface BotInstanceOptions {
id: string;
name: string;
@@ -322,8 +325,17 @@ export class BotInstance extends EventEmitter {
);
if (!parsed) return;
if (isAdminCommand(parsed.name)) {
// TODO: Check if invoker is in adminGroups
if (!(await this.isCommandAllowed(parsed.name, msg))) {
this.logger.info(
{ command: parsed.name, invoker: msg.invokerName },
"Command denied: invoker not in adminGroups"
);
try {
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
}
return;
}
this.logger.info(
@@ -348,6 +360,42 @@ export class BotInstance extends EventEmitter {
}
}
/**
* Decide whether a chat command may run for this sender. Reads adminGroups
* live from this.config (the router mutates the same object). Only performs
* the async group lookup when the synchronous decision is "deny because the
* event carried no groups" — i.e. an admin command, enforcement on, and
* empty invokerGroups. Fails closed if groups remain undeterminable.
*/
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
const adminGroups = this.config.adminGroups;
if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true;
// Here: admin command, enforcement on, and the provided groups did not match.
// If the event actually carried groups, this is a genuine deny — no lookup.
if (msg.invokerGroups.length > 0) return false;
// Groups unknown (sender not in the view cache): one targeted lookup, then
// re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown.
const groups = await this.lookupInvokerGroups(msg.invokerId);
return canRunCommand(commandName, groups, adminGroups);
}
/**
* Best-effort lookup of a sender's server groups by client id, via the
* channel client list (whose entries already carry parsed serverGroups).
* Returns [] when the client can't be found or the query fails (→ deny).
*/
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
const clid = Number(invokerId);
if (!Number.isFinite(clid) || clid <= 0) return [];
try {
const clients = await this.tsClient.getClientsInChannel();
const match = clients.find((c) => c.id === clid);
return match?.serverGroups ?? [];
} catch {
return [];
}
}
async executeCommand(
cmd: ParsedCommand,
msg?: TS3TextMessage