mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
chore: Phase 1 complete — project scaffold, config, database, logger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c60bd69ef
commit
9931d8b40d
12 files changed
+6969
No files matched your search
@@ -0,0 +1,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)"
|
||||
```
|
||||
Reference in new issue
Block a user