diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index a42376c..d6d8338 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -1,6 +1,6 @@ import { Router } from "express"; 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 type { Logger } from "../../logger.js"; import type { BotDatabase } from "../../data/database.js"; @@ -16,6 +16,7 @@ export function createBotRouter( logger: Logger, botDb: BotDatabase, avatarStore: AvatarStore, + onGuestPolicyChanged?: (cfg: GuestModeConfig) => void, ): Router { const router = Router(); @@ -55,7 +56,8 @@ export function createBotRouter( if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes; 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; if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled; if (guestMode.bots === "all") { @@ -74,6 +76,13 @@ export function createBotRouter( 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 实例更新 for (const bot of botManager.getAllBots()) { if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes); diff --git a/src/web/server.ts b/src/web/server.ts index da5258f..5b98d49 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -6,7 +6,7 @@ import { WebSocketServer } from "ws"; import type { BotManager } from "../bot/manager.js"; import type { MusicProvider } from "../music/provider.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 { CookieStore } from "../music/auth.js"; import type { AvatarStore } from "../data/avatars.js"; @@ -104,6 +104,11 @@ export function createWebServer(options: WebServerOptions): WebServer { app.use("/api", requireAuth); // ─── 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( "/api/bot", createBotRouter( @@ -113,6 +118,7 @@ export function createWebServer(options: WebServerOptions): WebServer { logger, options.database, options.avatarStore, + (cfg) => onGuestPolicyChanged(cfg), ) ); app.use( @@ -195,7 +201,8 @@ export function createWebServer(options: WebServerOptions): WebServer { 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 ────────────────────────────────────────── let cleanupTimer: ReturnType | null = null; @@ -221,7 +228,7 @@ export function createWebServer(options: WebServerOptions): WebServer { clearInterval(cleanupTimer); cleanupTimer = null; } - cleanupWs(); + controller.cleanup(); wss.close(); server.close(); }, diff --git a/src/web/websocket-auth.test.ts b/src/web/websocket-auth.test.ts index d49fbfd..c4ede10 100644 --- a/src/web/websocket-auth.test.ts +++ b/src/web/websocket-auth.test.ts @@ -102,7 +102,7 @@ describe("WebSocket guest bot scope", () => { off: () => {}, removeListener: () => {}, }; - const cleanup = setupWebSocket(fakeWss, botManager, { + const { cleanup } = setupWebSocket(fakeWss, botManager, { debug() {}, error() {}, info() {}, @@ -114,3 +114,68 @@ describe("WebSocket guest bot scope", () => { 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 }) { + 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); + }); +}); diff --git a/src/web/websocket.ts b/src/web/websocket.ts index 8ca7f2a..ac36f74 100644 --- a/src/web/websocket.ts +++ b/src/web/websocket.ts @@ -3,11 +3,21 @@ import type { BotManager } from "../bot/manager.js"; import type { BotInstance } from "../bot/instance.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( wss: WebSocketServer, botManager: BotManager, logger: Logger -): () => void { +): WebSocketController { const clients = new Set(); /** @@ -150,7 +160,7 @@ export function setupWebSocket( }, 5000); ensureAllBotsAttached(); - return () => { + const cleanup = () => { clearInterval(intervalId); botManager.removeListener("botInstance", onBotInstance); botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved); @@ -159,4 +169,25 @@ export function setupWebSocket( 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 }; + 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 }; }