From 0b54809113d6b30627a13570230ad252af89a806 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 00:47:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20web=20backend=20=E2=80=94=20Expre?= =?UTF-8?q?ss=20REST=20API,=20WebSocket,=20full=20wiring=20in=20entry=20po?= =?UTF-8?q?int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 48 +++++++++++++ src/web/api/auth.ts | 104 +++++++++++++++++++++++++++++ src/web/api/bot.ts | 88 ++++++++++++++++++++++++ src/web/api/music.ts | 90 +++++++++++++++++++++++++ src/web/api/player.ts | 152 ++++++++++++++++++++++++++++++++++++++++++ src/web/server.ts | 81 ++++++++++++++++++++++ src/web/websocket.ts | 70 +++++++++++++++++++ 7 files changed, 633 insertions(+) create mode 100644 src/web/api/auth.ts create mode 100644 src/web/api/bot.ts create mode 100644 src/web/api/music.ts create mode 100644 src/web/api/player.ts create mode 100644 src/web/server.ts create mode 100644 src/web/websocket.ts diff --git a/src/index.ts b/src/index.ts index d4a6268..8da3316 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,12 @@ import { fileURLToPath } from "node:url"; import { loadConfig, saveConfig } from "./data/config.js"; import { createDatabase } from "./data/database.js"; import { createLogger } from "./logger.js"; +import { createApiServerManager } from "./music/api-server.js"; +import { NeteaseProvider } from "./music/netease.js"; +import { QQMusicProvider } from "./music/qq.js"; +import { createCookieStore } from "./music/auth.js"; +import { BotManager } from "./bot/manager.js"; +import { createWebServer } from "./web/server.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(__dirname, ".."); @@ -10,6 +16,8 @@ const DATA_DIR = path.join(ROOT_DIR, "data"); const CONFIG_PATH = path.join(ROOT_DIR, "config.json"); const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db"); const LOG_DIR = path.join(DATA_DIR, "logs"); +const COOKIE_DIR = path.join(DATA_DIR, "cookies"); +const STATIC_DIR = path.join(ROOT_DIR, "web", "dist"); async function main() { const config = loadConfig(CONFIG_PATH); @@ -18,10 +26,50 @@ async function main() { const logger = createLogger(LOG_DIR); const db = createDatabase(DB_PATH); + const apiServer = createApiServerManager( + { neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort }, + logger + ); + await apiServer.start(); + + const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl()); + const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl()); + + const cookieStore = createCookieStore(COOKIE_DIR); + const neteaseCookie = cookieStore.load("netease"); + if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie); + const qqCookie = cookieStore.load("qq"); + if (qqCookie) qqProvider.setCookie(qqCookie); + + const botManager = new BotManager( + neteaseProvider, + qqProvider, + db, + config, + logger + ); + await botManager.loadSavedBots(); + + const webServer = createWebServer({ + port: config.webPort, + botManager, + neteaseProvider, + qqProvider, + database: db, + config, + logger, + staticDir: STATIC_DIR, + }); + await webServer.start(); + logger.info({ webPort: config.webPort }, "TSMusicBot started"); + logger.info(`WebUI: http://localhost:${config.webPort}`); const shutdown = () => { logger.info("Shutting down..."); + botManager.shutdown(); + webServer.stop(); + apiServer.stop(); db.close(); process.exit(0); }; diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts new file mode 100644 index 0000000..269aa80 --- /dev/null +++ b/src/web/api/auth.ts @@ -0,0 +1,104 @@ +import { Router } from "express"; +import type { MusicProvider } from "../../music/provider.js"; +import type { Logger } from "../../logger.js"; + +export function createAuthRouter( + neteaseProvider: MusicProvider, + qqProvider: MusicProvider, + logger: Logger +): Router { + const router = Router(); + + function getProvider(platform?: string): MusicProvider { + return platform === "qq" ? qqProvider : neteaseProvider; + } + + router.get("/status", async (req, res) => { + try { + const platform = req.query.platform as string; + const provider = getProvider(platform); + const status = await provider.getAuthStatus(); + res.json({ platform: provider.platform, ...status }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/qrcode", async (req, res) => { + try { + const { platform } = req.body; + const provider = getProvider(platform); + const qr = await provider.getQrCode(); + res.json(qr); + } catch (err) { + logger.error({ err }, "QR code generation failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/qrcode/status", async (req, res) => { + try { + const { key, platform } = req.query; + if (!key) { + res.status(400).json({ error: "key is required" }); + return; + } + const provider = getProvider(platform as string); + const status = await provider.checkQrCodeStatus(key as string); + res.json({ status }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/sms/send", async (req, res) => { + try { + const { phone } = req.body; + if (!phone) { + res.status(400).json({ error: "phone is required" }); + return; + } + if (!neteaseProvider.sendSmsCode) { + res + .status(400) + .json({ error: "SMS login not supported for this platform" }); + return; + } + const success = await neteaseProvider.sendSmsCode(phone); + res.json({ success }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/sms/verify", async (req, res) => { + try { + const { phone, code } = req.body; + if (!phone || !code) { + res.status(400).json({ error: "phone and code are required" }); + return; + } + if (!neteaseProvider.loginWithSms) { + res.status(400).json({ error: "SMS login not supported" }); + return; + } + const success = await neteaseProvider.loginWithSms(phone, code); + res.json({ success }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/cookie", (req, res) => { + const { platform, cookie } = req.body; + if (!cookie) { + res.status(400).json({ error: "cookie is required" }); + return; + } + const provider = getProvider(platform); + provider.setCookie(cookie); + res.json({ success: true }); + }); + + return router; +} diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts new file mode 100644 index 0000000..a85e308 --- /dev/null +++ b/src/web/api/bot.ts @@ -0,0 +1,88 @@ +import { Router } from "express"; +import type { BotManager } from "../../bot/manager.js"; +import type { BotConfig } from "../../data/config.js"; +import type { Logger } from "../../logger.js"; + +export function createBotRouter( + botManager: BotManager, + config: BotConfig, + logger: Logger +): Router { + const router = Router(); + + router.get("/", (_req, res) => { + const bots = botManager.getAllBots().map((b) => b.getStatus()); + res.json({ bots }); + }); + + router.get("/:id", (req, res) => { + const bot = botManager.getBot(req.params.id); + if (!bot) { + res.status(404).json({ error: "Bot not found" }); + return; + } + res.json(bot.getStatus()); + }); + + router.post("/", async (req, res) => { + try { + const { + name, + serverAddress, + serverPort, + nickname, + defaultChannel, + channelPassword, + autoStart, + } = req.body; + if (!name || !serverAddress || !nickname) { + res + .status(400) + .json({ error: "name, serverAddress, and nickname are required" }); + return; + } + const bot = await botManager.createBot({ + name, + serverAddress, + serverPort: serverPort ?? 9987, + nickname, + defaultChannel, + channelPassword, + autoStart: autoStart ?? false, + }); + res.status(201).json(bot.getStatus()); + } catch (err) { + logger.error({ err }, "Failed to create bot"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.delete("/:id", async (req, res) => { + try { + await botManager.removeBot(req.params.id); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/:id/start", async (req, res) => { + try { + await botManager.startBot(req.params.id); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/:id/stop", (req, res) => { + try { + botManager.stopBot(req.params.id); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + return router; +} diff --git a/src/web/api/music.ts b/src/web/api/music.ts new file mode 100644 index 0000000..0930fe5 --- /dev/null +++ b/src/web/api/music.ts @@ -0,0 +1,90 @@ +import { Router } from "express"; +import type { MusicProvider } from "../../music/provider.js"; +import type { Logger } from "../../logger.js"; + +export function createMusicRouter( + neteaseProvider: MusicProvider, + qqProvider: MusicProvider, + logger: Logger +): Router { + const router = Router(); + + function getProvider(platform?: string): MusicProvider { + return platform === "qq" ? qqProvider : neteaseProvider; + } + + router.get("/search", async (req, res) => { + try { + const { q, platform, limit } = req.query; + if (!q) { + res.status(400).json({ error: "q (query) is required" }); + return; + } + const provider = getProvider(platform as string); + const result = await provider.search( + q as string, + parseInt(limit as string) || 20 + ); + res.json(result); + } catch (err) { + logger.error({ err }, "Search failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/song/:id", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + const song = await provider.getSongDetail(req.params.id); + if (!song) { + res.status(404).json({ error: "Song not found" }); + return; + } + res.json(song); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/playlist/:id", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + const songs = await provider.getPlaylistSongs(req.params.id); + res.json({ songs }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/recommend/playlists", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + const playlists = await provider.getRecommendPlaylists(); + res.json({ playlists }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/album/:id", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + const songs = await provider.getAlbumSongs(req.params.id); + res.json({ songs }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/lyrics/:id", async (req, res) => { + try { + const provider = getProvider(req.query.platform as string); + const lyrics = await provider.getLyrics(req.params.id); + res.json({ lyrics }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + return router; +} diff --git a/src/web/api/player.ts b/src/web/api/player.ts new file mode 100644 index 0000000..67e7ce7 --- /dev/null +++ b/src/web/api/player.ts @@ -0,0 +1,152 @@ +import { Router } from "express"; +import type { BotManager } from "../../bot/manager.js"; +import type { Logger } from "../../logger.js"; +import { parseCommand } from "../../bot/commands.js"; + +export function createPlayerRouter( + botManager: BotManager, + logger: Logger +): Router { + const router = Router(); + + router.use("/:botId", (req, res, next) => { + const bot = botManager.getBot(req.params.botId); + if (!bot) { + res.status(404).json({ error: "Bot not found" }); + return; + } + (req as any).bot = bot; + next(); + }); + + router.post("/:botId/play", async (req, res) => { + try { + const bot = (req as any).bot; + const { query, platform } = req.body; + if (!query) { + res.status(400).json({ error: "query is required" }); + return; + } + const flags = platform === "qq" ? "-q" : ""; + const cmd = parseCommand(`!play ${flags} ${query}`.trim(), "!"); + if (!cmd) { + res.status(400).json({ error: "Invalid command" }); + return; + } + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/:botId/add", async (req, res) => { + try { + const bot = (req as any).bot; + const { query, platform } = req.body; + const flags = platform === "qq" ? "-q" : ""; + const cmd = parseCommand(`!add ${flags} ${query}`.trim(), "!"); + if (!cmd) { + res.status(400).json({ error: "Invalid command" }); + return; + } + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.post("/:botId/pause", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!pause", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/resume", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!resume", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/next", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!next", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/prev", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!prev", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/stop", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!stop", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/volume", async (req, res) => { + const bot = (req as any).bot; + const { volume } = req.body; + const cmd = parseCommand(`!vol ${volume}`, "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/mode", async (req, res) => { + const bot = (req as any).bot; + const { mode } = req.body; + const cmd = parseCommand(`!mode ${mode}`, "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.get("/:botId/queue", (req, res) => { + const bot = (req as any).bot; + res.json({ queue: bot.getQueue(), status: bot.getStatus() }); + }); + + router.post("/:botId/clear", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand("!clear", "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.delete("/:botId/queue/:index", async (req, res) => { + const bot = (req as any).bot; + const cmd = parseCommand(`!remove ${req.params.index}`, "!")!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + }); + + router.post("/:botId/playlist", async (req, res) => { + try { + const bot = (req as any).bot; + const { playlistId, platform } = req.body; + const flags = platform === "qq" ? "-q" : ""; + const cmd = parseCommand( + `!playlist ${flags} ${playlistId}`.trim(), + "!" + )!; + const response = await bot.executeCommand(cmd); + res.json({ message: response }); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } + }); + + router.get("/:botId/history", (req, res) => { + // Will be wired when database access is available through bot + res.json({ history: [] }); + }); + + return router; +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..a97f926 --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,81 @@ +import express from "express"; +import http from "node:http"; +import path from "node:path"; +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 { Logger } from "../logger.js"; +import { createBotRouter } from "./api/bot.js"; +import { createMusicRouter } from "./api/music.js"; +import { createPlayerRouter } from "./api/player.js"; +import { createAuthRouter } from "./api/auth.js"; +import { setupWebSocket } from "./websocket.js"; + +export interface WebServerOptions { + port: number; + botManager: BotManager; + neteaseProvider: MusicProvider; + qqProvider: MusicProvider; + database: BotDatabase; + config: BotConfig; + logger: Logger; + staticDir?: string; +} + +export interface WebServer { + start(): Promise; + stop(): void; +} + +export function createWebServer(options: WebServerOptions): WebServer { + const app = express(); + const server = http.createServer(app); + const logger = options.logger.child({ component: "web" }); + + app.use(express.json()); + + app.use( + "/api/bot", + createBotRouter(options.botManager, options.config, logger) + ); + app.use( + "/api/music", + createMusicRouter(options.neteaseProvider, options.qqProvider, logger) + ); + app.use("/api/player", createPlayerRouter(options.botManager, logger)); + app.use( + "/api/auth", + createAuthRouter(options.neteaseProvider, options.qqProvider, logger) + ); + + app.get("/api/health", (_req, res) => { + res.json({ status: "ok", version: "0.1.0" }); + }); + + if (options.staticDir) { + app.use(express.static(options.staticDir)); + app.get("*", (_req, res) => { + res.sendFile(path.join(options.staticDir!, "index.html")); + }); + } + + const wss = new WebSocketServer({ server, path: "/ws" }); + setupWebSocket(wss, options.botManager, logger); + + return { + async start(): Promise { + return new Promise((resolve) => { + server.listen(options.port, () => { + logger.info({ port: options.port }, "Web server started"); + resolve(); + }); + }); + }, + stop(): void { + wss.close(); + server.close(); + }, + }; +} diff --git a/src/web/websocket.ts b/src/web/websocket.ts new file mode 100644 index 0000000..a8d5a14 --- /dev/null +++ b/src/web/websocket.ts @@ -0,0 +1,70 @@ +import { WebSocketServer, WebSocket } from "ws"; +import type { BotManager } from "../bot/manager.js"; +import type { Logger } from "../logger.js"; + +export function setupWebSocket( + wss: WebSocketServer, + botManager: BotManager, + logger: Logger +): void { + const clients = new Set(); + + wss.on("connection", (ws) => { + clients.add(ws); + logger.debug("WebSocket client connected"); + + const bots = botManager.getAllBots().map((b) => b.getStatus()); + ws.send(JSON.stringify({ type: "init", bots })); + + ws.on("close", () => { + clients.delete(ws); + logger.debug("WebSocket client disconnected"); + }); + + ws.on("error", (err) => { + logger.error({ err }, "WebSocket error"); + clients.delete(ws); + }); + }); + + const broadcast = (data: object) => { + const message = JSON.stringify(data); + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } + }; + + const attachBotListeners = () => { + for (const bot of botManager.getAllBots()) { + bot.removeAllListeners("stateChange"); + bot.removeAllListeners("connected"); + bot.removeAllListeners("disconnected"); + + bot.on("stateChange", () => { + broadcast({ + type: "stateChange", + botId: bot.id, + status: bot.getStatus(), + queue: bot.getQueue(), + }); + }); + + bot.on("connected", () => { + broadcast({ + type: "botConnected", + botId: bot.id, + status: bot.getStatus(), + }); + }); + + bot.on("disconnected", () => { + broadcast({ type: "botDisconnected", botId: bot.id }); + }); + } + }; + + setInterval(attachBotListeners, 5000); + attachBotListeners(); +}