mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
chore: Phase 1 complete — project scaffold, config, database, logger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c60bd69ef
commit
9931d8b40d
12 files changed
+6969
No files matched your search
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash(*)",
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
"Agent(*)",
|
||||
"mcp__Claude_Preview__*",
|
||||
"mcp__Claude_in_Chrome__*",
|
||||
"mcp__scheduled-tasks__*"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push * main)",
|
||||
"Bash(git push * master)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(rm -rf /)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash(*)",
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
"Agent(*)",
|
||||
"mcp__Claude_Preview__*",
|
||||
"mcp__Claude_in_Chrome__*",
|
||||
"mcp__scheduled-tasks__*",
|
||||
"Bash(npx vitest:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push * main)",
|
||||
"Bash(git push * master)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(rm -rf /)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
# TSMusicBot — Master Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build TSMusicBot from scratch — a TeamSpeak music bot supporting NetEase Cloud Music and QQ Music, with a YesPlayMusic-inspired WebUI, TS text commands, and one-click deployment.
|
||||
|
||||
**Architecture:** Node.js/TypeScript monolith, single process running TS3 client protocol + audio engine + embedded music APIs + Express/WebSocket web server + Vue.js SPA.
|
||||
|
||||
**Tech Stack:** Node.js 20, TypeScript 5, Express, Vue 3/Vite/Pinia, FFmpeg, @discordjs/opus, better-sqlite3, ws
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase produces working, testable software and ends with a commit checkpoint.
|
||||
|
||||
- [ ] **[Phase 1: Project Scaffold, Data Layer & Config](2026-03-29-tsmusicbot-phase1.md)** (6 tasks)
|
||||
- Git init, package.json, tsconfig, dependencies
|
||||
- JSON config module (load/save/defaults)
|
||||
- Pino logger
|
||||
- SQLite database (play_history, bot_instances)
|
||||
- Application entry point
|
||||
- Result: `npm run dev` boots and logs "TSMusicBot started"
|
||||
|
||||
- [ ] **[Phase 2: TeamSpeak 3 Protocol Layer](2026-03-29-tsmusicbot-phase2.md)** (6 tasks)
|
||||
- TS3 identity generation (Ed25519)
|
||||
- Command encoding/decoding (escape/unescape)
|
||||
- TCP connection (ServerQuery command channel)
|
||||
- UDP voice connection (Opus packet sending)
|
||||
- High-level TS3Client (connect, join channel, send text, send voice)
|
||||
- Result: Can connect to a TS server, join a channel, send/receive messages
|
||||
|
||||
- [ ] **[Phase 3: Audio Engine](2026-03-29-tsmusicbot-phase3.md)** (4 tasks)
|
||||
- Opus encoder wrapper (@discordjs/opus)
|
||||
- Play queue with 4 modes (sequential, loop, random, random-loop)
|
||||
- Audio player (FFmpeg → PCM → Opus → 20ms frames)
|
||||
- Result: Can decode any audio URL and produce timed Opus frames
|
||||
|
||||
- [ ] **[Phase 4: Music Source Service](2026-03-29-tsmusicbot-phase4.md)** (6 tasks)
|
||||
- Embedded API server launcher
|
||||
- Unified MusicProvider interface
|
||||
- NetEase Cloud Music adapter (search, playlist, lyrics, auth)
|
||||
- QQ Music adapter (search, playlist, lyrics, auth)
|
||||
- Cookie persistence store
|
||||
- Result: Can search, get song URLs, and authenticate with both platforms
|
||||
|
||||
- [ ] **[Phase 5: Bot Core & TS Command System](2026-03-29-tsmusicbot-phase5.md)** (4 tasks)
|
||||
- Command parser (prefix, aliases, flags, permissions)
|
||||
- BotInstance (ties TS3Client + AudioPlayer + MusicProvider)
|
||||
- BotManager (multi-instance lifecycle, persistence)
|
||||
- Result: Full bot that plays music via TS commands (!play, !next, etc.)
|
||||
|
||||
- [ ] **[Phase 6: Web Backend](2026-03-29-tsmusicbot-phase6.md)** (8 tasks)
|
||||
- Express + WebSocket server bootstrap
|
||||
- Bot management API (CRUD + start/stop)
|
||||
- Music search/playlist/lyrics API
|
||||
- Player control API (play, pause, queue, volume, mode)
|
||||
- Auth API (QR code, SMS, cookie)
|
||||
- WebSocket real-time state broadcasting
|
||||
- Wire everything in index.ts
|
||||
- Result: Full REST API + WebSocket backend, ready for frontend
|
||||
|
||||
- [ ] **[Phase 7: WebUI Frontend](2026-03-29-tsmusicbot-phase7.md)** (10 tasks)
|
||||
- Vue.js project scaffold (Vite, Pinia, Router)
|
||||
- SCSS theme (YesPlayMusic dark/light)
|
||||
- Pinia stores + WebSocket composable
|
||||
- Router setup
|
||||
- Navbar (frosted glass)
|
||||
- Player bar (frosted glass, controls, volume)
|
||||
- CoverArt component (colored shadow)
|
||||
- Home page (bot selector, search, playlists, now playing)
|
||||
- All page views (Search, Playlist, Lyrics, History, Settings)
|
||||
- Build and verify
|
||||
- Result: Beautiful, functional WebUI
|
||||
|
||||
- [ ] **[Phase 8: Deployment & Packaging](2026-03-29-tsmusicbot-phase8.md)** (5 tasks)
|
||||
- Windows start script (start.bat)
|
||||
- Linux one-click install script (install.sh + systemd)
|
||||
- Docker (Dockerfile + docker-compose.yml)
|
||||
- Setup Wizard (4-step first-run flow in WebUI)
|
||||
- Final build and verify
|
||||
- Result: One-click installable on Windows and Linux
|
||||
|
||||
## Total: 8 phases, 49 tasks
|
||||
|
||||
## Implementation Order
|
||||
|
||||
Phases MUST be implemented in order (1 → 2 → 3 → ... → 8). Each phase depends on the previous one.
|
||||
@@ -0,0 +1,603 @@
|
||||
# TSMusicBot Phase 1: Project Scaffold, Data Layer & Config
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Set up the project from scratch with TypeScript, build tooling, data layer (SQLite + JSON config), and logger — producing a runnable `npm start` that boots and logs "TSMusicBot started".
|
||||
|
||||
**Architecture:** Monorepo with backend (`src/`) and frontend (`web/`). This phase focuses on backend scaffold only. Express server serves a placeholder page. Data layer uses better-sqlite3 for structured data and JSON files for user-editable config.
|
||||
|
||||
**Tech Stack:** Node.js 20, TypeScript 5, Express 4, better-sqlite3, pino, vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Initialize project and install dependencies
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`
|
||||
- Create: `tsconfig.json`
|
||||
- Create: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Initialize git repo**
|
||||
|
||||
```bash
|
||||
cd "/c/Users/saopig1/Music/teamspeak music bot"
|
||||
git init
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create package.json**
|
||||
|
||||
```bash
|
||||
npm init -y
|
||||
```
|
||||
|
||||
Then edit `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "tsmusicbot",
|
||||
"version": "0.1.0",
|
||||
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install express ws better-sqlite3 pino
|
||||
npm install -D typescript tsx vitest @types/node @types/express @types/better-sqlite3 @types/ws
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create tsconfig.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "web"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create .gitignore**
|
||||
|
||||
```
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
*.db
|
||||
.env
|
||||
.superpowers/
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json tsconfig.json .gitignore package-lock.json
|
||||
git commit -m "chore: initialize project with TypeScript and dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Config module (JSON-based)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data/config.ts`
|
||||
- Create: `src/data/config.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/data/config.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { loadConfig, saveConfig, getDefaultConfig } from './config.js';
|
||||
|
||||
const TEST_CONFIG_PATH = path.join(import.meta.dirname, '../../test-config.json');
|
||||
|
||||
describe('Config', () => {
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TEST_CONFIG_PATH)) {
|
||||
fs.unlinkSync(TEST_CONFIG_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns default config when file does not exist', () => {
|
||||
const config = loadConfig(TEST_CONFIG_PATH);
|
||||
expect(config.webPort).toBe(3000);
|
||||
expect(config.locale).toBe('zh');
|
||||
expect(config.theme).toBe('dark');
|
||||
expect(config.commandPrefix).toBe('!');
|
||||
});
|
||||
|
||||
it('creates config file on save', () => {
|
||||
const config = getDefaultConfig();
|
||||
config.webPort = 8080;
|
||||
saveConfig(TEST_CONFIG_PATH, config);
|
||||
expect(fs.existsSync(TEST_CONFIG_PATH)).toBe(true);
|
||||
|
||||
const loaded = loadConfig(TEST_CONFIG_PATH);
|
||||
expect(loaded.webPort).toBe(8080);
|
||||
});
|
||||
|
||||
it('merges partial config with defaults', () => {
|
||||
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ webPort: 9000 }));
|
||||
const config = loadConfig(TEST_CONFIG_PATH);
|
||||
expect(config.webPort).toBe(9000);
|
||||
expect(config.locale).toBe('zh'); // default preserved
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/data/config.test.ts`
|
||||
Expected: FAIL with "cannot find module './config.js'"
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/data/config.ts`:
|
||||
|
||||
```typescript
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface BotConfig {
|
||||
webPort: number;
|
||||
locale: 'zh' | 'en';
|
||||
theme: 'dark' | 'light';
|
||||
commandPrefix: string;
|
||||
commandAliases: Record<string, string>;
|
||||
neteaseApiPort: number;
|
||||
qqMusicApiPort: number;
|
||||
adminPassword: string;
|
||||
adminGroups: number[];
|
||||
autoReturnDelay: number; // seconds, 0 = disabled
|
||||
autoPauseOnEmpty: boolean;
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): BotConfig {
|
||||
return {
|
||||
webPort: 3000,
|
||||
locale: 'zh',
|
||||
theme: 'dark',
|
||||
commandPrefix: '!',
|
||||
commandAliases: {
|
||||
p: 'play',
|
||||
s: 'skip',
|
||||
n: 'next',
|
||||
},
|
||||
neteaseApiPort: 3001,
|
||||
qqMusicApiPort: 3002,
|
||||
adminPassword: '',
|
||||
adminGroups: [],
|
||||
autoReturnDelay: 300,
|
||||
autoPauseOnEmpty: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(configPath: string): BotConfig {
|
||||
const defaults = getDefaultConfig();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return defaults;
|
||||
}
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const partial = JSON.parse(raw) as Partial<BotConfig>;
|
||||
return { ...defaults, ...partial };
|
||||
}
|
||||
|
||||
export function saveConfig(configPath: string, config: BotConfig): void {
|
||||
const dir = path.dirname(configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/data/config.test.ts`
|
||||
Expected: 3 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/config.ts src/data/config.test.ts
|
||||
git commit -m "feat: add JSON config module with load/save/defaults"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Logger setup
|
||||
|
||||
**Files:**
|
||||
- Create: `src/logger.ts`
|
||||
|
||||
- [ ] **Step 1: Create logger module**
|
||||
|
||||
Create `src/logger.ts`:
|
||||
|
||||
```typescript
|
||||
import pino from 'pino';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export function createLogger(logDir?: string) {
|
||||
const targets: pino.TransportTargetOptions[] = [
|
||||
{
|
||||
target: 'pino/file',
|
||||
options: { destination: 1 }, // stdout
|
||||
level: 'info',
|
||||
},
|
||||
];
|
||||
|
||||
if (logDir) {
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
targets.push({
|
||||
target: 'pino/file',
|
||||
options: { destination: path.join(logDir, 'tsmusicbot.log') },
|
||||
level: 'debug',
|
||||
});
|
||||
}
|
||||
|
||||
return pino({
|
||||
level: 'debug',
|
||||
transport: { targets },
|
||||
});
|
||||
}
|
||||
|
||||
export type Logger = pino.Logger;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/logger.ts
|
||||
git commit -m "feat: add pino logger with file and stdout transports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Database module (SQLite)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/data/database.ts`
|
||||
- Create: `src/data/database.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/data/database.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import { createDatabase, type Database } from './database.js';
|
||||
|
||||
const TEST_DB_PATH = ':memory:';
|
||||
|
||||
describe('Database', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDatabase(TEST_DB_PATH);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('creates tables on init', () => {
|
||||
const tables = db.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[];
|
||||
const names = tables.map((t) => t.name);
|
||||
expect(names).toContain('play_history');
|
||||
expect(names).toContain('bot_instances');
|
||||
});
|
||||
|
||||
it('records and retrieves play history', () => {
|
||||
db.addPlayHistory({
|
||||
botId: 'bot-1',
|
||||
songId: '12345',
|
||||
songName: '晴天',
|
||||
artist: '周杰伦',
|
||||
album: '叶惠美',
|
||||
platform: 'netease',
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
});
|
||||
|
||||
const history = db.getPlayHistory('bot-1', 10);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].songName).toBe('晴天');
|
||||
expect(history[0].platform).toBe('netease');
|
||||
});
|
||||
|
||||
it('saves and loads bot instances', () => {
|
||||
db.saveBotInstance({
|
||||
id: 'bot-1',
|
||||
name: 'Music Bot',
|
||||
serverAddress: 'ts.example.com',
|
||||
serverPort: 9987,
|
||||
nickname: 'MusicBot',
|
||||
defaultChannel: '音乐频道',
|
||||
channelPassword: '',
|
||||
autoStart: true,
|
||||
});
|
||||
|
||||
const instances = db.getBotInstances();
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].name).toBe('Music Bot');
|
||||
expect(instances[0].serverAddress).toBe('ts.example.com');
|
||||
});
|
||||
|
||||
it('deletes bot instance', () => {
|
||||
db.saveBotInstance({
|
||||
id: 'bot-1',
|
||||
name: 'Music Bot',
|
||||
serverAddress: 'ts.example.com',
|
||||
serverPort: 9987,
|
||||
nickname: 'MusicBot',
|
||||
defaultChannel: '',
|
||||
channelPassword: '',
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
db.deleteBotInstance('bot-1');
|
||||
expect(db.getBotInstances()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/data/database.test.ts`
|
||||
Expected: FAIL with "cannot find module './database.js'"
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/data/database.ts`:
|
||||
|
||||
```typescript
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
|
||||
export interface PlayHistoryEntry {
|
||||
botId: string;
|
||||
songId: string;
|
||||
songName: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: 'netease' | 'qq';
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
export interface PlayHistoryRow extends PlayHistoryEntry {
|
||||
id: number;
|
||||
playedAt: string;
|
||||
}
|
||||
|
||||
export interface BotInstanceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serverAddress: string;
|
||||
serverPort: number;
|
||||
nickname: string;
|
||||
defaultChannel: string;
|
||||
channelPassword: string;
|
||||
autoStart: boolean;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
db: BetterSqlite3.Database;
|
||||
close(): void;
|
||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||
getPlayHistory(botId: string, limit: number): PlayHistoryRow[];
|
||||
saveBotInstance(instance: BotInstanceRow): void;
|
||||
getBotInstances(): BotInstanceRow[];
|
||||
deleteBotInstance(id: string): void;
|
||||
}
|
||||
|
||||
export function createDatabase(dbPath: string): Database {
|
||||
const db = new BetterSqlite3(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS play_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
botId TEXT NOT NULL,
|
||||
songId TEXT NOT NULL,
|
||||
songName TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
coverUrl TEXT NOT NULL,
|
||||
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
serverAddress TEXT NOT NULL,
|
||||
serverPort INTEGER NOT NULL DEFAULT 9987,
|
||||
nickname TEXT NOT NULL,
|
||||
defaultChannel TEXT NOT NULL DEFAULT '',
|
||||
channelPassword TEXT NOT NULL DEFAULT '',
|
||||
autoStart INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
const stmts = {
|
||||
insertHistory: db.prepare(`
|
||||
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
|
||||
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
|
||||
`),
|
||||
getHistory: db.prepare(`
|
||||
SELECT * FROM play_history WHERE botId = ? ORDER BY playedAt DESC LIMIT ?
|
||||
`),
|
||||
upsertInstance: db.prepare(`
|
||||
INSERT OR REPLACE INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart)
|
||||
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart)
|
||||
`),
|
||||
getInstances: db.prepare(`SELECT * FROM bot_instances`),
|
||||
deleteInstance: db.prepare(`DELETE FROM bot_instances WHERE id = ?`),
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
addPlayHistory(entry: PlayHistoryEntry) {
|
||||
stmts.insertHistory.run(entry);
|
||||
},
|
||||
getPlayHistory(botId: string, limit: number): PlayHistoryRow[] {
|
||||
return stmts.getHistory.all(botId, limit) as PlayHistoryRow[];
|
||||
},
|
||||
saveBotInstance(instance: BotInstanceRow) {
|
||||
stmts.upsertInstance.run({
|
||||
...instance,
|
||||
autoStart: instance.autoStart ? 1 : 0,
|
||||
});
|
||||
},
|
||||
getBotInstances(): BotInstanceRow[] {
|
||||
const rows = stmts.getInstances.all() as (Omit<BotInstanceRow, 'autoStart'> & { autoStart: number })[];
|
||||
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 }));
|
||||
},
|
||||
deleteBotInstance(id: string) {
|
||||
stmts.deleteInstance.run(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/data/database.test.ts`
|
||||
Expected: 4 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/database.ts src/data/database.test.ts
|
||||
git commit -m "feat: add SQLite database module with play history and bot instances"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Application entry point
|
||||
|
||||
**Files:**
|
||||
- Create: `src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create the entry point**
|
||||
|
||||
Create `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig, saveConfig, getDefaultConfig } from './data/config.js';
|
||||
import { createDatabase } from './data/database.js';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
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');
|
||||
|
||||
async function main() {
|
||||
// Load config
|
||||
const config = loadConfig(CONFIG_PATH);
|
||||
|
||||
// Save config to ensure file exists with all defaults
|
||||
saveConfig(CONFIG_PATH, config);
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger(LOG_DIR);
|
||||
|
||||
// Create database
|
||||
const db = createDatabase(DB_PATH);
|
||||
|
||||
logger.info({ webPort: config.webPort }, 'TSMusicBot started');
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the application**
|
||||
|
||||
Run: `npx tsx src/index.ts`
|
||||
Expected: Log output containing "TSMusicBot started", then `data/` directory is created with `tsmusicbot.db` and `logs/`, and `config.json` is created at root.
|
||||
|
||||
- [ ] **Step 3: Verify files were created**
|
||||
|
||||
Run: `ls data/ && cat config.json`
|
||||
Expected: `tsmusicbot.db`, `logs/` directory exist. `config.json` contains default config with `webPort: 3000`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts
|
||||
git commit -m "feat: add application entry point with config, database, and logger initialization"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Run all tests and verify clean build
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass (7 tests across 2 files)
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compilation**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Final commit for Phase 1**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 1 complete — project scaffold, config, database, logger"
|
||||
```
|
||||
@@ -0,0 +1,772 @@
|
||||
# TSMusicBot Phase 2: TeamSpeak 3 Protocol Layer
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a TS3 client protocol layer that can connect to a TeamSpeak server (without encryption), authenticate, join a channel, send/receive text messages, and send Opus voice data over UDP.
|
||||
|
||||
**Architecture:** The protocol layer lives in `src/ts-protocol/`. It implements the TS3 client protocol: TCP for commands (login, channel ops, text messages) and UDP for voice packets. No encryption. The `TS3Client` class provides a high-level EventEmitter-based API for the rest of the application.
|
||||
|
||||
**Tech Stack:** Node.js net/dgram modules, tweetnacl (Ed25519 identity), vitest
|
||||
|
||||
**Reference:** TeamSpeak 3 protocol documentation and Splamy/TSLib (C#) source code for packet structures and handshake flow.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TS3 Identity generation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ts-protocol/identity.ts`
|
||||
- Create: `src/ts-protocol/identity.test.ts`
|
||||
|
||||
- [ ] **Step 1: Install tweetnacl**
|
||||
|
||||
```bash
|
||||
npm install tweetnacl
|
||||
npm install -D @types/tweetnacl
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/ts-protocol/identity.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateIdentity, exportIdentity, importIdentity } from './identity.js';
|
||||
|
||||
describe('TS3 Identity', () => {
|
||||
it('generates a valid identity with keypair', () => {
|
||||
const identity = generateIdentity();
|
||||
expect(identity.publicKey).toBeInstanceOf(Uint8Array);
|
||||
expect(identity.privateKey).toBeInstanceOf(Uint8Array);
|
||||
expect(identity.publicKey.length).toBe(32);
|
||||
expect(identity.privateKey.length).toBe(64);
|
||||
expect(identity.uid).toBeTruthy();
|
||||
expect(typeof identity.uid).toBe('string');
|
||||
});
|
||||
|
||||
it('exports and imports identity consistently', () => {
|
||||
const identity = generateIdentity();
|
||||
const exported = exportIdentity(identity);
|
||||
const imported = importIdentity(exported);
|
||||
expect(imported.uid).toBe(identity.uid);
|
||||
expect(imported.publicKey).toEqual(identity.publicKey);
|
||||
});
|
||||
|
||||
it('generates unique identities each time', () => {
|
||||
const id1 = generateIdentity();
|
||||
const id2 = generateIdentity();
|
||||
expect(id1.uid).not.toBe(id2.uid);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/ts-protocol/identity.test.ts`
|
||||
Expected: FAIL with "cannot find module"
|
||||
|
||||
- [ ] **Step 4: Write implementation**
|
||||
|
||||
Create `src/ts-protocol/identity.ts`:
|
||||
|
||||
```typescript
|
||||
import nacl from 'tweetnacl';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export interface TS3Identity {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
uid: string; // base64-encoded SHA1 of public key
|
||||
}
|
||||
|
||||
export function generateIdentity(): TS3Identity {
|
||||
const keypair = nacl.sign.keyPair();
|
||||
const uid = computeUid(keypair.publicKey);
|
||||
return {
|
||||
publicKey: keypair.publicKey,
|
||||
privateKey: keypair.secretKey,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeUid(publicKey: Uint8Array): string {
|
||||
const hash = crypto.createHash('sha1').update(publicKey).digest('base64');
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function exportIdentity(identity: TS3Identity): string {
|
||||
return JSON.stringify({
|
||||
publicKey: Buffer.from(identity.publicKey).toString('base64'),
|
||||
privateKey: Buffer.from(identity.privateKey).toString('base64'),
|
||||
});
|
||||
}
|
||||
|
||||
export function importIdentity(data: string): TS3Identity {
|
||||
const parsed = JSON.parse(data) as { publicKey: string; privateKey: string };
|
||||
const publicKey = new Uint8Array(Buffer.from(parsed.publicKey, 'base64'));
|
||||
const privateKey = new Uint8Array(Buffer.from(parsed.privateKey, 'base64'));
|
||||
const uid = computeUid(publicKey);
|
||||
return { publicKey, privateKey, uid };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/ts-protocol/identity.test.ts`
|
||||
Expected: 3 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ts-protocol/identity.ts src/ts-protocol/identity.test.ts package.json package-lock.json
|
||||
git commit -m "feat: add TS3 identity generation with Ed25519 keypairs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TS3 Command encoding/decoding
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ts-protocol/commands.ts`
|
||||
- Create: `src/ts-protocol/commands.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/ts-protocol/commands.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodeCommand, decodeResponse, escapeValue, unescapeValue } from './commands.js';
|
||||
|
||||
describe('TS3 Commands', () => {
|
||||
it('encodes a simple command', () => {
|
||||
const encoded = encodeCommand('login', { client_login_name: 'bot', client_login_password: 'pass' });
|
||||
expect(encoded).toBe('login client_login_name=bot client_login_password=pass\n');
|
||||
});
|
||||
|
||||
it('escapes special characters in values', () => {
|
||||
expect(escapeValue('hello world')).toBe('hello\\sworld');
|
||||
expect(escapeValue('foo|bar')).toBe('foo\\pbar');
|
||||
expect(escapeValue('a/b')).toBe('a\\/b');
|
||||
expect(escapeValue('line\nnew')).toBe('line\\nnew');
|
||||
});
|
||||
|
||||
it('unescapes special characters', () => {
|
||||
expect(unescapeValue('hello\\sworld')).toBe('hello world');
|
||||
expect(unescapeValue('foo\\pbar')).toBe('foo|bar');
|
||||
expect(unescapeValue('a\\/b')).toBe('a/b');
|
||||
});
|
||||
|
||||
it('decodes a single response', () => {
|
||||
const response = 'virtualserver_name=My\\sServer virtualserver_port=9987';
|
||||
const result = decodeResponse(response);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].virtualserver_name).toBe('My Server');
|
||||
expect(result[0].virtualserver_port).toBe('9987');
|
||||
});
|
||||
|
||||
it('decodes a piped multi-entry response', () => {
|
||||
const response = 'clid=1 client_nickname=User1|clid=2 client_nickname=User2';
|
||||
const result = decodeResponse(response);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].client_nickname).toBe('User1');
|
||||
expect(result[1].client_nickname).toBe('User2');
|
||||
});
|
||||
|
||||
it('handles command with no params', () => {
|
||||
const encoded = encodeCommand('quit', {});
|
||||
expect(encoded).toBe('quit\n');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/ts-protocol/commands.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/ts-protocol/commands.ts`:
|
||||
|
||||
```typescript
|
||||
const ESCAPE_MAP: [string, string][] = [
|
||||
['\\', '\\\\'],
|
||||
['/', '\\/'],
|
||||
[' ', '\\s'],
|
||||
['|', '\\p'],
|
||||
['\n', '\\n'],
|
||||
['\r', '\\r'],
|
||||
['\t', '\\t'],
|
||||
['\x07', '\\a'], // bell
|
||||
['\x08', '\\b'], // backspace
|
||||
['\x0C', '\\f'], // form feed
|
||||
['\x0B', '\\v'], // vertical tab
|
||||
];
|
||||
|
||||
const UNESCAPE_MAP: [string, string][] = ESCAPE_MAP.map(([plain, escaped]) => [escaped, plain]).reverse();
|
||||
|
||||
export function escapeValue(value: string): string {
|
||||
let result = value;
|
||||
for (const [plain, escaped] of ESCAPE_MAP) {
|
||||
result = result.replaceAll(plain, escaped);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function unescapeValue(value: string): string {
|
||||
let result = value;
|
||||
for (const [escaped, plain] of UNESCAPE_MAP) {
|
||||
result = result.replaceAll(escaped, plain);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function encodeCommand(command: string, params: Record<string, string | number>): string {
|
||||
const parts = [command];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
parts.push(`${key}=${escapeValue(String(value))}`);
|
||||
}
|
||||
return parts.join(' ') + '\n';
|
||||
}
|
||||
|
||||
export function decodeResponse(raw: string): Record<string, string>[] {
|
||||
const entries = raw.split('|');
|
||||
return entries.map((entry) => {
|
||||
const result: Record<string, string> = {};
|
||||
const pairs = entry.trim().split(' ');
|
||||
for (const pair of pairs) {
|
||||
const eqIndex = pair.indexOf('=');
|
||||
if (eqIndex === -1) {
|
||||
result[pair] = '';
|
||||
} else {
|
||||
const key = pair.substring(0, eqIndex);
|
||||
const value = unescapeValue(pair.substring(eqIndex + 1));
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export interface TS3Response {
|
||||
errorId: number;
|
||||
errorMessage: string;
|
||||
data: Record<string, string>[];
|
||||
}
|
||||
|
||||
export function parseErrorLine(line: string): { id: number; msg: string } {
|
||||
const decoded = decodeResponse(line.replace(/^error\s+/, ''))[0];
|
||||
return {
|
||||
id: parseInt(decoded.id ?? '0', 10),
|
||||
msg: decoded.msg ?? 'ok',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/ts-protocol/commands.test.ts`
|
||||
Expected: 6 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ts-protocol/commands.ts src/ts-protocol/commands.test.ts
|
||||
git commit -m "feat: add TS3 command encoding/decoding with escape handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: TCP Connection (ServerQuery-style command channel)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ts-protocol/connection.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/ts-protocol/connection.ts`:
|
||||
|
||||
```typescript
|
||||
import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { encodeCommand, decodeResponse, parseErrorLine } from './commands.js';
|
||||
|
||||
export interface ConnectionOptions {
|
||||
host: string;
|
||||
port: number; // ServerQuery port, typically 10011
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
errorId: number;
|
||||
errorMessage: string;
|
||||
data: Record<string, string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TCP connection to TeamSpeak 3 server.
|
||||
* Handles the ServerQuery text protocol for command/control operations.
|
||||
* Voice data is handled separately via UDP (see voice.ts).
|
||||
*/
|
||||
export class TS3Connection extends EventEmitter {
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer = '';
|
||||
private commandQueue: Array<{
|
||||
resolve: (result: CommandResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
}> = [];
|
||||
private connected = false;
|
||||
private responseLines: string[] = [];
|
||||
|
||||
constructor(private options: ConnectionOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = net.createConnection(this.options.port, this.options.host, () => {
|
||||
this.connected = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.setEncoding('utf-8');
|
||||
this.socket.on('data', (data: string) => this.handleData(data));
|
||||
this.socket.on('error', (err) => {
|
||||
if (!this.connected) {
|
||||
reject(err);
|
||||
}
|
||||
this.emit('error', err);
|
||||
});
|
||||
this.socket.on('close', () => {
|
||||
this.connected = false;
|
||||
this.emit('close');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleData(data: string): void {
|
||||
this.buffer += data;
|
||||
const lines = this.buffer.split('\n\r');
|
||||
|
||||
// Keep the incomplete last part in buffer
|
||||
this.buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Skip welcome messages (TS3 banner)
|
||||
if (trimmed.startsWith('TS3') || trimmed.startsWith('Welcome')) continue;
|
||||
|
||||
// Notifications start with "notify"
|
||||
if (trimmed.startsWith('notify')) {
|
||||
this.handleNotification(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Error line completes a command response
|
||||
if (trimmed.startsWith('error ')) {
|
||||
const error = parseErrorLine(trimmed);
|
||||
const pending = this.commandQueue.shift();
|
||||
if (pending) {
|
||||
pending.resolve({
|
||||
errorId: error.id,
|
||||
errorMessage: error.msg,
|
||||
data: this.responseLines.length > 0
|
||||
? decodeResponse(this.responseLines.join('\n'))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
this.responseLines = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Data line (part of command response)
|
||||
this.responseLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(line: string): void {
|
||||
// Extract notification name: "notifytextmessage" -> "textmessage"
|
||||
const spaceIndex = line.indexOf(' ');
|
||||
const notifyPart = spaceIndex === -1 ? line : line.substring(0, spaceIndex);
|
||||
const eventName = notifyPart.replace(/^notify/, '');
|
||||
const data = spaceIndex === -1 ? {} : decodeResponse(line.substring(spaceIndex + 1))[0];
|
||||
this.emit('notify', eventName, data);
|
||||
this.emit(`notify:${eventName}`, data);
|
||||
}
|
||||
|
||||
async send(command: string, params: Record<string, string | number> = {}): Promise<CommandResult> {
|
||||
if (!this.socket || !this.connected) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
const encoded = encodeCommand(command, params);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.commandQueue.push({ resolve, reject });
|
||||
this.socket!.write(encoded);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ts-protocol/connection.ts
|
||||
git commit -m "feat: add TCP connection with command queue and notification handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: UDP Voice channel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ts-protocol/voice.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/ts-protocol/voice.ts`:
|
||||
|
||||
```typescript
|
||||
import dgram from 'node:dgram';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
/**
|
||||
* TS3 voice packet structure (simplified, no encryption):
|
||||
*
|
||||
* Header (variable):
|
||||
* - packetId: uint16 (2 bytes)
|
||||
* - clientId: uint16 (2 bytes) — assigned by server after login
|
||||
* - packetType: uint8 (1 byte) — 0=voice, 1=voice_whisper, etc.
|
||||
* - flags: uint8 (1 byte)
|
||||
*
|
||||
* Payload:
|
||||
* - codec: uint8 (1 byte) — 4 = Opus Voice, 5 = Opus Music
|
||||
* - audioData: Opus-encoded frame
|
||||
*/
|
||||
|
||||
export interface VoiceOptions {
|
||||
host: string;
|
||||
port: number; // same as virtual server port, typically 9987
|
||||
}
|
||||
|
||||
export const CODEC_OPUS_VOICE = 4;
|
||||
export const CODEC_OPUS_MUSIC = 5;
|
||||
|
||||
export class VoiceConnection extends EventEmitter {
|
||||
private socket: dgram.Socket | null = null;
|
||||
private packetCounter = 0;
|
||||
private clientId = 0;
|
||||
|
||||
constructor(private options: VoiceOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
setClientId(id: number): void {
|
||||
this.clientId = id;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.socket = dgram.createSocket('udp4');
|
||||
|
||||
this.socket.on('message', (msg) => {
|
||||
this.emit('voiceData', msg);
|
||||
});
|
||||
|
||||
this.socket.on('error', (err) => {
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
// UDP is connectionless, but we "connect" to set default destination
|
||||
this.socket.connect(this.options.port, this.options.host, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an Opus-encoded audio frame to the TS3 server.
|
||||
* Frame should be 20ms of Opus-encoded audio (48kHz, mono or stereo).
|
||||
*/
|
||||
sendVoicePacket(opusData: Buffer, codec: number = CODEC_OPUS_MUSIC): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
const packetId = this.packetCounter++;
|
||||
if (this.packetCounter > 0xFFFF) this.packetCounter = 0;
|
||||
|
||||
// Build packet header
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt16BE(packetId, 0); // packet id
|
||||
header.writeUInt16BE(this.clientId, 2); // client id
|
||||
header.writeUInt8(codec, 4); // codec type
|
||||
|
||||
const packet = Buffer.concat([header, opusData]);
|
||||
this.socket.send(packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a keepalive/ping packet to maintain the UDP connection.
|
||||
*/
|
||||
sendKeepAlive(): void {
|
||||
if (!this.socket) return;
|
||||
const ping = Buffer.alloc(4);
|
||||
ping.writeUInt16BE(this.packetCounter++, 0);
|
||||
ping.writeUInt16BE(this.clientId, 2);
|
||||
if (this.packetCounter > 0xFFFF) this.packetCounter = 0;
|
||||
this.socket.send(ping);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ts-protocol/voice.ts
|
||||
git commit -m "feat: add UDP voice connection for sending Opus audio packets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: High-level TS3 Client
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ts-protocol/client.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/ts-protocol/client.ts`:
|
||||
|
||||
```typescript
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { TS3Connection, type CommandResult } from './connection.js';
|
||||
import { VoiceConnection, CODEC_OPUS_MUSIC } from './voice.js';
|
||||
import { generateIdentity, importIdentity, exportIdentity, type TS3Identity } from './identity.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
export interface TS3ClientOptions {
|
||||
host: string;
|
||||
port: number; // Voice/virtual server port (default 9987)
|
||||
queryPort: number; // ServerQuery port (default 10011)
|
||||
nickname: string;
|
||||
identity?: string; // Exported identity JSON, or undefined to generate new
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
}
|
||||
|
||||
export interface TS3TextMessage {
|
||||
invokerName: string;
|
||||
invokerId: string;
|
||||
invokerUid: string;
|
||||
message: string;
|
||||
targetMode: number; // 1=private, 2=channel, 3=server
|
||||
}
|
||||
|
||||
export class TS3Client extends EventEmitter {
|
||||
private connection: TS3Connection;
|
||||
private voice: VoiceConnection;
|
||||
private identity: TS3Identity;
|
||||
private clientId = 0;
|
||||
private keepAliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(private options: TS3ClientOptions, logger: Logger) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
|
||||
this.connection = new TS3Connection({
|
||||
host: options.host,
|
||||
port: options.queryPort,
|
||||
});
|
||||
|
||||
this.voice = new VoiceConnection({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
});
|
||||
|
||||
if (options.identity) {
|
||||
this.identity = importIdentity(options.identity);
|
||||
} else {
|
||||
this.identity = generateIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.logger.info({ host: this.options.host, port: this.options.port }, 'Connecting to TeamSpeak server');
|
||||
|
||||
// Connect TCP (ServerQuery)
|
||||
await this.connection.connect();
|
||||
this.logger.debug('TCP connection established');
|
||||
|
||||
// Select virtual server by port
|
||||
await this.sendCommand('use', { port: this.options.port });
|
||||
|
||||
// Login with client nickname
|
||||
const loginResult = await this.sendCommand('clientupdate', {
|
||||
client_nickname: this.options.nickname,
|
||||
});
|
||||
|
||||
// Get own client ID
|
||||
const whoami = await this.sendCommand('whoami', {});
|
||||
if (whoami.data.length > 0) {
|
||||
this.clientId = parseInt(whoami.data[0].client_id ?? '0', 10);
|
||||
this.logger.info({ clientId: this.clientId }, 'Logged in');
|
||||
}
|
||||
|
||||
// Join default channel if specified
|
||||
if (this.options.defaultChannel) {
|
||||
await this.joinChannel(this.options.defaultChannel, this.options.channelPassword);
|
||||
}
|
||||
|
||||
// Connect UDP voice
|
||||
await this.voice.connect();
|
||||
this.voice.setClientId(this.clientId);
|
||||
this.logger.debug('UDP voice connection established');
|
||||
|
||||
// Register for text message notifications
|
||||
await this.sendCommand('servernotifyregister', { event: 'textchannel' });
|
||||
await this.sendCommand('servernotifyregister', { event: 'textprivate' });
|
||||
|
||||
// Listen for text messages
|
||||
this.connection.on('notify:textmessage', (data: Record<string, string>) => {
|
||||
const msg: TS3TextMessage = {
|
||||
invokerName: data.invokername ?? '',
|
||||
invokerId: data.invokerid ?? '',
|
||||
invokerUid: data.invokeruid ?? '',
|
||||
message: data.msg ?? '',
|
||||
targetMode: parseInt(data.targetmode ?? '0', 10),
|
||||
};
|
||||
this.emit('textMessage', msg);
|
||||
});
|
||||
|
||||
// Start keepalive
|
||||
this.keepAliveInterval = setInterval(() => {
|
||||
this.voice.sendKeepAlive();
|
||||
}, 5000);
|
||||
|
||||
this.connection.on('close', () => {
|
||||
this.logger.warn('Connection closed');
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this.emit('connected');
|
||||
}
|
||||
|
||||
async sendCommand(command: string, params: Record<string, string | number>): Promise<CommandResult> {
|
||||
return this.connection.send(command, params);
|
||||
}
|
||||
|
||||
async joinChannel(channelName: string, password?: string): Promise<void> {
|
||||
// Find channel by name
|
||||
const channels = await this.sendCommand('channellist', {});
|
||||
const channel = channels.data.find(
|
||||
(ch) => ch.channel_name === channelName
|
||||
);
|
||||
|
||||
if (!channel) {
|
||||
this.logger.warn({ channelName }, 'Channel not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Record<string, string | number> = {
|
||||
cid: channel.cid,
|
||||
clid: this.clientId,
|
||||
};
|
||||
if (password) {
|
||||
params.cpw = password;
|
||||
}
|
||||
|
||||
await this.sendCommand('clientmove', params);
|
||||
this.logger.info({ channelName, cid: channel.cid }, 'Joined channel');
|
||||
}
|
||||
|
||||
async moveToChannel(channelNameOrId: string, password?: string): Promise<void> {
|
||||
await this.joinChannel(channelNameOrId, password);
|
||||
}
|
||||
|
||||
async sendTextMessage(message: string, targetMode: number = 2): Promise<void> {
|
||||
// targetMode: 1=private, 2=channel, 3=server
|
||||
await this.sendCommand('sendtextmessage', {
|
||||
targetmode: targetMode,
|
||||
target: targetMode === 2 ? 0 : this.clientId, // 0 = current channel for mode 2
|
||||
msg: message,
|
||||
});
|
||||
}
|
||||
|
||||
async getClientsInChannel(): Promise<Record<string, string>[]> {
|
||||
const result = await this.sendCommand('clientlist', {});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
sendVoiceData(opusFrame: Buffer): void {
|
||||
this.voice.sendVoicePacket(opusFrame, CODEC_OPUS_MUSIC);
|
||||
}
|
||||
|
||||
getIdentityExport(): string {
|
||||
return exportIdentity(this.identity);
|
||||
}
|
||||
|
||||
getClientId(): number {
|
||||
return this.clientId;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval);
|
||||
this.keepAliveInterval = null;
|
||||
}
|
||||
this.connection.disconnect();
|
||||
this.voice.disconnect();
|
||||
this.logger.info('Disconnected from TeamSpeak server');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ts-protocol/client.ts
|
||||
git commit -m "feat: add high-level TS3Client with connect, channel, text message, and voice API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify TypeScript compilation
|
||||
|
||||
- [ ] **Step 1: Run tsc**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 2: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass (identity: 3, commands: 6, config: 3, database: 4 = 16 tests)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 2 complete — TS3 protocol layer (identity, commands, TCP, UDP, client)"
|
||||
```
|
||||
@@ -0,0 +1,591 @@
|
||||
# TSMusicBot Phase 3: Audio Engine
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the audio pipeline: FFmpeg decodes audio from a URL → PCM → Opus encoding → 20ms frames ready to send via TS3 voice. Plus a play queue with 4 modes (sequential, loop, random, random-loop).
|
||||
|
||||
**Architecture:** `src/audio/encoder.ts` wraps Opus encoding, `src/audio/player.ts` orchestrates FFmpeg → Opus → timed frame emission, `src/audio/queue.ts` manages the song queue with playback modes.
|
||||
|
||||
**Tech Stack:** FFmpeg (child_process), @discordjs/opus, vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Opus encoder wrapper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/audio/encoder.ts`
|
||||
- Create: `src/audio/encoder.test.ts`
|
||||
|
||||
- [ ] **Step 1: Install opus dependency**
|
||||
|
||||
```bash
|
||||
npm install @discordjs/opus
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `src/audio/encoder.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createOpusEncoder } from './encoder.js';
|
||||
|
||||
describe('OpusEncoder', () => {
|
||||
it('encodes PCM buffer to Opus frame', () => {
|
||||
const encoder = createOpusEncoder();
|
||||
// 20ms of silence at 48kHz stereo = 960 frames * 2 channels * 2 bytes = 3840 bytes
|
||||
const silence = Buffer.alloc(3840, 0);
|
||||
const opus = encoder.encode(silence);
|
||||
expect(opus).toBeInstanceOf(Buffer);
|
||||
expect(opus.length).toBeGreaterThan(0);
|
||||
expect(opus.length).toBeLessThan(3840); // compressed should be smaller
|
||||
});
|
||||
|
||||
it('decodes Opus frame back to PCM', () => {
|
||||
const encoder = createOpusEncoder();
|
||||
const silence = Buffer.alloc(3840, 0);
|
||||
const opus = encoder.encode(silence);
|
||||
const pcm = encoder.decode(opus);
|
||||
expect(pcm).toBeInstanceOf(Buffer);
|
||||
expect(pcm.length).toBe(3840);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/audio/encoder.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 4: Write implementation**
|
||||
|
||||
Create `src/audio/encoder.ts`:
|
||||
|
||||
```typescript
|
||||
import { OpusEncoder } from '@discordjs/opus';
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
const FRAME_DURATION_MS = 20;
|
||||
export const FRAME_SIZE = (SAMPLE_RATE * FRAME_DURATION_MS) / 1000; // 960 samples
|
||||
export const PCM_FRAME_BYTES = FRAME_SIZE * CHANNELS * 2; // 3840 bytes (16-bit stereo)
|
||||
|
||||
export interface Encoder {
|
||||
encode(pcm: Buffer): Buffer;
|
||||
decode(opus: Buffer): Buffer;
|
||||
}
|
||||
|
||||
export function createOpusEncoder(): Encoder {
|
||||
const opus = new OpusEncoder(SAMPLE_RATE, CHANNELS);
|
||||
|
||||
return {
|
||||
encode(pcm: Buffer): Buffer {
|
||||
return opus.encode(pcm);
|
||||
},
|
||||
decode(opusData: Buffer): Buffer {
|
||||
return opus.decode(opusData);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/audio/encoder.test.ts`
|
||||
Expected: 2 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/audio/encoder.ts src/audio/encoder.test.ts package.json package-lock.json
|
||||
git commit -m "feat: add Opus encoder/decoder wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Play queue with modes
|
||||
|
||||
**Files:**
|
||||
- Create: `src/audio/queue.ts`
|
||||
- Create: `src/audio/queue.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/audio/queue.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { PlayQueue, type QueuedSong, PlayMode } from './queue.js';
|
||||
|
||||
function makeSong(id: string, name: string = id): QueuedSong {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
platform: 'netease',
|
||||
url: `https://example.com/${id}.mp3`,
|
||||
coverUrl: `https://example.com/${id}.jpg`,
|
||||
duration: 240,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayQueue', () => {
|
||||
let queue: PlayQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new PlayQueue();
|
||||
});
|
||||
|
||||
it('starts empty', () => {
|
||||
expect(queue.isEmpty()).toBe(true);
|
||||
expect(queue.current()).toBeNull();
|
||||
expect(queue.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('adds and retrieves songs', () => {
|
||||
queue.add(makeSong('1', 'Song A'));
|
||||
queue.add(makeSong('2', 'Song B'));
|
||||
expect(queue.size()).toBe(2);
|
||||
expect(queue.list()[0].name).toBe('Song A');
|
||||
expect(queue.list()[1].name).toBe('Song B');
|
||||
});
|
||||
|
||||
it('plays first song when starting', () => {
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.play();
|
||||
expect(queue.current()?.id).toBe('1');
|
||||
});
|
||||
|
||||
it('advances to next song in sequential mode', () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.add(makeSong('3'));
|
||||
queue.play();
|
||||
expect(queue.current()?.id).toBe('1');
|
||||
const next = queue.next();
|
||||
expect(next?.id).toBe('2');
|
||||
expect(queue.current()?.id).toBe('2');
|
||||
});
|
||||
|
||||
it('returns null at end in sequential mode', () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong('1'));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('loops in loop mode', () => {
|
||||
queue.setMode(PlayMode.Loop);
|
||||
queue.add(makeSong('1'));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next?.id).toBe('1'); // loops back
|
||||
});
|
||||
|
||||
it('goes to previous song', () => {
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.play();
|
||||
queue.next();
|
||||
expect(queue.current()?.id).toBe('2');
|
||||
queue.prev();
|
||||
expect(queue.current()?.id).toBe('1');
|
||||
});
|
||||
|
||||
it('removes song by index', () => {
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.add(makeSong('3'));
|
||||
queue.remove(1); // remove index 1 ("2")
|
||||
expect(queue.size()).toBe(2);
|
||||
expect(queue.list()[1].id).toBe('3');
|
||||
});
|
||||
|
||||
it('clears all songs', () => {
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.clear();
|
||||
expect(queue.isEmpty()).toBe(true);
|
||||
expect(queue.current()).toBeNull();
|
||||
});
|
||||
|
||||
it('random mode returns a song', () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.add(makeSong('3'));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it('random-loop mode never returns null', () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong('1'));
|
||||
queue.play();
|
||||
// Should always return a song
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(queue.next()).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('playAt jumps to specific index', () => {
|
||||
queue.add(makeSong('1'));
|
||||
queue.add(makeSong('2'));
|
||||
queue.add(makeSong('3'));
|
||||
queue.playAt(2);
|
||||
expect(queue.current()?.id).toBe('3');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/audio/queue.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/audio/queue.ts`:
|
||||
|
||||
```typescript
|
||||
export enum PlayMode {
|
||||
Sequential = 'seq',
|
||||
Loop = 'loop',
|
||||
Random = 'random',
|
||||
RandomLoop = 'rloop',
|
||||
}
|
||||
|
||||
export interface QueuedSong {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: 'netease' | 'qq';
|
||||
url: string;
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
}
|
||||
|
||||
export class PlayQueue {
|
||||
private songs: QueuedSong[] = [];
|
||||
private currentIndex = -1;
|
||||
private mode: PlayMode = PlayMode.Sequential;
|
||||
|
||||
add(song: QueuedSong): void {
|
||||
this.songs.push(song);
|
||||
}
|
||||
|
||||
addMany(songs: QueuedSong[]): void {
|
||||
this.songs.push(...songs);
|
||||
}
|
||||
|
||||
remove(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
const [removed] = this.songs.splice(index, 1);
|
||||
|
||||
// Adjust current index if needed
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (index === this.currentIndex) {
|
||||
// Current song was removed, keep index but it now points to next song
|
||||
if (this.currentIndex >= this.songs.length) {
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.songs = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
play(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
this.currentIndex = 0;
|
||||
return this.songs[0];
|
||||
}
|
||||
|
||||
playAt(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
this.currentIndex = index;
|
||||
return this.songs[index];
|
||||
}
|
||||
|
||||
next(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
|
||||
switch (this.mode) {
|
||||
case PlayMode.Sequential: {
|
||||
const nextIndex = this.currentIndex + 1;
|
||||
if (nextIndex >= this.songs.length) return null;
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.Loop: {
|
||||
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
case PlayMode.Random: {
|
||||
if (this.songs.length === 1) return this.songs[0];
|
||||
let nextIndex: number;
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.RandomLoop: {
|
||||
// Same as Random but never returns null
|
||||
if (this.songs.length === 1) {
|
||||
this.currentIndex = 0;
|
||||
return this.songs[0];
|
||||
}
|
||||
let idx: number;
|
||||
do {
|
||||
idx = Math.floor(Math.random() * this.songs.length);
|
||||
} while (idx === this.currentIndex);
|
||||
this.currentIndex = idx;
|
||||
return this.songs[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prev(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
const prevIndex = this.currentIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
this.currentIndex = this.songs.length - 1; // wrap to end
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
current(): QueuedSong | null {
|
||||
if (this.currentIndex < 0 || this.currentIndex >= this.songs.length) return null;
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
list(): QueuedSong[] {
|
||||
return [...this.songs];
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.songs.length;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.songs.length === 0;
|
||||
}
|
||||
|
||||
getMode(): PlayMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
setMode(mode: PlayMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/audio/queue.test.ts`
|
||||
Expected: 12 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/audio/queue.ts src/audio/queue.test.ts
|
||||
git commit -m "feat: add play queue with sequential, loop, random, and random-loop modes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Audio player (FFmpeg → Opus → timed frames)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/audio/player.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/audio/player.ts`:
|
||||
|
||||
```typescript
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from './encoder.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
export interface PlayerEvents {
|
||||
frame: (opusFrame: Buffer) => void;
|
||||
trackEnd: () => void;
|
||||
error: (err: Error) => void;
|
||||
}
|
||||
|
||||
export type PlayerState = 'idle' | 'playing' | 'paused';
|
||||
|
||||
export class AudioPlayer extends EventEmitter {
|
||||
private ffmpeg: ChildProcess | null = null;
|
||||
private encoder: Encoder;
|
||||
private state: PlayerState = 'idle';
|
||||
private volume = 75; // 0-100
|
||||
private frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pcmBuffer: Buffer = Buffer.alloc(0);
|
||||
private logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super();
|
||||
this.encoder = createOpusEncoder();
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playing audio from a URL or file path.
|
||||
* FFmpeg decodes to raw PCM (48kHz, stereo, s16le).
|
||||
*/
|
||||
play(url: string): void {
|
||||
this.stop();
|
||||
|
||||
this.logger.info({ url }, 'Starting playback');
|
||||
|
||||
this.ffmpeg = spawn('ffmpeg', [
|
||||
'-reconnect', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '5',
|
||||
'-i', url,
|
||||
'-f', 's16le', // raw PCM output
|
||||
'-ar', '48000', // 48kHz sample rate
|
||||
'-ac', '2', // stereo
|
||||
'-af', `volume=${this.volume / 100}`,
|
||||
'-', // output to stdout
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
this.ffmpeg.stdout!.on('data', (chunk: Buffer) => {
|
||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||
});
|
||||
|
||||
this.ffmpeg.on('close', (code) => {
|
||||
this.logger.debug({ code }, 'FFmpeg process closed');
|
||||
if (this.state === 'playing') {
|
||||
// Flush remaining frames
|
||||
this.flushRemainingFrames();
|
||||
this.state = 'idle';
|
||||
this.emit('trackEnd');
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on('error', (err) => {
|
||||
this.logger.error({ err }, 'FFmpeg error');
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
this.state = 'playing';
|
||||
|
||||
// Send frames every 20ms
|
||||
this.frameTimer = setInterval(() => {
|
||||
if (this.state !== 'playing') return;
|
||||
this.sendNextFrame();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
private sendNextFrame(): void {
|
||||
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
||||
|
||||
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
||||
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
||||
|
||||
const opusFrame = this.encoder.encode(Buffer.from(pcmFrame));
|
||||
this.emit('frame', opusFrame);
|
||||
}
|
||||
|
||||
private flushRemainingFrames(): void {
|
||||
while (this.pcmBuffer.length >= PCM_FRAME_BYTES) {
|
||||
this.sendNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.state === 'playing') {
|
||||
this.state = 'paused';
|
||||
this.logger.debug('Playback paused');
|
||||
}
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.state === 'paused') {
|
||||
this.state = 'playing';
|
||||
this.logger.debug('Playback resumed');
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.frameTimer) {
|
||||
clearInterval(this.frameTimer);
|
||||
this.frameTimer = null;
|
||||
}
|
||||
if (this.ffmpeg) {
|
||||
this.ffmpeg.kill('SIGTERM');
|
||||
this.ffmpeg = null;
|
||||
}
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.state = 'idle';
|
||||
}
|
||||
|
||||
setVolume(vol: number): void {
|
||||
this.volume = Math.max(0, Math.min(100, vol));
|
||||
// Volume change takes effect on next track.
|
||||
// For live volume change, we'd need to restart FFmpeg or use a PCM gain stage.
|
||||
// TODO: implement PCM-level volume adjustment for live changes
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/audio/player.ts
|
||||
git commit -m "feat: add audio player with FFmpeg decode, Opus encode, and 20ms frame timing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify compilation and all tests
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass (encoder: 2, queue: 12, identity: 3, commands: 6, config: 3, database: 4 = 30 tests)
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compilation**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 3 complete — audio engine (Opus encoder, play queue, FFmpeg player)"
|
||||
```
|
||||
@@ -0,0 +1,812 @@
|
||||
# TSMusicBot Phase 4: Music Source Service
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Integrate NetEase Cloud Music and QQ Music APIs as embedded services, wrapped behind a unified music provider interface. Support search, song URL retrieval, playlists, albums, lyrics, and authentication (QR code, SMS, Cookie).
|
||||
|
||||
**Architecture:** `src/music/api-server.ts` manages spawning the two API server processes. `src/music/provider.ts` defines the unified interface. `src/music/netease.ts` and `src/music/qq.ts` implement it for each platform. `src/music/auth.ts` handles login flows.
|
||||
|
||||
**Tech Stack:** NeteaseCloudMusicApi (npm), QQMusicApi (npm or git), axios, vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Embedded API server launcher
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/api-server.ts`
|
||||
|
||||
- [ ] **Step 1: Install music API dependencies**
|
||||
|
||||
```bash
|
||||
npm install NeteaseCloudMusicApi axios
|
||||
```
|
||||
|
||||
Note: QQ Music API may need to be installed from a git repository. Check the latest source:
|
||||
|
||||
```bash
|
||||
npm install qq-music-api || echo "Will implement QQ Music API wrapper manually if no npm package exists"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write implementation**
|
||||
|
||||
Create `src/music/api-server.ts`:
|
||||
|
||||
```typescript
|
||||
import { fork, type ChildProcess } from 'node:child_process';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
export interface ApiServerOptions {
|
||||
neteasePort: number;
|
||||
qqMusicPort: number;
|
||||
}
|
||||
|
||||
export interface ApiServerManager {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
getNeteaseBaseUrl(): string;
|
||||
getQQMusicBaseUrl(): string;
|
||||
}
|
||||
|
||||
export function createApiServerManager(options: ApiServerOptions, logger: Logger): ApiServerManager {
|
||||
let neteaseReady = false;
|
||||
let qqMusicReady = false;
|
||||
|
||||
const neteaseBaseUrl = `http://127.0.0.1:${options.neteasePort}`;
|
||||
const qqMusicBaseUrl = `http://127.0.0.1:${options.qqMusicPort}`;
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
logger.info('Starting embedded music API servers...');
|
||||
|
||||
// Start NetEase Cloud Music API
|
||||
try {
|
||||
// NeteaseCloudMusicApi can be started programmatically
|
||||
const { default: ncmApi } = await import('NeteaseCloudMusicApi');
|
||||
// The API exposes a start function or we use it as an Express middleware
|
||||
// Depending on the version, we use the appropriate start method
|
||||
logger.info({ port: options.neteasePort }, 'NetEase Cloud Music API starting');
|
||||
neteaseReady = true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to start NetEase Cloud Music API');
|
||||
}
|
||||
|
||||
// Start QQ Music API (if available)
|
||||
try {
|
||||
logger.info({ port: options.qqMusicPort }, 'QQ Music API starting');
|
||||
qqMusicReady = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'QQ Music API not available, QQ Music features will be disabled');
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
logger.info('Stopping music API servers');
|
||||
neteaseReady = false;
|
||||
qqMusicReady = false;
|
||||
},
|
||||
|
||||
getNeteaseBaseUrl(): string {
|
||||
return neteaseBaseUrl;
|
||||
},
|
||||
|
||||
getQQMusicBaseUrl(): string {
|
||||
return qqMusicBaseUrl;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/api-server.ts package.json package-lock.json
|
||||
git commit -m "feat: add embedded music API server manager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Unified music provider interface
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/provider.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/music/provider.ts`:
|
||||
|
||||
```typescript
|
||||
export interface Song {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number; // seconds
|
||||
coverUrl: string;
|
||||
platform: 'netease' | 'qq';
|
||||
}
|
||||
|
||||
export interface SongWithUrl extends Song {
|
||||
url: string; // playable audio URL
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: 'netease' | 'qq';
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
coverUrl: string;
|
||||
songCount: number;
|
||||
platform: 'netease' | 'qq';
|
||||
}
|
||||
|
||||
export interface LyricLine {
|
||||
time: number; // seconds
|
||||
text: string;
|
||||
translation?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
songs: Song[];
|
||||
playlists: Playlist[];
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export interface QrCodeResult {
|
||||
qrUrl: string; // URL to encode as QR
|
||||
key: string; // key to poll status
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
loggedIn: boolean;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface MusicProvider {
|
||||
readonly platform: 'netease' | 'qq';
|
||||
|
||||
// Search
|
||||
search(query: string, limit?: number): Promise<SearchResult>;
|
||||
|
||||
// Song operations
|
||||
getSongUrl(songId: string): Promise<string | null>;
|
||||
getSongDetail(songId: string): Promise<Song | null>;
|
||||
|
||||
// Playlist operations
|
||||
getPlaylistSongs(playlistId: string): Promise<Song[]>;
|
||||
getRecommendPlaylists(): Promise<Playlist[]>;
|
||||
|
||||
// Album operations
|
||||
getAlbumSongs(albumId: string): Promise<Song[]>;
|
||||
|
||||
// Lyrics
|
||||
getLyrics(songId: string): Promise<LyricLine[]>;
|
||||
|
||||
// Authentication
|
||||
getQrCode(): Promise<QrCodeResult>;
|
||||
checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'>;
|
||||
loginWithSms?(phone: string, code: string): Promise<boolean>;
|
||||
sendSmsCode?(phone: string): Promise<boolean>;
|
||||
setCookie(cookie: string): void;
|
||||
getCookie(): string;
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
|
||||
// Personal FM (netease only)
|
||||
getPersonalFm?(): Promise<Song[]>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/provider.ts
|
||||
git commit -m "feat: add unified MusicProvider interface with search, playlist, auth, and lyrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: NetEase Cloud Music adapter
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/netease.ts`
|
||||
- Create: `src/music/netease.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/music/netease.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseLyrics } from './netease.js';
|
||||
|
||||
describe('NetEase adapter', () => {
|
||||
it('parses LRC format lyrics', () => {
|
||||
const lrc = `[00:00.00] 作词 : 周杰伦
|
||||
[00:01.00] 作曲 : 周杰伦
|
||||
[00:12.50]故事的小黄花
|
||||
[00:15.80]从出生那年就飘着`;
|
||||
|
||||
const lines = parseLyrics(lrc);
|
||||
expect(lines).toHaveLength(2); // skip metadata lines
|
||||
expect(lines[0].time).toBeCloseTo(12.5, 1);
|
||||
expect(lines[0].text).toBe('故事的小黄花');
|
||||
expect(lines[1].time).toBeCloseTo(15.8, 1);
|
||||
expect(lines[1].text).toBe('从出生那年就飘着');
|
||||
});
|
||||
|
||||
it('handles empty lyrics', () => {
|
||||
const lines = parseLyrics('');
|
||||
expect(lines).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('merges translation lyrics', () => {
|
||||
const lrc = '[00:12.50]Hello world';
|
||||
const tlyric = '[00:12.50]你好世界';
|
||||
const lines = parseLyrics(lrc, tlyric);
|
||||
expect(lines[0].text).toBe('Hello world');
|
||||
expect(lines[0].translation).toBe('你好世界');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/music/netease.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/music/netease.ts`:
|
||||
|
||||
```typescript
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import type {
|
||||
MusicProvider, Song, SongWithUrl, Playlist, Album,
|
||||
LyricLine, SearchResult, QrCodeResult, AuthStatus,
|
||||
} from './provider.js';
|
||||
|
||||
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
|
||||
if (!lrc) return [];
|
||||
|
||||
const parseLine = (line: string): { time: number; text: string } | null => {
|
||||
const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\](.+)$/);
|
||||
if (!match) return null;
|
||||
const minutes = parseInt(match[1], 10);
|
||||
const seconds = parseInt(match[2], 10);
|
||||
const ms = parseInt(match[3].padEnd(3, '0'), 10);
|
||||
const text = match[4].trim();
|
||||
|
||||
// Skip metadata lines (作词, 作曲, 编曲, etc.)
|
||||
if (/^(作词|作曲|编曲|制作|混音|母带)\s*[::]/.test(text)) return null;
|
||||
|
||||
return { time: minutes * 60 + seconds + ms / 1000, text };
|
||||
};
|
||||
|
||||
const lines: LyricLine[] = [];
|
||||
const translationMap = new Map<number, string>();
|
||||
|
||||
// Parse translation lyrics first
|
||||
if (tlyric) {
|
||||
for (const line of tlyric.split('\n')) {
|
||||
const parsed = parseLine(line);
|
||||
if (parsed) {
|
||||
translationMap.set(Math.round(parsed.time * 100), parsed.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lrc.split('\n')) {
|
||||
const parsed = parseLine(line);
|
||||
if (parsed) {
|
||||
const timeKey = Math.round(parsed.time * 100);
|
||||
lines.push({
|
||||
time: parsed.time,
|
||||
text: parsed.text,
|
||||
translation: translationMap.get(timeKey),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export class NeteaseProvider implements MusicProvider {
|
||||
readonly platform = 'netease' as const;
|
||||
private api: AxiosInstance;
|
||||
private cookie = '';
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.api = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
private get cookieParams(): Record<string, string> {
|
||||
return this.cookie ? { cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const [songRes, playlistRes] = await Promise.all([
|
||||
this.api.get('/cloudsearch', {
|
||||
params: { keywords: query, type: 1, limit, ...this.cookieParams },
|
||||
}),
|
||||
this.api.get('/cloudsearch', {
|
||||
params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams },
|
||||
}),
|
||||
]);
|
||||
|
||||
const songs: Song[] = (songRes.data?.result?.songs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.al?.name ?? '',
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? '',
|
||||
platform: 'netease',
|
||||
}));
|
||||
|
||||
const playlists: Playlist[] = (playlistRes.data?.result?.playlists ?? []).map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
coverUrl: p.coverImgUrl ?? '',
|
||||
songCount: p.trackCount ?? 0,
|
||||
platform: 'netease',
|
||||
}));
|
||||
|
||||
return { songs, playlists, albums: [] };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<string | null> {
|
||||
const res = await this.api.get('/song/url/v1', {
|
||||
params: { id: songId, level: 'exhigh', ...this.cookieParams },
|
||||
});
|
||||
const url = res.data?.data?.[0]?.url;
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const res = await this.api.get('/song/detail', {
|
||||
params: { ids: songId, ...this.cookieParams },
|
||||
});
|
||||
const s = res.data?.songs?.[0];
|
||||
if (!s) return null;
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.al?.name ?? '',
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? '',
|
||||
platform: 'netease',
|
||||
};
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const res = await this.api.get('/playlist/track/all', {
|
||||
params: { id: playlistId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.songs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.al?.name ?? '',
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? '',
|
||||
platform: 'netease',
|
||||
}));
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
const res = await this.api.get('/personalized', {
|
||||
params: { limit: 10, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.result ?? []).map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
coverUrl: p.picUrl ?? '',
|
||||
songCount: p.trackCount ?? 0,
|
||||
platform: 'netease',
|
||||
}));
|
||||
}
|
||||
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
const res = await this.api.get('/album', {
|
||||
params: { id: albumId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.songs ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.ar ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.al?.name ?? '',
|
||||
duration: Math.round((s.dt ?? 0) / 1000),
|
||||
coverUrl: s.al?.picUrl ?? '',
|
||||
platform: 'netease',
|
||||
}));
|
||||
}
|
||||
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
const res = await this.api.get('/lyric', {
|
||||
params: { id: songId, ...this.cookieParams },
|
||||
});
|
||||
return parseLyrics(res.data?.lrc?.lyric ?? '', res.data?.tlyric?.lyric);
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
const keyRes = await this.api.get('/login/qr/key');
|
||||
const key = keyRes.data?.data?.unikey ?? '';
|
||||
const createRes = await this.api.get('/login/qr/create', {
|
||||
params: { key, qrimg: true },
|
||||
});
|
||||
return {
|
||||
qrUrl: createRes.data?.data?.qrurl ?? '',
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
|
||||
const res = await this.api.get('/login/qr/check', {
|
||||
params: { key },
|
||||
});
|
||||
const code = res.data?.code;
|
||||
switch (code) {
|
||||
case 801: return 'waiting';
|
||||
case 802: return 'scanned';
|
||||
case 803:
|
||||
// Save cookie from response
|
||||
if (res.data?.cookie) {
|
||||
this.cookie = res.data.cookie;
|
||||
}
|
||||
return 'confirmed';
|
||||
default: return 'expired';
|
||||
}
|
||||
}
|
||||
|
||||
async sendSmsCode(phone: string): Promise<boolean> {
|
||||
const res = await this.api.get('/captcha/sent', {
|
||||
params: { phone },
|
||||
});
|
||||
return res.data?.code === 200;
|
||||
}
|
||||
|
||||
async loginWithSms(phone: string, code: string): Promise<boolean> {
|
||||
const res = await this.api.get('/captcha/verify', {
|
||||
params: { phone, captcha: code },
|
||||
});
|
||||
if (res.data?.cookie) {
|
||||
this.cookie = res.data.cookie;
|
||||
}
|
||||
return res.data?.code === 200;
|
||||
}
|
||||
|
||||
setCookie(cookie: string): void {
|
||||
this.cookie = cookie;
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return this.cookie;
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
if (!this.cookie) return { loggedIn: false };
|
||||
try {
|
||||
const res = await this.api.get('/login/status', {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
const profile = res.data?.data?.profile;
|
||||
if (profile) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
nickname: profile.nickname,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { loggedIn: false };
|
||||
}
|
||||
|
||||
async getPersonalFm(): Promise<Song[]> {
|
||||
const res = await this.api.get('/personal_fm', {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data ?? []).map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
artist: (s.artists ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.album?.name ?? '',
|
||||
duration: Math.round((s.duration ?? 0) / 1000),
|
||||
coverUrl: s.album?.picUrl ?? '',
|
||||
platform: 'netease',
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/music/netease.test.ts`
|
||||
Expected: 3 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/netease.ts src/music/netease.test.ts
|
||||
git commit -m "feat: add NetEase Cloud Music provider with search, playlist, lyrics, and auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: QQ Music adapter
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/qq.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/music/qq.ts`:
|
||||
|
||||
```typescript
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
import type {
|
||||
MusicProvider, Song, Playlist, Album,
|
||||
LyricLine, SearchResult, QrCodeResult, AuthStatus,
|
||||
} from './provider.js';
|
||||
import { parseLyrics } from './netease.js';
|
||||
|
||||
export class QQMusicProvider implements MusicProvider {
|
||||
readonly platform = 'qq' as const;
|
||||
private api: AxiosInstance;
|
||||
private cookie = '';
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.api = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
private get cookieParams(): Record<string, string> {
|
||||
return this.cookie ? { cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
async search(query: string, limit = 20): Promise<SearchResult> {
|
||||
const res = await this.api.get('/search', {
|
||||
params: { key: query, pageSize: limit, ...this.cookieParams },
|
||||
});
|
||||
|
||||
const songs: Song[] = (res.data?.data?.list ?? []).map((s: any) => ({
|
||||
id: String(s.songmid ?? s.id),
|
||||
name: s.songname ?? s.name ?? '',
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.albumname ?? '',
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: '',
|
||||
platform: 'qq',
|
||||
}));
|
||||
|
||||
return { songs, playlists: [], albums: [] };
|
||||
}
|
||||
|
||||
async getSongUrl(songId: string): Promise<string | null> {
|
||||
const res = await this.api.get('/song/url', {
|
||||
params: { id: songId, ...this.cookieParams },
|
||||
});
|
||||
return res.data?.data ?? null;
|
||||
}
|
||||
|
||||
async getSongDetail(songId: string): Promise<Song | null> {
|
||||
const res = await this.api.get('/song', {
|
||||
params: { songmid: songId, ...this.cookieParams },
|
||||
});
|
||||
const s = res.data?.data;
|
||||
if (!s) return null;
|
||||
return {
|
||||
id: String(s.mid ?? s.id),
|
||||
name: s.name ?? '',
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.album?.name ?? '',
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.album?.mid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.album.mid}.jpg`
|
||||
: '',
|
||||
platform: 'qq',
|
||||
};
|
||||
}
|
||||
|
||||
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
|
||||
const res = await this.api.get('/songlist', {
|
||||
params: { id: playlistId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data?.songlist ?? []).map((s: any) => ({
|
||||
id: String(s.songmid ?? s.id),
|
||||
name: s.songname ?? s.name ?? '',
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.albumname ?? '',
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: '',
|
||||
platform: 'qq',
|
||||
}));
|
||||
}
|
||||
|
||||
async getRecommendPlaylists(): Promise<Playlist[]> {
|
||||
const res = await this.api.get('/recommend/playlist', {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data?.list ?? []).map((p: any) => ({
|
||||
id: String(p.content_id ?? p.id),
|
||||
name: p.title ?? p.name ?? '',
|
||||
coverUrl: p.cover ?? '',
|
||||
songCount: p.cnt ?? 0,
|
||||
platform: 'qq',
|
||||
}));
|
||||
}
|
||||
|
||||
async getAlbumSongs(albumId: string): Promise<Song[]> {
|
||||
const res = await this.api.get('/album/songs', {
|
||||
params: { albummid: albumId, ...this.cookieParams },
|
||||
});
|
||||
return (res.data?.data?.list ?? []).map((s: any) => ({
|
||||
id: String(s.songmid ?? s.id),
|
||||
name: s.songname ?? s.name ?? '',
|
||||
artist: (s.singer ?? []).map((a: any) => a.name).join(' / '),
|
||||
album: s.albumname ?? '',
|
||||
duration: s.interval ?? 0,
|
||||
coverUrl: s.albummid
|
||||
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${s.albummid}.jpg`
|
||||
: '',
|
||||
platform: 'qq',
|
||||
}));
|
||||
}
|
||||
|
||||
async getLyrics(songId: string): Promise<LyricLine[]> {
|
||||
const res = await this.api.get('/lyric', {
|
||||
params: { songmid: songId, ...this.cookieParams },
|
||||
});
|
||||
return parseLyrics(res.data?.data?.lyric ?? '', res.data?.data?.trans ?? '');
|
||||
}
|
||||
|
||||
async getQrCode(): Promise<QrCodeResult> {
|
||||
const res = await this.api.get('/login/qr/create');
|
||||
return {
|
||||
qrUrl: res.data?.data?.qrurl ?? '',
|
||||
key: res.data?.data?.key ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async checkQrCodeStatus(key: string): Promise<'waiting' | 'scanned' | 'confirmed' | 'expired'> {
|
||||
const res = await this.api.get('/login/qr/check', {
|
||||
params: { key },
|
||||
});
|
||||
const code = res.data?.data?.code;
|
||||
if (code === 0) {
|
||||
if (res.data?.data?.cookie) {
|
||||
this.cookie = res.data.data.cookie;
|
||||
}
|
||||
return 'confirmed';
|
||||
}
|
||||
if (code === 1) return 'scanned';
|
||||
if (code === 2) return 'waiting';
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
setCookie(cookie: string): void {
|
||||
this.cookie = cookie;
|
||||
}
|
||||
|
||||
getCookie(): string {
|
||||
return this.cookie;
|
||||
}
|
||||
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
if (!this.cookie) return { loggedIn: false };
|
||||
try {
|
||||
const res = await this.api.get('/user/detail', {
|
||||
params: { ...this.cookieParams },
|
||||
});
|
||||
if (res.data?.data) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
nickname: res.data.data.nickname,
|
||||
avatarUrl: res.data.data.headpic,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { loggedIn: false };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/qq.ts
|
||||
git commit -m "feat: add QQ Music provider with search, playlist, lyrics, and auth"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Auth module (Cookie persistence)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/music/auth.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/music/auth.ts`:
|
||||
|
||||
```typescript
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface CookieStore {
|
||||
save(platform: 'netease' | 'qq', cookie: string): void;
|
||||
load(platform: 'netease' | 'qq'): string;
|
||||
}
|
||||
|
||||
export function createCookieStore(cookieDir: string): CookieStore {
|
||||
if (!fs.existsSync(cookieDir)) {
|
||||
fs.mkdirSync(cookieDir, { recursive: true });
|
||||
}
|
||||
|
||||
return {
|
||||
save(platform: 'netease' | 'qq', cookie: string): void {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify({ cookie, updatedAt: new Date().toISOString() }), 'utf-8');
|
||||
},
|
||||
|
||||
load(platform: 'netease' | 'qq'): string {
|
||||
const filePath = path.join(cookieDir, `${platform}.json`);
|
||||
if (!fs.existsSync(filePath)) return '';
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
return data.cookie ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/music/auth.ts
|
||||
git commit -m "feat: add cookie persistence store for music platform authentication"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Verify and commit Phase 4
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compilation**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 4 complete — music source service (NetEase, QQ Music, auth)"
|
||||
```
|
||||
@@ -0,0 +1,869 @@
|
||||
# TSMusicBot Phase 5: Bot Core & TS Command System
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build BotInstance (ties TS3Client + AudioPlayer + MusicProvider together), BotManager (multi-instance lifecycle), and the TS text command parser/executor with permissions.
|
||||
|
||||
**Architecture:** `BotInstance` is the core unit — one TS connection, one player, one queue. `BotManager` creates/destroys instances and routes commands. `CommandHandler` parses `!play`, `!next`, etc. from TS text messages and dispatches them.
|
||||
|
||||
**Tech Stack:** TypeScript, vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Command parser
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bot/commands.ts`
|
||||
- Create: `src/bot/commands.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/bot/commands.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCommand, type ParsedCommand } from './commands.js';
|
||||
|
||||
describe('Command Parser', () => {
|
||||
it('parses simple command', () => {
|
||||
const result = parseCommand('!play 晴天', '!');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('play');
|
||||
expect(result!.args).toBe('晴天');
|
||||
expect(result!.rawArgs).toEqual(['晴天']);
|
||||
});
|
||||
|
||||
it('parses command with flags', () => {
|
||||
const result = parseCommand('!play -q 七里香', '!');
|
||||
expect(result!.name).toBe('play');
|
||||
expect(result!.flags.has('q')).toBe(true);
|
||||
expect(result!.args).toBe('七里香');
|
||||
});
|
||||
|
||||
it('returns null for non-command messages', () => {
|
||||
expect(parseCommand('hello world', '!')).toBeNull();
|
||||
expect(parseCommand('', '!')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles custom prefix', () => {
|
||||
const result = parseCommand('/play test', '/');
|
||||
expect(result!.name).toBe('play');
|
||||
expect(result!.args).toBe('test');
|
||||
});
|
||||
|
||||
it('resolves aliases', () => {
|
||||
const aliases = { p: 'play', s: 'skip', n: 'next' };
|
||||
const result = parseCommand('!p 稻香', '!', aliases);
|
||||
expect(result!.name).toBe('play');
|
||||
expect(result!.args).toBe('稻香');
|
||||
});
|
||||
|
||||
it('parses command with no args', () => {
|
||||
const result = parseCommand('!pause', '!');
|
||||
expect(result!.name).toBe('pause');
|
||||
expect(result!.args).toBe('');
|
||||
expect(result!.rawArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses vol command with number arg', () => {
|
||||
const result = parseCommand('!vol 80', '!');
|
||||
expect(result!.name).toBe('vol');
|
||||
expect(result!.args).toBe('80');
|
||||
});
|
||||
|
||||
it('parses mode command', () => {
|
||||
const result = parseCommand('!mode loop', '!');
|
||||
expect(result!.name).toBe('mode');
|
||||
expect(result!.args).toBe('loop');
|
||||
});
|
||||
|
||||
it('parses remove command with index', () => {
|
||||
const result = parseCommand('!remove 3', '!');
|
||||
expect(result!.name).toBe('remove');
|
||||
expect(result!.args).toBe('3');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/bot/commands.test.ts`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Write implementation**
|
||||
|
||||
Create `src/bot/commands.ts`:
|
||||
|
||||
```typescript
|
||||
export interface ParsedCommand {
|
||||
name: string; // command name (e.g., "play")
|
||||
args: string; // everything after the command name and flags
|
||||
rawArgs: string[]; // split args
|
||||
flags: Set<string>; // single-char flags (e.g., -q)
|
||||
}
|
||||
|
||||
// Commands anyone can use
|
||||
export const PUBLIC_COMMANDS = new Set([
|
||||
'play', 'add', 'queue', 'list', 'now', 'lyrics', 'vote', 'help',
|
||||
'playlist', 'album', 'fm', 'prev', 'next', 'skip', 'pause', 'resume',
|
||||
]);
|
||||
|
||||
// Commands that require admin
|
||||
export const ADMIN_COMMANDS = new Set([
|
||||
'stop', 'clear', 'move', 'vol', 'mode', 'follow', 'remove',
|
||||
]);
|
||||
|
||||
export function parseCommand(
|
||||
message: string,
|
||||
prefix: string,
|
||||
aliases: Record<string, string> = {},
|
||||
): ParsedCommand | null {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed.startsWith(prefix)) return null;
|
||||
|
||||
const withoutPrefix = trimmed.slice(prefix.length);
|
||||
if (!withoutPrefix) return null;
|
||||
|
||||
const parts = withoutPrefix.split(/\s+/);
|
||||
let name = parts[0].toLowerCase();
|
||||
|
||||
// Resolve alias
|
||||
if (aliases[name]) {
|
||||
name = aliases[name];
|
||||
}
|
||||
|
||||
// Parse flags and remaining args
|
||||
const flags = new Set<string>();
|
||||
const argParts: string[] = [];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (parts[i].startsWith('-') && parts[i].length === 2 && /[a-zA-Z]/.test(parts[i][1])) {
|
||||
flags.add(parts[i][1].toLowerCase());
|
||||
} else {
|
||||
argParts.push(parts[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
args: argParts.join(' '),
|
||||
rawArgs: argParts,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAdminCommand(commandName: string): boolean {
|
||||
return ADMIN_COMMANDS.has(commandName);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/bot/commands.test.ts`
|
||||
Expected: 9 tests PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/commands.ts src/bot/commands.test.ts
|
||||
git commit -m "feat: add TS command parser with aliases, flags, and permission classification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: BotInstance
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bot/instance.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/bot/instance.ts`:
|
||||
|
||||
```typescript
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { TS3Client, type TS3ClientOptions, type TS3TextMessage } from '../ts-protocol/client.js';
|
||||
import { AudioPlayer } from '../audio/player.js';
|
||||
import { PlayQueue, PlayMode, type QueuedSong } from '../audio/queue.js';
|
||||
import type { MusicProvider, Song } from '../music/provider.js';
|
||||
import { parseCommand, isAdminCommand, type ParsedCommand } from './commands.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
import type { Database } from '../data/database.js';
|
||||
import type { BotConfig } from '../data/config.js';
|
||||
|
||||
export interface BotInstanceOptions {
|
||||
id: string;
|
||||
name: string;
|
||||
tsOptions: TS3ClientOptions;
|
||||
neteaseProvider: MusicProvider;
|
||||
qqProvider: MusicProvider;
|
||||
database: Database;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
playing: boolean;
|
||||
paused: boolean;
|
||||
currentSong: QueuedSong | null;
|
||||
queueSize: number;
|
||||
volume: number;
|
||||
playMode: PlayMode;
|
||||
}
|
||||
|
||||
export class BotInstance extends EventEmitter {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
private tsClient: TS3Client;
|
||||
private player: AudioPlayer;
|
||||
private queue: PlayQueue;
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private database: Database;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
private connected = false;
|
||||
private voteSkipUsers = new Set<string>();
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
this.id = options.id;
|
||||
this.name = options.name;
|
||||
this.neteaseProvider = options.neteaseProvider;
|
||||
this.qqProvider = options.qqProvider;
|
||||
this.database = options.database;
|
||||
this.config = options.config;
|
||||
this.logger = options.logger.child({ botId: this.id });
|
||||
|
||||
this.tsClient = new TS3Client(options.tsOptions, this.logger);
|
||||
this.player = new AudioPlayer(this.logger);
|
||||
this.queue = new PlayQueue();
|
||||
|
||||
this.setupPlayerEvents();
|
||||
this.setupTsEvents();
|
||||
}
|
||||
|
||||
private setupPlayerEvents(): void {
|
||||
this.player.on('frame', (opusFrame: Buffer) => {
|
||||
this.tsClient.sendVoiceData(opusFrame);
|
||||
});
|
||||
|
||||
this.player.on('trackEnd', () => {
|
||||
this.logger.debug('Track ended, advancing queue');
|
||||
this.playNext();
|
||||
});
|
||||
|
||||
this.player.on('error', (err: Error) => {
|
||||
this.logger.error({ err }, 'Player error');
|
||||
this.playNext();
|
||||
});
|
||||
}
|
||||
|
||||
private setupTsEvents(): void {
|
||||
this.tsClient.on('textMessage', (msg: TS3TextMessage) => {
|
||||
this.handleTextMessage(msg);
|
||||
});
|
||||
|
||||
this.tsClient.on('disconnected', () => {
|
||||
this.connected = false;
|
||||
this.player.stop();
|
||||
this.emit('disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.tsClient.connect();
|
||||
this.connected = true;
|
||||
this.emit('connected');
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.player.stop();
|
||||
this.tsClient.disconnect();
|
||||
this.connected = false;
|
||||
this.emit('disconnected');
|
||||
}
|
||||
|
||||
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
||||
const parsed = parseCommand(msg.message, this.config.commandPrefix, this.config.commandAliases);
|
||||
if (!parsed) return;
|
||||
|
||||
// Permission check
|
||||
if (isAdminCommand(parsed.name)) {
|
||||
// For now, allow all commands. Admin check via TS Server Groups
|
||||
// will be implemented when we add the permission system.
|
||||
// TODO: Check if invoker is in adminGroups
|
||||
}
|
||||
|
||||
this.logger.info({ command: parsed.name, args: parsed.args, invoker: msg.invokerName }, 'Command received');
|
||||
|
||||
try {
|
||||
const response = await this.executeCommand(parsed, msg);
|
||||
if (response) {
|
||||
await this.tsClient.sendTextMessage(response);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err, command: parsed.name }, 'Command execution error');
|
||||
await this.tsClient.sendTextMessage(`Error: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async executeCommand(cmd: ParsedCommand, msg?: TS3TextMessage): Promise<string | null> {
|
||||
switch (cmd.name) {
|
||||
case 'play': return this.cmdPlay(cmd);
|
||||
case 'add': return this.cmdAdd(cmd);
|
||||
case 'pause': return this.cmdPause();
|
||||
case 'resume': return this.cmdResume();
|
||||
case 'stop': return this.cmdStop();
|
||||
case 'next':
|
||||
case 'skip': return this.cmdNext();
|
||||
case 'prev': return this.cmdPrev();
|
||||
case 'vol': return this.cmdVol(cmd);
|
||||
case 'now': return this.cmdNow();
|
||||
case 'queue':
|
||||
case 'list': return this.cmdQueue();
|
||||
case 'clear': return this.cmdClear();
|
||||
case 'remove': return this.cmdRemove(cmd);
|
||||
case 'mode': return this.cmdMode(cmd);
|
||||
case 'playlist': return this.cmdPlaylist(cmd);
|
||||
case 'album': return this.cmdAlbum(cmd);
|
||||
case 'fm': return this.cmdFm();
|
||||
case 'vote': return this.cmdVote(msg);
|
||||
case 'lyrics': return this.cmdLyrics();
|
||||
case 'move': return this.cmdMove(cmd);
|
||||
case 'follow': return this.cmdFollow(msg);
|
||||
case 'help': return this.cmdHelp();
|
||||
default: return `Unknown command: ${cmd.name}. Type ${this.config.commandPrefix}help for help.`;
|
||||
}
|
||||
}
|
||||
|
||||
private getProvider(useQQ: boolean): MusicProvider {
|
||||
return useQQ ? this.qqProvider : this.neteaseProvider;
|
||||
}
|
||||
|
||||
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return 'Usage: !play <song name or URL>';
|
||||
const provider = this.getProvider(cmd.flags.has('q'));
|
||||
const result = await provider.search(cmd.args, 1);
|
||||
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
|
||||
|
||||
const song = result.songs[0];
|
||||
const url = await provider.getSongUrl(song.id);
|
||||
if (!url) return `Cannot get play URL for: ${song.name}`;
|
||||
|
||||
const queuedSong: QueuedSong = { ...song, url, platform: provider.platform };
|
||||
this.queue.clear();
|
||||
this.queue.add(queuedSong);
|
||||
this.queue.play();
|
||||
this.player.play(url);
|
||||
|
||||
this.database.addPlayHistory({
|
||||
botId: this.id,
|
||||
songId: song.id,
|
||||
songName: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
platform: provider.platform,
|
||||
coverUrl: song.coverUrl,
|
||||
});
|
||||
|
||||
this.emit('stateChange');
|
||||
return `Now playing: ${song.name} - ${song.artist}`;
|
||||
}
|
||||
|
||||
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return 'Usage: !add <song name>';
|
||||
const provider = this.getProvider(cmd.flags.has('q'));
|
||||
const result = await provider.search(cmd.args, 1);
|
||||
if (result.songs.length === 0) return `No results found for: ${cmd.args}`;
|
||||
|
||||
const song = result.songs[0];
|
||||
const url = await provider.getSongUrl(song.id);
|
||||
if (!url) return `Cannot get play URL for: ${song.name}`;
|
||||
|
||||
this.queue.add({ ...song, url, platform: provider.platform });
|
||||
this.emit('stateChange');
|
||||
return `Added to queue: ${song.name} - ${song.artist} (position ${this.queue.size()})`;
|
||||
}
|
||||
|
||||
private cmdPause(): string {
|
||||
this.player.pause();
|
||||
this.emit('stateChange');
|
||||
return 'Paused';
|
||||
}
|
||||
|
||||
private cmdResume(): string {
|
||||
this.player.resume();
|
||||
this.emit('stateChange');
|
||||
return 'Resumed';
|
||||
}
|
||||
|
||||
private cmdStop(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.emit('stateChange');
|
||||
return 'Stopped and queue cleared';
|
||||
}
|
||||
|
||||
private cmdNext(): string {
|
||||
this.playNext();
|
||||
const current = this.queue.current();
|
||||
if (current) return `Now playing: ${current.name} - ${current.artist}`;
|
||||
return 'Queue is empty';
|
||||
}
|
||||
|
||||
private cmdPrev(): string {
|
||||
const prev = this.queue.prev();
|
||||
if (prev) {
|
||||
this.player.play(prev.url);
|
||||
this.emit('stateChange');
|
||||
return `Now playing: ${prev.name} - ${prev.artist}`;
|
||||
}
|
||||
return 'No previous song';
|
||||
}
|
||||
|
||||
private cmdVol(cmd: ParsedCommand): string {
|
||||
const vol = parseInt(cmd.args, 10);
|
||||
if (isNaN(vol) || vol < 0 || vol > 100) return 'Usage: !vol <0-100>';
|
||||
this.player.setVolume(vol);
|
||||
this.emit('stateChange');
|
||||
return `Volume set to ${vol}%`;
|
||||
}
|
||||
|
||||
private cmdNow(): string {
|
||||
const song = this.queue.current();
|
||||
if (!song) return 'Nothing is playing';
|
||||
return `Now playing: ${song.name} - ${song.artist} [${song.album}] (${song.platform})`;
|
||||
}
|
||||
|
||||
private cmdQueue(): string {
|
||||
const songs = this.queue.list();
|
||||
if (songs.length === 0) return 'Queue is empty';
|
||||
const currentIdx = this.queue.getCurrentIndex();
|
||||
const lines = songs.map((s, i) => {
|
||||
const marker = i === currentIdx ? '▶ ' : ' ';
|
||||
return `${marker}${i + 1}. ${s.name} - ${s.artist}`;
|
||||
});
|
||||
return `Queue (${songs.length} songs, mode: ${this.queue.getMode()}):\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
private cmdClear(): string {
|
||||
this.player.stop();
|
||||
this.queue.clear();
|
||||
this.emit('stateChange');
|
||||
return 'Queue cleared';
|
||||
}
|
||||
|
||||
private cmdRemove(cmd: ParsedCommand): string {
|
||||
const index = parseInt(cmd.args, 10) - 1; // user sees 1-based
|
||||
if (isNaN(index) || index < 0) return 'Usage: !remove <number>';
|
||||
const removed = this.queue.remove(index);
|
||||
if (!removed) return 'Invalid position';
|
||||
this.emit('stateChange');
|
||||
return `Removed: ${removed.name}`;
|
||||
}
|
||||
|
||||
private cmdMode(cmd: ParsedCommand): string {
|
||||
const modeMap: Record<string, PlayMode> = {
|
||||
seq: PlayMode.Sequential,
|
||||
loop: PlayMode.Loop,
|
||||
random: PlayMode.Random,
|
||||
rloop: PlayMode.RandomLoop,
|
||||
};
|
||||
const mode = modeMap[cmd.args];
|
||||
if (!mode) return 'Usage: !mode <seq|loop|random|rloop>';
|
||||
this.queue.setMode(mode);
|
||||
this.emit('stateChange');
|
||||
return `Play mode set to: ${cmd.args}`;
|
||||
}
|
||||
|
||||
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return 'Usage: !playlist <playlist ID or URL>';
|
||||
const provider = this.getProvider(cmd.flags.has('q'));
|
||||
const id = this.extractId(cmd.args);
|
||||
const songs = await provider.getPlaylistSongs(id);
|
||||
if (songs.length === 0) return 'Playlist is empty or not found';
|
||||
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
const url = await provider.getSongUrl(song.id);
|
||||
if (url) {
|
||||
this.queue.add({ ...song, url, platform: provider.platform });
|
||||
}
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) this.player.play(first.url);
|
||||
this.emit('stateChange');
|
||||
return `Loaded playlist: ${songs.length} songs. Now playing: ${first?.name ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return 'Usage: !album <album ID>';
|
||||
const provider = this.getProvider(cmd.flags.has('q'));
|
||||
const songs = await provider.getAlbumSongs(cmd.args);
|
||||
if (songs.length === 0) return 'Album is empty or not found';
|
||||
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
const url = await provider.getSongUrl(song.id);
|
||||
if (url) {
|
||||
this.queue.add({ ...song, url, platform: provider.platform });
|
||||
}
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) this.player.play(first.url);
|
||||
this.emit('stateChange');
|
||||
return `Loaded album: ${songs.length} songs. Now playing: ${first?.name ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
private async cmdFm(): Promise<string> {
|
||||
if (!this.neteaseProvider.getPersonalFm) {
|
||||
return 'Personal FM is only available for NetEase Cloud Music';
|
||||
}
|
||||
const songs = await this.neteaseProvider.getPersonalFm();
|
||||
if (songs.length === 0) return 'No FM songs available (need to login first)';
|
||||
|
||||
this.queue.clear();
|
||||
for (const song of songs) {
|
||||
const url = await this.neteaseProvider.getSongUrl(song.id);
|
||||
if (url) {
|
||||
this.queue.add({ ...song, url, platform: 'netease' });
|
||||
}
|
||||
}
|
||||
const first = this.queue.play();
|
||||
if (first) this.player.play(first.url);
|
||||
this.emit('stateChange');
|
||||
return `Personal FM started: ${first?.name ?? 'unknown'} - ${first?.artist ?? ''}`;
|
||||
}
|
||||
|
||||
private async cmdVote(msg?: TS3TextMessage): Promise<string> {
|
||||
if (!msg) return 'Vote can only be used in TeamSpeak';
|
||||
this.voteSkipUsers.add(msg.invokerUid);
|
||||
const clients = await this.tsClient.getClientsInChannel();
|
||||
const totalUsers = clients.length - 1; // exclude bot
|
||||
const needed = Math.ceil(totalUsers / 2);
|
||||
const votes = this.voteSkipUsers.size;
|
||||
|
||||
if (votes >= needed) {
|
||||
this.voteSkipUsers.clear();
|
||||
this.playNext();
|
||||
return `Vote passed (${votes}/${needed}). Skipping to next song.`;
|
||||
}
|
||||
return `Vote to skip: ${votes}/${needed} (need ${needed - votes} more)`;
|
||||
}
|
||||
|
||||
private async cmdLyrics(): Promise<string> {
|
||||
const song = this.queue.current();
|
||||
if (!song) return 'Nothing is playing';
|
||||
const provider = this.getProvider(song.platform === 'qq');
|
||||
const lyrics = await provider.getLyrics(song.id);
|
||||
if (lyrics.length === 0) return 'No lyrics available';
|
||||
// Show first 10 lines
|
||||
const lines = lyrics.slice(0, 10).map((l) => l.text);
|
||||
return `Lyrics for ${song.name}:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
private async cmdMove(cmd: ParsedCommand): Promise<string> {
|
||||
if (!cmd.args) return 'Usage: !move <channel name or ID>';
|
||||
await this.tsClient.moveToChannel(cmd.args);
|
||||
return `Moved to channel: ${cmd.args}`;
|
||||
}
|
||||
|
||||
private async cmdFollow(msg?: TS3TextMessage): Promise<string> {
|
||||
if (!msg) return 'Follow can only be used in TeamSpeak';
|
||||
// Would need to get the invoker's current channel
|
||||
return 'Following you to your channel';
|
||||
}
|
||||
|
||||
private cmdHelp(): string {
|
||||
const p = this.config.commandPrefix;
|
||||
return [
|
||||
`TSMusicBot Commands:`,
|
||||
`${p}play <song> — Search and play`,
|
||||
`${p}play -q <song> — Search from QQ Music`,
|
||||
`${p}add <song> — Add to queue`,
|
||||
`${p}pause/resume — Pause/resume`,
|
||||
`${p}next/prev — Next/previous`,
|
||||
`${p}stop — Stop and clear queue`,
|
||||
`${p}vol <0-100> — Set volume`,
|
||||
`${p}queue — Show queue`,
|
||||
`${p}mode <seq|loop|random|rloop> — Play mode`,
|
||||
`${p}playlist <id> — Load playlist`,
|
||||
`${p}album <id> — Load album`,
|
||||
`${p}fm — Personal FM (NetEase)`,
|
||||
`${p}vote — Vote to skip`,
|
||||
`${p}lyrics — Show lyrics`,
|
||||
`${p}now — Current song info`,
|
||||
`${p}help — This help message`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private playNext(): void {
|
||||
this.voteSkipUsers.clear();
|
||||
const next = this.queue.next();
|
||||
if (next) {
|
||||
this.player.play(next.url);
|
||||
this.database.addPlayHistory({
|
||||
botId: this.id,
|
||||
songId: next.id,
|
||||
songName: next.name,
|
||||
artist: next.artist,
|
||||
album: next.album,
|
||||
platform: next.platform,
|
||||
coverUrl: next.coverUrl,
|
||||
});
|
||||
} else {
|
||||
this.player.stop();
|
||||
}
|
||||
this.emit('stateChange');
|
||||
}
|
||||
|
||||
private extractId(input: string): string {
|
||||
// Try to extract numeric ID from URL or return as-is
|
||||
const match = input.match(/[?&]id=(\d+)/);
|
||||
if (match) return match[1];
|
||||
const pathMatch = input.match(/\/(\d+)/);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
return input;
|
||||
}
|
||||
|
||||
getStatus(): BotStatus {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
connected: this.connected,
|
||||
playing: this.player.getState() === 'playing',
|
||||
paused: this.player.getState() === 'paused',
|
||||
currentSong: this.queue.current(),
|
||||
queueSize: this.queue.size(),
|
||||
volume: this.player.getVolume(),
|
||||
playMode: this.queue.getMode(),
|
||||
};
|
||||
}
|
||||
|
||||
getQueue(): QueuedSong[] {
|
||||
return this.queue.list();
|
||||
}
|
||||
|
||||
getPlayer(): AudioPlayer {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
getQueueManager(): PlayQueue {
|
||||
return this.queue;
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/instance.ts
|
||||
git commit -m "feat: add BotInstance with full command execution, player, queue, and TS integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: BotManager (multi-instance)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/bot/manager.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/bot/manager.ts`:
|
||||
|
||||
```typescript
|
||||
import crypto from 'node:crypto';
|
||||
import { BotInstance, type BotInstanceOptions } from './instance.js';
|
||||
import type { MusicProvider } from '../music/provider.js';
|
||||
import type { Database, BotInstanceRow } from '../data/database.js';
|
||||
import type { BotConfig } from '../data/config.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
export interface CreateBotParams {
|
||||
name: string;
|
||||
serverAddress: string;
|
||||
serverPort: number;
|
||||
queryPort?: number;
|
||||
nickname: string;
|
||||
defaultChannel?: string;
|
||||
channelPassword?: string;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
export class BotManager {
|
||||
private bots = new Map<string, BotInstance>();
|
||||
private neteaseProvider: MusicProvider;
|
||||
private qqProvider: MusicProvider;
|
||||
private database: Database;
|
||||
private config: BotConfig;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
neteaseProvider: MusicProvider,
|
||||
qqProvider: MusicProvider,
|
||||
database: Database,
|
||||
config: BotConfig,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.neteaseProvider = neteaseProvider;
|
||||
this.qqProvider = qqProvider;
|
||||
this.database = database;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async createBot(params: CreateBotParams): Promise<BotInstance> {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const bot = new BotInstance({
|
||||
id,
|
||||
name: params.name,
|
||||
tsOptions: {
|
||||
host: params.serverAddress,
|
||||
port: params.serverPort,
|
||||
queryPort: params.queryPort ?? 10011,
|
||||
nickname: params.nickname,
|
||||
defaultChannel: params.defaultChannel,
|
||||
channelPassword: params.channelPassword,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
this.bots.set(id, bot);
|
||||
|
||||
// Save to database
|
||||
this.database.saveBotInstance({
|
||||
id,
|
||||
name: params.name,
|
||||
serverAddress: params.serverAddress,
|
||||
serverPort: params.serverPort,
|
||||
nickname: params.nickname,
|
||||
defaultChannel: params.defaultChannel ?? '',
|
||||
channelPassword: params.channelPassword ?? '',
|
||||
autoStart: params.autoStart ?? false,
|
||||
});
|
||||
|
||||
this.logger.info({ botId: id, name: params.name }, 'Bot instance created');
|
||||
return bot;
|
||||
}
|
||||
|
||||
async removeBot(id: string): Promise<void> {
|
||||
const bot = this.bots.get(id);
|
||||
if (bot) {
|
||||
bot.disconnect();
|
||||
this.bots.delete(id);
|
||||
}
|
||||
this.database.deleteBotInstance(id);
|
||||
this.logger.info({ botId: id }, 'Bot instance removed');
|
||||
}
|
||||
|
||||
getBot(id: string): BotInstance | undefined {
|
||||
return this.bots.get(id);
|
||||
}
|
||||
|
||||
getAllBots(): BotInstance[] {
|
||||
return Array.from(this.bots.values());
|
||||
}
|
||||
|
||||
async startBot(id: string): Promise<void> {
|
||||
const bot = this.bots.get(id);
|
||||
if (!bot) throw new Error(`Bot ${id} not found`);
|
||||
await bot.connect();
|
||||
}
|
||||
|
||||
stopBot(id: string): void {
|
||||
const bot = this.bots.get(id);
|
||||
if (!bot) throw new Error(`Bot ${id} not found`);
|
||||
bot.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load saved bot instances from database and create BotInstance objects.
|
||||
* Optionally auto-start bots that have autoStart=true.
|
||||
*/
|
||||
async loadSavedBots(): Promise<void> {
|
||||
const savedInstances = this.database.getBotInstances();
|
||||
for (const saved of savedInstances) {
|
||||
const bot = new BotInstance({
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
tsOptions: {
|
||||
host: saved.serverAddress,
|
||||
port: saved.serverPort,
|
||||
queryPort: 10011,
|
||||
nickname: saved.nickname,
|
||||
defaultChannel: saved.defaultChannel || undefined,
|
||||
channelPassword: saved.channelPassword || undefined,
|
||||
},
|
||||
neteaseProvider: this.neteaseProvider,
|
||||
qqProvider: this.qqProvider,
|
||||
database: this.database,
|
||||
config: this.config,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
this.bots.set(saved.id, bot);
|
||||
|
||||
if (saved.autoStart) {
|
||||
try {
|
||||
await bot.connect();
|
||||
this.logger.info({ botId: saved.id, name: saved.name }, 'Auto-started bot');
|
||||
} catch (err) {
|
||||
this.logger.error({ err, botId: saved.id }, 'Failed to auto-start bot');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({ count: savedInstances.length }, 'Loaded saved bot instances');
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const bot of this.bots.values()) {
|
||||
bot.disconnect();
|
||||
}
|
||||
this.bots.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/bot/manager.ts
|
||||
git commit -m "feat: add BotManager for multi-instance lifecycle, persistence, and auto-start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify and commit Phase 5
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compilation**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 5 complete — bot core (commands, instance, manager)"
|
||||
```
|
||||
@@ -0,0 +1,856 @@
|
||||
# TSMusicBot Phase 6: Web Backend (Express API + WebSocket)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the Express REST API and WebSocket server that the Vue.js frontend will consume. Covers bot management, music search/playback control, auth, and real-time state updates.
|
||||
|
||||
**Architecture:** `src/web/server.ts` bootstraps Express + WebSocket. API routes are split by domain: `bot.ts`, `music.ts`, `player.ts`, `auth.ts`. WebSocket pushes state changes in real-time.
|
||||
|
||||
**Tech Stack:** Express 4, ws, vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Web server bootstrap
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/server.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/server.ts`:
|
||||
|
||||
```typescript
|
||||
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 { Database } 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: Database;
|
||||
config: BotConfig;
|
||||
logger: Logger;
|
||||
staticDir?: string; // path to built Vue.js SPA
|
||||
}
|
||||
|
||||
export interface WebServer {
|
||||
start(): Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const logger = options.logger.child({ component: 'web' });
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// API routes
|
||||
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));
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '0.1.0' });
|
||||
});
|
||||
|
||||
// Serve Vue.js SPA static files
|
||||
if (options.staticDir) {
|
||||
app.use(express.static(options.staticDir));
|
||||
// SPA fallback: serve index.html for all non-API routes
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(options.staticDir!, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
setupWebSocket(wss, options.botManager, logger);
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(options.port, () => {
|
||||
logger.info({ port: options.port }, 'Web server started');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
stop(): void {
|
||||
wss.close();
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/server.ts
|
||||
git commit -m "feat: add Express + WebSocket web server bootstrap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Bot management API
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/api/bot.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/api/bot.ts`:
|
||||
|
||||
```typescript
|
||||
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();
|
||||
|
||||
// List all bot instances
|
||||
router.get('/', (_req, res) => {
|
||||
const bots = botManager.getAllBots().map((b) => b.getStatus());
|
||||
res.json({ bots });
|
||||
});
|
||||
|
||||
// Get single bot status
|
||||
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());
|
||||
});
|
||||
|
||||
// Create new bot instance
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete bot instance
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Start bot (connect to TS server)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop bot (disconnect from TS server)
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/bot.ts
|
||||
git commit -m "feat: add bot management REST API (CRUD + start/stop)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Music search API
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/api/music.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/api/music.ts`:
|
||||
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
|
||||
// Search
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Song detail
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Playlist songs
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Recommended playlists
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Album songs
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Lyrics
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/music.ts
|
||||
git commit -m "feat: add music search/detail/playlist/lyrics REST API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Player control API
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/api/player.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/api/player.ts`:
|
||||
|
||||
```typescript
|
||||
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();
|
||||
|
||||
// All player routes require a botId
|
||||
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();
|
||||
});
|
||||
|
||||
// Play a song
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Add to queue
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Pause
|
||||
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 });
|
||||
});
|
||||
|
||||
// Resume
|
||||
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 });
|
||||
});
|
||||
|
||||
// Next
|
||||
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 });
|
||||
});
|
||||
|
||||
// Previous
|
||||
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 });
|
||||
});
|
||||
|
||||
// Stop
|
||||
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 });
|
||||
});
|
||||
|
||||
// Set volume
|
||||
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 });
|
||||
});
|
||||
|
||||
// Set play mode
|
||||
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 });
|
||||
});
|
||||
|
||||
// Get queue
|
||||
router.get('/:botId/queue', (req, res) => {
|
||||
const bot = (req as any).bot;
|
||||
res.json({ queue: bot.getQueue(), status: bot.getStatus() });
|
||||
});
|
||||
|
||||
// Clear queue
|
||||
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 });
|
||||
});
|
||||
|
||||
// Remove from queue
|
||||
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 });
|
||||
});
|
||||
|
||||
// Load playlist
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Play history
|
||||
router.get('/:botId/history', (req, res) => {
|
||||
const bot = (req as any).bot;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
// Access database directly through the bot's status
|
||||
// The database is accessed through the botManager
|
||||
res.json({ history: [] }); // Will be wired properly when database is accessible
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/player.ts
|
||||
git commit -m "feat: add player control REST API (play, pause, queue, volume, mode)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Auth API
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/api/auth.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/api/auth.ts`:
|
||||
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
|
||||
// Get auth status
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Get QR code for login
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Check QR code status
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Send SMS code (NetEase only)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify SMS code (NetEase only)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Set cookie manually
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/api/auth.ts
|
||||
git commit -m "feat: add auth REST API (QR code, SMS, cookie login)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: WebSocket real-time updates
|
||||
|
||||
**Files:**
|
||||
- Create: `src/web/websocket.ts`
|
||||
|
||||
- [ ] **Step 1: Write implementation**
|
||||
|
||||
Create `src/web/websocket.ts`:
|
||||
|
||||
```typescript
|
||||
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<WebSocket>();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clients.add(ws);
|
||||
logger.debug('WebSocket client connected');
|
||||
|
||||
// Send initial state
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast state changes from all bots
|
||||
const broadcast = (data: object) => {
|
||||
const message = JSON.stringify(data);
|
||||
for (const client of clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for state changes on all bots
|
||||
// Re-attach listeners when bots are added/removed
|
||||
const attachBotListeners = () => {
|
||||
for (const bot of botManager.getAllBots()) {
|
||||
// Remove existing listeners to avoid duplicates
|
||||
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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh listeners periodically (simple approach)
|
||||
setInterval(attachBotListeners, 5000);
|
||||
attachBotListeners();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/web/websocket.ts
|
||||
git commit -m "feat: add WebSocket real-time state broadcasting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wire everything in index.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Update entry point**
|
||||
|
||||
Replace the contents of `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
import path from 'node:path';
|
||||
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, '..');
|
||||
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() {
|
||||
// Load config
|
||||
const config = loadConfig(CONFIG_PATH);
|
||||
saveConfig(CONFIG_PATH, config);
|
||||
|
||||
// Create logger
|
||||
const logger = createLogger(LOG_DIR);
|
||||
|
||||
// Create database
|
||||
const db = createDatabase(DB_PATH);
|
||||
|
||||
// Start embedded music API servers
|
||||
const apiServer = createApiServerManager(
|
||||
{ neteasePort: config.neteaseApiPort, qqMusicApiPort: config.qqMusicApiPort },
|
||||
logger,
|
||||
);
|
||||
await apiServer.start();
|
||||
|
||||
// Create music providers
|
||||
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
|
||||
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
|
||||
|
||||
// Load saved cookies
|
||||
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);
|
||||
|
||||
// Create bot manager
|
||||
const botManager = new BotManager(neteaseProvider, qqProvider, db, config, logger);
|
||||
await botManager.loadSavedBots();
|
||||
|
||||
// Create and start web server
|
||||
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}`);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
botManager.shutdown();
|
||||
webServer.stop();
|
||||
apiServer.stop();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compilation**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/index.ts
|
||||
git commit -m "feat: wire all components in entry point — full backend stack"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Verify Phase 6
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 6 complete — web backend (REST API, WebSocket, full wiring)"
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,552 @@
|
||||
# TSMusicBot Phase 8: Deployment & Packaging
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create one-click install experiences for Windows (.exe + portable .zip) and Linux (install script + Docker), plus the first-run Setup Wizard in the WebUI.
|
||||
|
||||
**Architecture:** Windows uses `pkg` to bundle Node.js into a single .exe with FFmpeg as a sidecar binary. Linux uses a shell script that installs dependencies and configures systemd, plus a Docker image. The Setup Wizard is a Vue.js view that guides users through initial configuration.
|
||||
|
||||
**Tech Stack:** pkg, Inno Setup/NSIS, Docker, bash
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Windows start script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/start.bat`
|
||||
|
||||
- [ ] **Step 1: Create start.bat**
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
title TSMusicBot
|
||||
echo Starting TSMusicBot...
|
||||
echo.
|
||||
|
||||
:: Check if node is available
|
||||
where node >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Node.js is not installed. Please install Node.js 20 LTS from https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check if ffmpeg is available
|
||||
where ffmpeg >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo FFmpeg not found in PATH. Checking local directory...
|
||||
if exist "%~dp0ffmpeg\ffmpeg.exe" (
|
||||
set PATH=%~dp0ffmpeg;%PATH%
|
||||
echo Using bundled FFmpeg.
|
||||
) else (
|
||||
echo FFmpeg is required. Please install FFmpeg or place it in the ffmpeg\ directory.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: Install dependencies if needed
|
||||
if not exist "%~dp0node_modules" (
|
||||
echo Installing dependencies...
|
||||
cd /d "%~dp0"
|
||||
call npm install --production
|
||||
)
|
||||
|
||||
:: Start the application
|
||||
cd /d "%~dp0"
|
||||
node dist/index.js
|
||||
|
||||
pause
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/start.bat
|
||||
git commit -m "feat: add Windows start script with dependency checks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Linux install script
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/install.sh`
|
||||
|
||||
- [ ] **Step 1: Create install.sh**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
echo "║ TSMusicBot Installer ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
INSTALL_DIR="/opt/tsmusicbot"
|
||||
SERVICE_NAME="tsmusicbot"
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
else
|
||||
echo "Unsupported OS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/5] Installing system dependencies..."
|
||||
case $OS in
|
||||
ubuntu|debian)
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq curl ffmpeg
|
||||
;;
|
||||
centos|rhel|fedora)
|
||||
sudo yum install -y curl ffmpeg
|
||||
;;
|
||||
arch|manjaro)
|
||||
sudo pacman -S --noconfirm curl ffmpeg
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[2/5] Installing Node.js 20 LTS..."
|
||||
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
|
||||
fi
|
||||
echo "Node.js $(node -v) installed"
|
||||
|
||||
echo "[3/5] Downloading TSMusicBot..."
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
# In production, this would download from GitHub releases
|
||||
# For now, copy from current directory
|
||||
if [ -d "$(pwd)/dist" ]; then
|
||||
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
|
||||
else
|
||||
echo "Please run this script from the TSMusicBot source directory after building."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[4/5] Installing npm dependencies..."
|
||||
cd "$INSTALL_DIR"
|
||||
sudo npm install --production
|
||||
|
||||
echo "[5/5] Creating systemd service..."
|
||||
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
|
||||
[Unit]
|
||||
Description=TSMusicBot - TeamSpeak Music Bot
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ${SERVICE_NAME}
|
||||
sudo systemctl start ${SERVICE_NAME}
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════╗"
|
||||
echo "║ TSMusicBot installed and running! ║"
|
||||
echo "║ ║"
|
||||
echo "║ WebUI: http://localhost:3000 ║"
|
||||
echo "║ ║"
|
||||
echo "║ Commands: ║"
|
||||
echo "║ systemctl status tsmusicbot ║"
|
||||
echo "║ systemctl restart tsmusicbot ║"
|
||||
echo "║ systemctl stop tsmusicbot ║"
|
||||
echo "╚══════════════════════════════════════╝"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x scripts/install.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/install.sh
|
||||
git commit -m "feat: add Linux one-click install script with systemd service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Docker setup
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/docker/Dockerfile`
|
||||
- Create: `scripts/docker/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: Create Dockerfile**
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/web/dist ./web/dist
|
||||
COPY --from=builder /app/package*.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create docker-compose.yml**
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
tsmusicbot:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: scripts/docker/Dockerfile
|
||||
container_name: tsmusicbot
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/docker/
|
||||
git commit -m "feat: add Dockerfile and docker-compose.yml"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Setup Wizard (first-run flow)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/views/Setup.vue`
|
||||
- Modify: `web/src/router/index.ts` (add setup route)
|
||||
|
||||
- [ ] **Step 1: Create Setup wizard view**
|
||||
|
||||
Create `web/src/views/Setup.vue` — A 4-step wizard:
|
||||
1. Welcome: set admin password, language, theme
|
||||
2. TS Server: address, port, nickname, default channel, test connection button
|
||||
3. Music Account (optional): QR code login for NetEase/QQ, skip button
|
||||
4. Done: success message, redirect to home
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="setup-wizard">
|
||||
<!-- Step Indicator -->
|
||||
<div class="steps">
|
||||
<div v-for="(label, i) in stepLabels" :key="i" class="step" :class="{ active: currentStep === i, done: currentStep > i }">
|
||||
<div class="step-dot">{{ currentStep > i ? '✓' : i + 1 }}</div>
|
||||
<div class="step-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Welcome -->
|
||||
<div v-if="currentStep === 0" class="step-content">
|
||||
<h2>欢迎使用 TSMusicBot</h2>
|
||||
<p class="subtitle">请设置管理员密码以保护你的 WebUI</p>
|
||||
<div class="form-group">
|
||||
<label>管理员密码</label>
|
||||
<input type="password" v-model="adminPassword" placeholder="设置密码" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>语言</label>
|
||||
<select v-model="locale" class="input">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>主题</label>
|
||||
<select v-model="theme" class="input">
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" @click="currentStep = 1">下一步</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: TS Server -->
|
||||
<div v-if="currentStep === 1" class="step-content">
|
||||
<h2>连接 TeamSpeak 服务器</h2>
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input v-model="serverAddress" placeholder="ts.example.com" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>端口</label>
|
||||
<input v-model.number="serverPort" type="number" placeholder="9987" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>机器人昵称</label>
|
||||
<input v-model="nickname" placeholder="MusicBot" class="input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>默认频道 (可选)</label>
|
||||
<input v-model="defaultChannel" placeholder="音乐频道" class="input" />
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" @click="currentStep = 0">上一步</button>
|
||||
<button class="btn-primary" @click="createBotAndNext">下一步</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Music Account -->
|
||||
<div v-if="currentStep === 2" class="step-content">
|
||||
<h2>登录音乐账号 (可选)</h2>
|
||||
<p class="subtitle">登录后可播放 VIP/付费歌曲,跳过则只能播放免费歌曲</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" @click="currentStep = 1">上一步</button>
|
||||
<button class="btn-secondary" @click="currentStep = 3">跳过</button>
|
||||
<button class="btn-primary" @click="currentStep = 3">完成登录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Done -->
|
||||
<div v-if="currentStep === 3" class="step-content done-step">
|
||||
<h2>设置完成!</h2>
|
||||
<p class="subtitle">TSMusicBot 已准备就绪</p>
|
||||
<button class="btn-primary" @click="$router.push('/')">开始使用</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const currentStep = ref(0);
|
||||
const stepLabels = ['欢迎', 'TS 服务器', '音乐账号', '完成'];
|
||||
|
||||
const adminPassword = ref('');
|
||||
const locale = ref('zh');
|
||||
const theme = ref('dark');
|
||||
const serverAddress = ref('');
|
||||
const serverPort = ref(9987);
|
||||
const nickname = ref('MusicBot');
|
||||
const defaultChannel = ref('');
|
||||
|
||||
async function createBotAndNext() {
|
||||
try {
|
||||
await axios.post('/api/bot', {
|
||||
name: `Bot - ${serverAddress.value}`,
|
||||
serverAddress: serverAddress.value,
|
||||
serverPort: serverPort.value,
|
||||
nickname: nickname.value,
|
||||
defaultChannel: defaultChannel.value,
|
||||
autoStart: true,
|
||||
});
|
||||
currentStep.value = 2;
|
||||
} catch (err) {
|
||||
alert('Failed to create bot: ' + (err as Error).message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setup-wizard {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.step {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
background: var(--hover-bg);
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.step.active .step-dot {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.step.done .step-dot {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.step.active .step-label { opacity: 1; color: var(--color-primary); }
|
||||
|
||||
.step-content h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 32px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover { transform: scale(1.04); }
|
||||
&:active { transform: scale(0.96); }
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 32px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.done-step {
|
||||
text-align: center;
|
||||
padding-top: 60px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add setup route to router**
|
||||
|
||||
Add to `web/src/router/index.ts` routes array:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('../views/Setup.vue'),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/views/Setup.vue web/src/router/index.ts
|
||||
git commit -m "feat: add first-run Setup Wizard (4-step guided configuration)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final build and verify
|
||||
|
||||
- [ ] **Step 1: Build full project**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/saopig1/Music/teamspeak music bot"
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `dist/` (backend) and `web/dist/` (frontend) both exist.
|
||||
|
||||
- [ ] **Step 2: Run application**
|
||||
|
||||
```bash
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
Expected: Console shows "TSMusicBot started" and "WebUI: http://localhost:3000"
|
||||
|
||||
- [ ] **Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: Phase 8 complete — deployment scripts, Docker, Setup Wizard. TSMusicBot v0.1.0"
|
||||
```
|
||||
@@ -0,0 +1,372 @@
|
||||
# TSMusicBot 设计文档
|
||||
|
||||
**日期**: 2026-03-29
|
||||
**状态**: 已批准
|
||||
|
||||
## 概述
|
||||
|
||||
TSMusicBot 是一个从零构建的 TeamSpeak 音乐机器人,支持播放网易云音乐和 QQ 音乐。机器人作为 TeamSpeak 客户端连接服务器,用户可通过精美的 WebUI 或 TeamSpeak 内文字命令进行操控。安装体验为一键式,面向不懂代码的用户。
|
||||
|
||||
## 1. 架构设计
|
||||
|
||||
### 整体架构
|
||||
|
||||
单体架构(Monolith),所有组件运行在一个 Node.js 进程中。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TSMusicBot (单体进程) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
||||
│ │ WebUI 层 │ │ TS 命令层 │ │ Bot 管理器 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ Vue.js SPA │ │ !play !next │ │ BotInstance #1 │ │
|
||||
│ │ Express API │ │ !pause !vol │ │ BotInstance #2 │ │
|
||||
│ │ WebSocket │ │ !queue !skip │ │ BotInstance #N │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────┴──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────┴────────────┐ │
|
||||
│ │ 核心服务层 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────┐ │ │
|
||||
│ │ │ 播放引擎 │ │ │
|
||||
│ │ │ FFmpeg 解码 │ │ │
|
||||
│ │ │ Opus 编码 │ │ │
|
||||
│ │ │ 播放队列管理 │ │ │
|
||||
│ │ └───────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────┐ │ │
|
||||
│ │ │ 音乐源服务 │ │ │
|
||||
│ │ │ 网易云 API │ │ │
|
||||
│ │ │ QQ音乐 API │ │ │
|
||||
│ │ │ 搜索/歌单/专辑 │ │ │
|
||||
│ │ └───────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────┐ │ │
|
||||
│ │ │ TS 协议层 │ │ │
|
||||
│ │ │ TCP 命令通道 │ │ │
|
||||
│ │ │ UDP 语音通道 │ │ │
|
||||
│ │ │ (无加密) │ │ │
|
||||
│ │ └───────────────────┘ │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 数据层 │ │
|
||||
│ │ SQLite (用户数据/播放记录) + JSON (配置/Cookie) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 六大核心模块
|
||||
|
||||
1. **TS 协议层** — 实现 TeamSpeak 3 客户端协议(参考 TSLib),包括 TCP 命令通道(登录、频道操作、文字消息)和 UDP 语音通道(Opus 音频包发送)。不实现加密。
|
||||
2. **播放引擎** — 通过 FFmpeg 子进程解码音频源(MP3/FLAC/AAC),输出 PCM 数据,再用 node-opus 编码为 Opus 格式,按 20ms 帧发送到 TS 语音通道。管理播放队列、循环模式、音量控制。
|
||||
3. **音乐源服务** — 内嵌启动 NeteaseCloudMusicApi 和 QQMusicApi 服务,提供统一的搜索、获取歌曲 URL、歌单、专辑、歌词等接口。处理账号登录(扫码/短信/Cookie)和 Cookie 自动刷新。
|
||||
4. **WebUI + API** — Express 提供 REST API 和静态文件服务(Vue.js SPA)。WebSocket 实现实时状态推送(当前播放、队列变化、在线用户)。
|
||||
5. **Bot 管理器** — 管理多个 BotInstance 的生命周期。每个实例拥有独立的 TS 连接、播放引擎和队列。支持通过 WebUI 创建/删除/配置实例。
|
||||
6. **TS 命令层** — 监听 TS 频道文字消息,解析命令(如 !play、!next、!vol),权限检查后转发给对应的 BotInstance 执行。支持自定义命令前缀和别名。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户点歌 (!play 晴天 或 WebUI 搜索)
|
||||
→ 命令层/API 层调用音乐源服务搜索歌曲
|
||||
→ 音乐源服务从网易云/QQ音乐 API 获取歌曲 URL
|
||||
→ 播放引擎: FFmpeg 下载并解码为 PCM → Opus 编码
|
||||
→ TS 协议层: 通过 UDP 将 Opus 音频包发送到 TS 服务器
|
||||
→ WebSocket: 推送播放状态更新到 WebUI
|
||||
```
|
||||
|
||||
## 2. WebUI 设计
|
||||
|
||||
### 设计风格
|
||||
|
||||
参考 YesPlayMusic(Apple Music 风格),核心设计要素:
|
||||
|
||||
- 深色主题为默认,支持浅色主题切换
|
||||
- 毛玻璃导航栏和播放栏 (`backdrop-filter: blur(20px)`)
|
||||
- 专辑封面彩色阴影效果(`node-vibrant` 动态提取主色)
|
||||
- 大字号标题(22-42px),Barlow + PingFang SC / Microsoft YaHei 字体
|
||||
- 圆角卡片(10-14px radius),充足留白
|
||||
- 流畅微交互(hover scale 1.04, active scale 0.96, 0.3s transitions)
|
||||
- 沉浸式歌词页 — 全屏、模糊背景、动态色彩
|
||||
|
||||
### 整体布局
|
||||
|
||||
- 固定毛玻璃导航栏(56px,顶部):Logo、导航链接(发现/搜索/歌单/播放历史)、Bot 状态指示、管理入口
|
||||
- 主内容区:10vw 水平内边距,Bot 实例切换器 + 主要内容 + 右侧边栏(队列/频道信息)
|
||||
- 固定毛玻璃播放栏(56px,底部):当前歌曲信息、播放控制、音量/队列/歌词入口
|
||||
|
||||
### 页面清单
|
||||
|
||||
1. **发现(首页)** — 推荐歌单、热门排行、正在播放状态、快速搜索入口
|
||||
2. **搜索** — 跨平台搜索(网易云/QQ音乐切换),结果展示歌曲/歌单/专辑/歌手
|
||||
3. **歌单详情** — 歌单封面、歌曲列表、一键播放全部、逐首添加到队列
|
||||
4. **歌词页** — 沉浸式全屏歌词,动态背景(专辑色彩提取),逐行高亮
|
||||
5. **播放历史** — 播放记录,按时间/平台筛选,快速重播
|
||||
6. **设置/管理** — Bot 实例管理、TS 服务器连接配置、音乐账号登录(扫码/短信/Cookie)、命令前缀设置、频道行为配置
|
||||
|
||||
## 3. TeamSpeak 命令系统
|
||||
|
||||
用户在 TeamSpeak 频道内发送文字消息控制机器人。默认命令前缀 `!`,可在 WebUI 中自定义。支持私聊和频道消息两种触发方式。
|
||||
|
||||
### 播放控制
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!play <歌名/URL/ID>` | 搜索并播放歌曲(默认网易云,加 -q 切 QQ 音乐) |
|
||||
| `!play -q <歌名>` | 从 QQ 音乐搜索并播放 |
|
||||
| `!pause` | 暂停播放 |
|
||||
| `!resume` | 恢复播放 |
|
||||
| `!stop` | 停止播放并清空队列 |
|
||||
| `!next` / `!skip` | 下一首 |
|
||||
| `!prev` | 上一首 |
|
||||
| `!vol <0-100>` | 设置音量 |
|
||||
| `!now` | 显示当前播放歌曲信息 |
|
||||
|
||||
### 队列管理
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!add <歌名/URL/ID>` | 添加歌曲到队列尾部(不立即播放) |
|
||||
| `!queue` / `!list` | 显示当前播放队列 |
|
||||
| `!clear` | 清空播放队列 |
|
||||
| `!remove <序号>` | 移除队列中指定位置的歌曲 |
|
||||
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式:顺序/单曲循环/随机/随机循环 |
|
||||
|
||||
### 歌单 & 专辑
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!playlist <歌单ID/URL>` | 加载歌单并开始播放 |
|
||||
| `!album <专辑ID/URL>` | 加载专辑并开始播放 |
|
||||
| `!fm` | 开启私人 FM 模式(网易云) |
|
||||
|
||||
### 社交互动
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!vote` | 发起投票切歌(频道内过半数同意即跳过) |
|
||||
| `!lyrics` | 在频道消息中显示当前歌词片段 |
|
||||
|
||||
### 管理命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `!move <频道名/ID>` | 移动机器人到指定频道 |
|
||||
| `!follow` | 机器人跟随你到你所在的频道 |
|
||||
| `!help` | 显示命令帮助列表 |
|
||||
|
||||
### 命令系统特性
|
||||
|
||||
- 命令前缀可自定义(默认 `!`),在 WebUI 设置页修改
|
||||
- 命令别名可配置(如 `!p` → `!play`)
|
||||
- 搜索结果多首时,回复序号选择
|
||||
- 支持私聊和频道消息两种触发方式
|
||||
|
||||
### 权限系统
|
||||
|
||||
- **所有人**: play, add, queue, now, lyrics, vote, help
|
||||
- **管理员**: stop, clear, move, vol, mode, follow
|
||||
- 管理员通过 TS Server Group 或 WebUI 配置指定
|
||||
- WebUI 管理页面需要密码登录
|
||||
|
||||
## 4. 技术栈
|
||||
|
||||
### 后端
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| Node.js 20 LTS | 运行时 |
|
||||
| TypeScript 5.x | 开发语言 |
|
||||
| Express 4.x | Web 框架 |
|
||||
| ws | WebSocket |
|
||||
| better-sqlite3 | SQLite 数据库 |
|
||||
| FFmpeg (子进程) | 音频解码 |
|
||||
| @discordjs/opus 或 opusscript | Opus 编码 |
|
||||
| 自研 TS3 协议 (参考 TSLib) | TeamSpeak 连接 |
|
||||
| Node.js crypto + tweetnacl | Ed25519 身份 |
|
||||
| NeteaseCloudMusicApi (内嵌) | 网易云音乐 API |
|
||||
| QQMusicApi (内嵌) | QQ 音乐 API |
|
||||
| pino | 日志 |
|
||||
|
||||
### 前端
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|------|------|
|
||||
| Vue 3 + Composition API | UI 框架 |
|
||||
| Vite 5.x | 构建工具 |
|
||||
| Vue Router 4 | 路由 |
|
||||
| Pinia | 状态管理 |
|
||||
| SCSS + CSS Variables | 样式 / 主题 |
|
||||
| node-vibrant | 专辑封面主色提取 |
|
||||
| Iconify | 图标 (按需加载) |
|
||||
| axios | HTTP 客户端 |
|
||||
| Barlow + 系统中文字体 | 排版 |
|
||||
|
||||
### 数据存储
|
||||
|
||||
- **SQLite** (better-sqlite3): 用户数据、播放记录、Bot 实例信息
|
||||
- **JSON 文件**: 应用配置(方便手动编辑)、音乐平台 Cookie
|
||||
|
||||
## 5. 项目结构
|
||||
|
||||
```
|
||||
TSMusicBot/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── config.json # 用户配置(JSON,可手动编辑)
|
||||
│
|
||||
├── src/ # 后端源码
|
||||
│ ├── index.ts # 入口:启动所有服务
|
||||
│ │
|
||||
│ ├── ts-protocol/ # TeamSpeak 3 客户端协议实现(无加密)
|
||||
│ │ ├── connection.ts # TCP/UDP 连接管理
|
||||
│ │ ├── identity.ts # TS3 身份生成与管理
|
||||
│ │ ├── commands.ts # TS3 命令编解码
|
||||
│ │ ├── voice.ts # 语音数据包发送/接收
|
||||
│ │ └── client.ts # 高层 TS3 Client 封装
|
||||
│ │
|
||||
│ ├── audio/ # 音频处理
|
||||
│ │ ├── player.ts # 播放引擎(FFmpeg → Opus → TS)
|
||||
│ │ ├── queue.ts # 播放队列管理
|
||||
│ │ └── encoder.ts # Opus 编码封装
|
||||
│ │
|
||||
│ ├── music/ # 音乐源服务
|
||||
│ │ ├── provider.ts # 统一音乐源接口
|
||||
│ │ ├── netease.ts # 网易云适配器
|
||||
│ │ ├── qq.ts # QQ音乐适配器
|
||||
│ │ ├── auth.ts # 账号认证(扫码/短信/Cookie)
|
||||
│ │ └── api-server.ts # 内嵌 API 服务启动器
|
||||
│ │
|
||||
│ ├── bot/ # Bot 核心
|
||||
│ │ ├── instance.ts # BotInstance(一个TS连接+播放引擎)
|
||||
│ │ ├── manager.ts # 多实例管理器
|
||||
│ │ └── commands.ts # TS 文字命令解析与执行
|
||||
│ │
|
||||
│ ├── web/ # Web 服务
|
||||
│ │ ├── server.ts # Express + 静态文件 + WebSocket
|
||||
│ │ ├── api/ # REST API 路由
|
||||
│ │ │ ├── bot.ts # Bot 管理接口
|
||||
│ │ │ ├── music.ts # 搜索/歌单/歌曲接口
|
||||
│ │ │ ├── player.ts # 播放控制接口
|
||||
│ │ │ └── auth.ts # 登录认证接口
|
||||
│ │ └── websocket.ts # WebSocket 事件推送
|
||||
│ │
|
||||
│ └── data/ # 数据层
|
||||
│ ├── database.ts # SQLite 封装
|
||||
│ ├── config.ts # JSON 配置读写
|
||||
│ └── migrations/ # 数据库迁移脚本
|
||||
│
|
||||
├── web/ # 前端源码 (Vue.js SPA)
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.ts
|
||||
│ ├── src/
|
||||
│ │ ├── App.vue
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── router/ # 路由定义
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ │ ├── Home.vue # 发现/首页
|
||||
│ │ │ ├── Search.vue # 搜索
|
||||
│ │ │ ├── Playlist.vue # 歌单详情
|
||||
│ │ │ ├── Lyrics.vue # 沉浸式歌词
|
||||
│ │ │ ├── History.vue # 播放历史
|
||||
│ │ │ └── Settings.vue # 设置/管理
|
||||
│ │ ├── components/ # 可复用组件
|
||||
│ │ │ ├── Player.vue # 底部播放栏
|
||||
│ │ │ ├── Navbar.vue # 顶部导航栏
|
||||
│ │ │ ├── Queue.vue # 播放队列面板
|
||||
│ │ │ ├── SongCard.vue # 歌曲卡片
|
||||
│ │ │ └── CoverArt.vue # 带彩色阴影的封面组件
|
||||
│ │ ├── composables/ # 组合式函数
|
||||
│ │ │ ├── useWebSocket.ts # WebSocket 连接
|
||||
│ │ │ └── usePlayer.ts # 播放器状态
|
||||
│ │ └── styles/ # 全局样式
|
||||
│ │ ├── variables.scss # CSS 变量 / 主题
|
||||
│ │ └── global.scss # 全局样式
|
||||
│ └── public/
|
||||
│
|
||||
├── scripts/ # 部署脚本
|
||||
│ ├── install.sh # Linux 一键安装
|
||||
│ ├── install.bat # Windows 一键安装
|
||||
│ └── docker/
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
│
|
||||
└── data/ # 运行时数据(gitignore)
|
||||
├── tsmusicbot.db # SQLite 数据库
|
||||
├── cookies/ # 音乐平台 Cookie
|
||||
└── logs/ # 日志文件
|
||||
```
|
||||
|
||||
## 6. 部署与安装
|
||||
|
||||
### Windows
|
||||
|
||||
- **安装包 (.exe)**: 使用 pkg 打包 Node.js + 应用为单个 .exe,FFmpeg 作为附带二进制文件,Inno Setup 生成安装程序。双击安装 → 桌面快捷方式 → 双击启动 → 自动打开浏览器。
|
||||
- **便携版 (.zip)**: 解压即用,包含所有依赖,双击 `start.bat` 启动。
|
||||
|
||||
### Linux
|
||||
|
||||
- **一键脚本**: `curl -fsSL https://get.tsmusicbot.com | bash` — 自动检测系统、安装 Node.js/FFmpeg、下载 TSMusicBot、配置 systemd 服务、启动。
|
||||
- **Docker**: `docker run -d -p 3000:3000 tsmusicbot/tsmusicbot` — 镜像基于 node:20-slim,内含 FFmpeg,映射 `./data` 持久化。
|
||||
|
||||
### 首次运行引导 (Setup Wizard)
|
||||
|
||||
4 步引导流程,在 WebUI 中完成:
|
||||
|
||||
1. **欢迎** — 设置 WebUI 管理员密码、选择语言(中/英)、选择主题(深色/浅色)
|
||||
2. **连接 TS 服务器** — 输入服务器地址、端口、昵称、默认频道,点击"测试连接"验证
|
||||
3. **音乐账号(可选)** — 扫码/短信/Cookie 登录网易云或 QQ 音乐账号,跳过则只能播放免费歌曲
|
||||
4. **完成** — Bot 已连接到 TS 服务器,跳转到主界面
|
||||
|
||||
### 端口配置
|
||||
|
||||
- WebUI 默认端口:`3000`(可在 config.json 中修改)
|
||||
- 内嵌网易云 API 端口:`3001`(内部使用,不对外暴露)
|
||||
- 内嵌 QQ 音乐 API 端口:`3002`(内部使用,不对外暴露)
|
||||
|
||||
## 7. 功能清单 (V1)
|
||||
|
||||
### 播放功能
|
||||
- 搜索并播放歌曲(网易云/QQ音乐)
|
||||
- 播放/暂停/停止/上一首/下一首
|
||||
- 音量调节 (0-100)
|
||||
- 4 种播放模式:顺序、单曲循环、随机、随机循环
|
||||
|
||||
### 播放列表
|
||||
- 加载网易云/QQ音乐歌单
|
||||
- 加载专辑
|
||||
- 创建临时播放队列(添加/移除/清空/查看)
|
||||
- 私人 FM 模式(网易云)
|
||||
|
||||
### 频道管理
|
||||
- 自动跟随用户切换频道
|
||||
- 无人时自动暂停
|
||||
- 自动回到默认频道(可配置延迟)
|
||||
- 手动移动到指定频道
|
||||
|
||||
### 社交互动
|
||||
- 频道描述/Bot 头像更新为当前歌曲信息
|
||||
- 投票切歌(频道内过半数同意)
|
||||
- 频道内显示歌词片段
|
||||
|
||||
### 多实例
|
||||
- 一个 WebUI 管理多个 Bot 实例
|
||||
- 每个实例独立连接不同 TS 服务器或频道
|
||||
- 独立的播放队列和配置
|
||||
|
||||
### 账号认证
|
||||
- 扫码登录(网易云/QQ音乐)
|
||||
- 短信登录(网易云)
|
||||
- Cookie 手动导入
|
||||
- Cookie 自动刷新
|
||||
|
||||
## 8. 未来演进
|
||||
|
||||
- 当音频编码出现性能瓶颈时,将 Opus 编码迁移到 Worker Threads
|
||||
- 可扩展的音乐源插件系统(支持添加更多平台)
|
||||
- 移动端 WebUI 适配
|
||||
Reference in new issue
Block a user