mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
fix(guest): tear down guest WS on guest-mode config change
An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c1d73b6ba8
commit
43c0175334
4 files changed
+120
-8
No files matched your search
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { BotManager } from "../../bot/manager.js";
|
import type { BotManager } from "../../bot/manager.js";
|
||||||
import type { BotConfig } from "../../data/config.js";
|
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
|
||||||
import { saveConfig } from "../../data/config.js";
|
import { saveConfig } from "../../data/config.js";
|
||||||
import type { Logger } from "../../logger.js";
|
import type { Logger } from "../../logger.js";
|
||||||
import type { BotDatabase } from "../../data/database.js";
|
import type { BotDatabase } from "../../data/database.js";
|
||||||
@@ -16,6 +16,7 @@ export function createBotRouter(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
botDb: BotDatabase,
|
botDb: BotDatabase,
|
||||||
avatarStore: AvatarStore,
|
avatarStore: AvatarStore,
|
||||||
|
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
|
||||||
): Router {
|
): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -55,7 +56,8 @@ export function createBotRouter(
|
|||||||
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||||
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
|
||||||
|
|
||||||
if (guestMode !== undefined && guestMode !== null && typeof guestMode === "object") {
|
const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
|
||||||
|
if (hasGuestMode) {
|
||||||
const gm = config.guestMode;
|
const gm = config.guestMode;
|
||||||
if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled;
|
if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled;
|
||||||
if (guestMode.bots === "all") {
|
if (guestMode.bots === "all") {
|
||||||
@@ -74,6 +76,13 @@ export function createBotRouter(
|
|||||||
|
|
||||||
saveConfig(configPath, config);
|
saveConfig(configPath, config);
|
||||||
|
|
||||||
|
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
|
||||||
|
// disabled or narrowed scope takes effect immediately (matches requireAuth's
|
||||||
|
// "disabling immediately invalidates in-flight guest sessions" invariant).
|
||||||
|
if (hasGuestMode) {
|
||||||
|
onGuestPolicyChanged?.(config.guestMode);
|
||||||
|
}
|
||||||
|
|
||||||
// 通知所有 bot 实例更新
|
// 通知所有 bot 实例更新
|
||||||
for (const bot of botManager.getAllBots()) {
|
for (const bot of botManager.getAllBots()) {
|
||||||
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
|
||||||
|
|||||||
+10
-3
@@ -6,7 +6,7 @@ import { WebSocketServer } from "ws";
|
|||||||
import type { BotManager } from "../bot/manager.js";
|
import type { BotManager } from "../bot/manager.js";
|
||||||
import type { MusicProvider } from "../music/provider.js";
|
import type { MusicProvider } from "../music/provider.js";
|
||||||
import type { BotDatabase } from "../data/database.js";
|
import type { BotDatabase } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig, GuestModeConfig } from "../data/config.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { CookieStore } from "../music/auth.js";
|
import type { CookieStore } from "../music/auth.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
@@ -104,6 +104,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
app.use("/api", requireAuth);
|
app.use("/api", requireAuth);
|
||||||
|
|
||||||
// ─── Protected routes ───────────────────────────────────────────────────
|
// ─── Protected routes ───────────────────────────────────────────────────
|
||||||
|
// The bot router is mounted BEFORE setupWebSocket runs, but its /settings
|
||||||
|
// handler needs to trigger a guest-policy refresh on the (later-created) WS
|
||||||
|
// controller. Bridge the two with a mutable indirection that starts as a
|
||||||
|
// no-op and is wired to the real refreshGuestPolicy once the WS is set up.
|
||||||
|
let onGuestPolicyChanged: (cfg: GuestModeConfig) => void = () => {};
|
||||||
app.use(
|
app.use(
|
||||||
"/api/bot",
|
"/api/bot",
|
||||||
createBotRouter(
|
createBotRouter(
|
||||||
@@ -113,6 +118,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
logger,
|
logger,
|
||||||
options.database,
|
options.database,
|
||||||
options.avatarStore,
|
options.avatarStore,
|
||||||
|
(cfg) => onGuestPolicyChanged(cfg),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
@@ -195,7 +201,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
wss.emit("connection", ws, req);
|
wss.emit("connection", ws, req);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
|
const controller = setupWebSocket(wss, options.botManager, logger);
|
||||||
|
onGuestPolicyChanged = controller.refreshGuestPolicy;
|
||||||
|
|
||||||
// ─── Session cleanup interval ──────────────────────────────────────────
|
// ─── Session cleanup interval ──────────────────────────────────────────
|
||||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -221,7 +228,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
|||||||
clearInterval(cleanupTimer);
|
clearInterval(cleanupTimer);
|
||||||
cleanupTimer = null;
|
cleanupTimer = null;
|
||||||
}
|
}
|
||||||
cleanupWs();
|
controller.cleanup();
|
||||||
wss.close();
|
wss.close();
|
||||||
server.close();
|
server.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe("WebSocket guest bot scope", () => {
|
|||||||
off: () => {},
|
off: () => {},
|
||||||
removeListener: () => {},
|
removeListener: () => {},
|
||||||
};
|
};
|
||||||
const cleanup = setupWebSocket(fakeWss, botManager, {
|
const { cleanup } = setupWebSocket(fakeWss, botManager, {
|
||||||
debug() {},
|
debug() {},
|
||||||
error() {},
|
error() {},
|
||||||
info() {},
|
info() {},
|
||||||
@@ -114,3 +114,68 @@ describe("WebSocket guest bot scope", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WebSocket refreshGuestPolicy", () => {
|
||||||
|
function makeHarness() {
|
||||||
|
const clients: any[] = [];
|
||||||
|
const fakeWss: any = {
|
||||||
|
on: (ev: string, cb: any) => {
|
||||||
|
if (ev === "connection") fakeWss._conn = cb;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const botManager: any = {
|
||||||
|
getAllBots: () => [],
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
};
|
||||||
|
const logger = { debug() {}, error() {}, info() {}, warn() {} } as any;
|
||||||
|
const controller = setupWebSocket(fakeWss, botManager, logger);
|
||||||
|
// Connect fake sockets via the connection handler so they land in `clients`.
|
||||||
|
const connect = (ws: any) => {
|
||||||
|
clients.push(ws);
|
||||||
|
fakeWss._conn(ws);
|
||||||
|
};
|
||||||
|
return { controller, connect };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeWs(opts: { isGuest: boolean; botScope?: "all" | Set<string> }) {
|
||||||
|
const closeCalls: Array<{ code?: number; reason?: string }> = [];
|
||||||
|
const ws: any = {
|
||||||
|
readyState: 1,
|
||||||
|
isGuest: opts.isGuest,
|
||||||
|
botScope: opts.botScope,
|
||||||
|
send: () => {},
|
||||||
|
on: () => {},
|
||||||
|
close: (code?: number, reason?: string) => closeCalls.push({ code, reason }),
|
||||||
|
};
|
||||||
|
return { ws, closeCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("disabling guest mode closes guest sockets but leaves non-guest sockets open", () => {
|
||||||
|
const { controller, connect } = makeHarness();
|
||||||
|
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
|
||||||
|
const member = makeFakeWs({ isGuest: false, botScope: "all" });
|
||||||
|
connect(guest.ws);
|
||||||
|
connect(member.ws);
|
||||||
|
|
||||||
|
controller.refreshGuestPolicy({ enabled: false, bots: "all" });
|
||||||
|
|
||||||
|
expect(guest.closeCalls.length).toBe(1);
|
||||||
|
expect(guest.closeCalls[0].code).toBe(1008);
|
||||||
|
expect(member.closeCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("narrowing the guest scope live re-scopes open guest sockets", () => {
|
||||||
|
const { controller, connect } = makeHarness();
|
||||||
|
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
|
||||||
|
connect(guest.ws);
|
||||||
|
|
||||||
|
controller.refreshGuestPolicy({ enabled: true, bots: ["bot2"] });
|
||||||
|
|
||||||
|
expect(guest.closeCalls.length).toBe(0);
|
||||||
|
expect(guest.ws.botScope instanceof Set).toBe(true);
|
||||||
|
expect(guest.ws.botScope.has("bot2")).toBe(true);
|
||||||
|
expect(guest.ws.botScope.has("bot1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-2
@@ -3,11 +3,21 @@ import type { BotManager } from "../bot/manager.js";
|
|||||||
import type { BotInstance } from "../bot/instance.js";
|
import type { BotInstance } from "../bot/instance.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
|
|
||||||
|
export interface WebSocketController {
|
||||||
|
cleanup: () => void;
|
||||||
|
/**
|
||||||
|
* Re-apply the current guest-mode policy to every already-open guest socket.
|
||||||
|
* If guest mode is disabled, in-flight guest sockets are force-closed; otherwise
|
||||||
|
* each guest socket is live re-scoped so out-of-scope bots stop streaming.
|
||||||
|
*/
|
||||||
|
refreshGuestPolicy: (cfg: { enabled: boolean; bots: "all" | string[] }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function setupWebSocket(
|
export function setupWebSocket(
|
||||||
wss: WebSocketServer,
|
wss: WebSocketServer,
|
||||||
botManager: BotManager,
|
botManager: BotManager,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
): () => void {
|
): WebSocketController {
|
||||||
const clients = new Set<WebSocket>();
|
const clients = new Set<WebSocket>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +160,7 @@ export function setupWebSocket(
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
ensureAllBotsAttached();
|
ensureAllBotsAttached();
|
||||||
|
|
||||||
return () => {
|
const cleanup = () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
botManager.removeListener("botInstance", onBotInstance);
|
botManager.removeListener("botInstance", onBotInstance);
|
||||||
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
|
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
|
||||||
@@ -159,4 +169,25 @@ export function setupWebSocket(
|
|||||||
detachBotListener(id);
|
detachBotListener(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When the admin changes guestMode (disable / narrow scope), already-open guest
|
||||||
|
// sockets must stop streaming immediately — their isGuest/botScope were stamped
|
||||||
|
// once at upgrade and would otherwise keep receiving bot state.
|
||||||
|
const refreshGuestPolicy = (cfg: { enabled: boolean; bots: "all" | string[] }) => {
|
||||||
|
for (const ws of clients) {
|
||||||
|
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
|
||||||
|
if (!w.isGuest) continue;
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
try {
|
||||||
|
ws.close(1008, "guest mode disabled");
|
||||||
|
} catch {
|
||||||
|
// socket may already be closing; ignore
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.botScope = cfg.bots === "all" ? "all" : new Set(cfg.bots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { cleanup, refreshGuestPolicy };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user