diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 751116c..1e0df26 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { BotInstance } from "./instance.js"; +import { describe, it, expect, vi } from "vitest"; +import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js"; +import type { TS3TextMessage } from "../ts-protocol/client.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, // reads avatars, etc.), and runExclusive only touches a single private field @@ -110,3 +111,84 @@ describe("BotInstance.runExclusive — serialization", () => { ]); }); }); + +/** Minimal `this` carrying only what handleTextMessage's gate path touches. + * The gate methods live on the prototype and are attached here so calls like + * `this.isCommandAllowed(...)` resolve against this same object. */ +function makeGateCtx(opts: { + adminGroups?: number[]; + clients?: Array<{ id: number; serverGroups: string[] }>; +}) { + const ctx: any = { + config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] }, + logger: { info: vi.fn(), error: vi.fn() }, + tsClient: { + sendTextMessage: vi.fn(async () => {}), + getClientsInChannel: vi.fn(async () => opts.clients ?? []), + }, + executeCommand: vi.fn(async () => null), + isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed, + lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups, + }; + return ctx; +} + +function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage { + return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups }; +} + +const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as ( + this: unknown, + msg: TS3TextMessage, +) => Promise; + +describe("BotInstance.handleTextMessage — command permission gate", () => { + it("runs a public command even with enforcement on", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!play 晴天")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("runs an admin command when enforcement is off (empty adminGroups)", async () => { + const ctx = makeGateCtx({ adminGroups: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop")); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("runs an admin command when the event carried a matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["6"])); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed + }); + + it("denies an admin command when known groups do not match (no fallback, with reply)", async () => { + const ctx = makeGateCtx({ adminGroups: [6] }); + await handleTextMessage.call(ctx, makeMsg("!stop", ["8"])); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("falls back to a group lookup when the event carried no groups, and allows on match", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1); + expect(ctx.executeCommand).toHaveBeenCalledTimes(1); + }); + + it("fails closed when the fallback finds the client but no matching group", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); + + it("fails closed when the fallback cannot find the client at all", async () => { + const ctx = makeGateCtx({ adminGroups: [6], clients: [] }); + await handleTextMessage.call(ctx, makeMsg("!stop", [], "5")); + expect(ctx.executeCommand).not.toHaveBeenCalled(); + expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index e83ec25..263beab 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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 { + 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 { + 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