mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(bot): gate admin chat commands on adminGroups with fallback + deny reply
This commit is contained in:
1 parent
b090a8ec21
commit
72ffd44f68
2 files changed
+135
-5
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { BotInstance } from "./instance.js";
|
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,
|
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
|
||||||
// reads avatars, etc.), and runExclusive only touches a single private field
|
// 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<void>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-3
@@ -9,7 +9,7 @@ import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
|
|||||||
import type { MusicProvider, Song } from "../music/provider.js";
|
import type { MusicProvider, Song } from "../music/provider.js";
|
||||||
import {
|
import {
|
||||||
parseCommand,
|
parseCommand,
|
||||||
isAdminCommand,
|
canRunCommand,
|
||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
shouldResumeOnReturn,
|
shouldResumeOnReturn,
|
||||||
} from "./auto-pause.js";
|
} from "./auto-pause.js";
|
||||||
|
|
||||||
|
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||||
|
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -322,8 +325,17 @@ export class BotInstance extends EventEmitter {
|
|||||||
);
|
);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
|
|
||||||
if (isAdminCommand(parsed.name)) {
|
if (!(await this.isCommandAllowed(parsed.name, msg))) {
|
||||||
// TODO: Check if invoker is in adminGroups
|
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(
|
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(
|
async executeCommand(
|
||||||
cmd: ParsedCommand,
|
cmd: ParsedCommand,
|
||||||
msg?: TS3TextMessage
|
msg?: TS3TextMessage
|
||||||
|
|||||||
Reference in new issue
Block a user