mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
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:
1 parent
322335dfc5
commit
8998b9623f
8 files changed
+363
-2
No files matched your search
@@ -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");
|
||||
|
||||
@@ -27,6 +27,7 @@ import { isSpotifyUri } from "../music/spotify/webapi.js";
|
||||
import path from "node:path";
|
||||
import { SpotifyController } from "../music/spotify/controller.js";
|
||||
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
|
||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||
@@ -77,6 +78,9 @@ export interface BotInstanceOptions {
|
||||
avatarStore: AvatarStore;
|
||||
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
|
||||
spotifyDataDir?: string;
|
||||
/** Process-wide shared Spotify OAuth (single account); injected into the
|
||||
* SpotifyController so web-login authorization is visible to playback (C3.1). */
|
||||
spotifyOAuth?: SpotifyOAuth;
|
||||
/** Test seam: build a fake controller instead of a real go-librespot one. */
|
||||
spotifyControllerFactory?: (o: {
|
||||
config: SpotifyConfig;
|
||||
@@ -85,6 +89,7 @@ export interface BotInstanceOptions {
|
||||
logger: Logger;
|
||||
apiPort: number;
|
||||
callbackPort: number;
|
||||
oauth?: SpotifyOAuth;
|
||||
}) => SpotifyController;
|
||||
}
|
||||
|
||||
@@ -181,6 +186,7 @@ export class BotInstance extends EventEmitter {
|
||||
logger: this.logger,
|
||||
apiPort: spotifyApiPort,
|
||||
callbackPort: spotifyCallbackPort,
|
||||
oauth: options.spotifyOAuth,
|
||||
});
|
||||
|
||||
const profileConfig = this.database.getProfileConfig(this.id);
|
||||
@@ -1412,6 +1418,13 @@ export class BotInstance extends EventEmitter {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
/** The per-bot Spotify sidecar controller. Exposed like getPlayer()/
|
||||
* getQueueManager() so the shared, process-wide OAuth threaded in at
|
||||
* construction (C3.1) is observable to callers/tests via getOAuth(). */
|
||||
getSpotifyController(): SpotifyController {
|
||||
return this.spotifyController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a seek to the Spotify sidecar for a spotify track (its PCM stream is
|
||||
* external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { MusicProvider } from "../music/provider.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
|
||||
// removeBot only calls logger.info; provide the full shape it could touch.
|
||||
const stubLogger = {
|
||||
@@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => {
|
||||
expect(loadConfig(configPath).guestMode.bots).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
|
||||
// The single process-wide SpotifyOAuth built in index.ts must reach every bot's
|
||||
// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance
|
||||
// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController,
|
||||
// so we assert the shared instance surfaces via the controller's getOAuth().
|
||||
describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => {
|
||||
const dirs: string[] = [];
|
||||
let db: BotDatabase | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
db?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
db = undefined;
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs.length = 0;
|
||||
});
|
||||
|
||||
it("forwards its shared SpotifyOAuth into a created bot's controller", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-"));
|
||||
dirs.push(dir);
|
||||
const configPath = join(dir, "config.json");
|
||||
const config = getDefaultConfig();
|
||||
saveConfig(configPath, config);
|
||||
db = createDatabase(":memory:");
|
||||
const permissions = createPermissionStore(db.db);
|
||||
const provider = {} as unknown as MusicProvider;
|
||||
const sentinel = {} as unknown as SpotifyOAuth;
|
||||
|
||||
const manager = new BotManager(
|
||||
provider,
|
||||
provider,
|
||||
provider,
|
||||
db,
|
||||
config,
|
||||
stubLogger,
|
||||
{} as unknown as AvatarStore,
|
||||
permissions,
|
||||
configPath,
|
||||
undefined, // localProvider
|
||||
undefined, // kugouProvider
|
||||
undefined, // spotifyProvider
|
||||
join(dir, "spotify"), // spotifyDataDir
|
||||
sentinel, // spotifyOAuth (the single shared instance)
|
||||
);
|
||||
|
||||
const bot = await manager.createBot({
|
||||
name: "b1",
|
||||
serverAddress: "localhost",
|
||||
serverPort: 9987,
|
||||
nickname: "b1",
|
||||
});
|
||||
|
||||
// Full chain observed: the manager's single shared instance is the exact
|
||||
// one the per-bot controller now owns (getOAuth() returns it unchanged).
|
||||
expect(bot.getSpotifyController().getOAuth()).toBe(sentinel);
|
||||
|
||||
bot.disconnect();
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -14,6 +14,7 @@ import type { Logger } from "../logger.js";
|
||||
import type { ServerProtocol } from "../ts-protocol/client.js";
|
||||
import type { AvatarStore } from "../data/avatars.js";
|
||||
import type { PermissionStore } from "../data/permissions.js";
|
||||
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
|
||||
|
||||
/**
|
||||
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
|
||||
@@ -79,6 +80,7 @@ export class BotManager extends EventEmitter {
|
||||
private kugouProvider: MusicProvider;
|
||||
private spotifyProvider: MusicProvider;
|
||||
private spotifyDataDir: string;
|
||||
private readonly spotifyOAuth?: SpotifyOAuth;
|
||||
private database: BotDatabase;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
@@ -99,7 +101,8 @@ export class BotManager extends EventEmitter {
|
||||
localProvider?: MusicProvider,
|
||||
kugouProvider?: MusicProvider,
|
||||
spotifyProvider?: MusicProvider,
|
||||
spotifyDataDir?: string
|
||||
spotifyDataDir?: string,
|
||||
spotifyOAuth?: SpotifyOAuth
|
||||
) {
|
||||
super();
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
@@ -110,6 +113,7 @@ export class BotManager extends EventEmitter {
|
||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
||||
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||
this.spotifyOAuth = spotifyOAuth;
|
||||
// Let the local provider see which uploads are still referenced by any
|
||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||
const referenceable = this.localProvider as Partial<{
|
||||
@@ -154,6 +158,7 @@ export class BotManager extends EventEmitter {
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
|
||||
this.bots.set(id, bot);
|
||||
@@ -296,6 +301,7 @@ export class BotManager extends EventEmitter {
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
this.bots.set(id, bot);
|
||||
this.emit("botInstance", bot);
|
||||
@@ -352,6 +358,7 @@ export class BotManager extends EventEmitter {
|
||||
logger: this.logger,
|
||||
avatarStore: this.avatarStore,
|
||||
spotifyDataDir: this.spotifyDataDir,
|
||||
spotifyOAuth: this.spotifyOAuth,
|
||||
});
|
||||
|
||||
this.bots.set(saved.id, bot);
|
||||
|
||||
Reference in new issue
Block a user