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:
saopig1andClaude Opus 4.6 committed 2026-03-30 00:21:55 +08:00
1 parent 7c60bd69ef
commit 9931d8b40d
12 files changed
+6969

No files matched your search

@@ -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"
```