feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)

Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-03 00:09:12 +08:00
1 parent 322335dfc5
commit 8998b9623f
8 files changed
+363 -2

No files matched your search

+68
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
@@ -611,6 +618,67 @@ describe("BotInstance.seek — spotify routing (C4)", () => {
});
});
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
// The process-wide shared SpotifyOAuth must reach the SpotifyController via the
// controller factory. We drive the REAL BotInstance constructor with a fake
// controller factory that captures its param object, so the thread is observed
// end-to-end (options.spotifyOAuth -> buildController({ oauth })).
describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1)", () => {
function makeInstanceOptions(over: Partial<BotInstanceOptions> = {}): {
options: BotInstanceOptions;
captured: { param?: { oauth?: SpotifyOAuth } };
} {
const captured: { param?: { oauth?: SpotifyOAuth } } = {};
const provider = { platform: "netease" } as unknown as MusicProvider;
const logger: any = {
info() {}, warn() {}, error() {}, debug() {},
child() { return logger; },
};
const database = {
getProfileConfig: () => ({}),
getCustomAvatarPath: () => null,
} as unknown as BotDatabase;
const options: BotInstanceOptions = {
id: "bot-oauth-test",
name: "OAuthBot",
tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "OAuthBot" } as any,
neteaseProvider: provider,
qqProvider: provider,
bilibiliProvider: provider,
youtubeProvider: provider,
database,
config: { spotify: {} } as unknown as BotConfig,
logger,
avatarStore: { read: () => null } as unknown as AvatarStore,
spotifyControllerFactory: (o) => {
captured.param = o;
// Only `on` is touched during construction (setupPlayerEvents wires
// the "trackEnded" listener); return a minimal fake controller.
return { on: () => {} } as unknown as SpotifyController;
},
...over,
};
return { options, captured };
}
it("forwards the injected spotifyOAuth to the controller factory as `oauth`", () => {
const sentinel = {} as unknown as SpotifyOAuth;
const { options, captured } = makeInstanceOptions({ spotifyOAuth: sentinel });
// eslint-disable-next-line no-new
new BotInstance(options);
expect(captured.param).toBeDefined();
expect(captured.param?.oauth).toBe(sentinel);
});
it("leaves the factory `oauth` undefined when no spotifyOAuth is supplied (behavior-unchanged)", () => {
const { options, captured } = makeInstanceOptions();
// eslint-disable-next-line no-new
new BotInstance(options);
expect(captured.param).toBeDefined();
expect(captured.param?.oauth).toBeUndefined();
});
});
describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
it("yields the SAME ports for the same bot id (stable across restarts)", () => {
const a = spotifyPortsForBotId("bot-alpha");