Compare commits

..
10 Commits
Author SHA1 Message Date
saopig1andClaude Opus 4.6 a56dc5bc85 docs(readme): document bot profile auto-update feature
Add profile feature to features list, architecture tree, and changelog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:52:09 +08:00
saopig1 1a663156c3 Merge branch 'main' of https://github.com/ZHANGTIANYAO1/teamspeak-music-bot into dev 2026-04-13 00:49:40 +08:00
saopig1andClaude Opus 4.6 e92628a6b4 fix(profile): add timeout to clientedit, guard clearAvatar with generation
- Wrap clientedit (description) with 5s timeout to prevent blocking
  channel description and now-playing updates if the command hangs
- Check generation counter in clearAvatar to avoid clearing a newer
  song's avatar when stop→play happens in quick succession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:09 +08:00
saopig1andClaude Opus 4.6 b4ad78ce84 fix(profile): harden against race conditions and edge cases
- Add generation counter to prevent stale avatar uploads from
  overwriting newer song's profile when rapidly skipping tracks
- Fix nickname truncation to use UTF-8 byte length instead of JS
  string length (TS3 counts bytes, Chinese chars are 3 bytes)
- Add timeout to clearAvatar file transfer (was missing)
- Extract withTimeout helper to deduplicate timeout logic
- Add BiliBili CDN thumbnail resize support (@200w_200h)
- Bump generation on reconnect to discard in-flight updates from
  the old connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:44:57 +08:00
saopig1andClaude Opus 4.6 30aced6d36 fix(profile): use clientedit for description, add sendCommandNoWait
Description via clientupdate is rejected (error 1538) on TS3 full-client
protocol. Switch to clientedit on the bot's own clid, which is how
TS3AudioBot handles it. Requires b_client_modify_description permission.

Also add sendCommandNoWait to TS3Client for fire-and-forget commands
(clientupdate, channeledit) that don't return timely responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:39:36 +08:00
saopig1andClaude Opus 4.6 da5b34f5f4 feat(profile): auto-update bot avatar, nickname, and away status based on playing song
Add BotProfileManager that updates the bot's TeamSpeak presence when
songs change: album cover as avatar, song info in nickname, away status
toggled on stop/play. Each feature is independently configurable via
REST API and persisted to the database. Permission-safe — features that
fail due to insufficient server permissions are silently disabled until
reconnect. Description falls back to nickname display on TS3 (only
supported via TS6 HTTP Query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:14:29 +08:00
TIANYAO ZHANG 010aa8aa8c Update README.md 2026-04-13 00:11:41 +08:00
TIANYAO ZHANG 05edca4a9a Merge pull request #27 from ZHANGTIANYAO1/claude/fix-shuffle-loop-bug-FrIbI
Fix random mode to track played songs and prevent duplicates
2026-04-12 23:09:33 +08:00
Claude e2b1a5e055 fix: prevent skipped/duplicate songs in Random mode edge cases
Three corner cases fixed:

1. Removing the currently-playing song caused the next song in the array
   to be silently marked as "played" and skipped. Root cause: playedIndices
   was updated in next() by marking currentIndex, but after remove() shifts
   currentIndex, it pointed to the wrong song. Fix: mark songs as played
   at play-time (in play/playAt/next/prev) instead of at next-request-time.

2. Using prev() in Random mode could cause a song to play twice — the
   song navigated to via prev() was not recorded in playedIndices, so
   next() could randomly select it again. Fix: prev() now marks the
   returned song as played.

3. Switching to Random mode mid-playback could cause the current song to
   repeat because setMode() cleared playedIndices without preserving the
   currently-playing song. Fix: setMode() now re-adds currentIndex after
   clearing.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 15:02:53 +00:00
Claude 4eac2e4dde fix: Random mode now stops after all songs played instead of looping forever
In Random (shuffle) mode, the queue's next() method would return the same
song indefinitely when only one song was in the playlist, and never terminate
even with multiple songs. This happened because played songs were not tracked.

Added a playedIndices Set to track which songs have already been played in
Random mode. Once all songs have been played once, next() returns null to
stop playback — matching the expected behavior where Random plays each song
once in random order, while RandomLoop is the mode for infinite shuffling.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 14:53:15 +00:00
9 changed files with 859 additions and 15 deletions

No files matched your search

+14 -2
View File
@@ -34,6 +34,7 @@
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
- **播放历史** — 自动记录所有播放过的歌曲
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
@@ -41,7 +42,8 @@
## 截图
> *截图即将添加*
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
## 快速开始
@@ -288,7 +290,8 @@ teamspeak-music-bot/
│ ├── bot/ # 机器人核心
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
│ │ └── manager.ts # 多实例生命周期管理
│ │ ├── manager.ts # 多实例生命周期管理
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
│ ├── data/ # 数据层
│ │ ├── config.ts # JSON 配置文件
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
@@ -473,6 +476,15 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
### 最新版本
**机器人形象自动更新(Bot Profile)**
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
**协议层 & 稳定性**
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
@@ -0,0 +1,124 @@
# Bot Profile Manager — Design Spec
## Goal
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
## Features
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|---|---------|---------------|-----------------|---------------|---------------|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
## Architecture
```
resolveAndPlay() success / stop / clear / playNext exhausted
↓
BotInstance → BotProfileManager.onSongChange(song | null)
├─ updateAvatar(coverUrl | null) [fire-and-forget]
├─ updateDescription(song | null)
├─ updateNickname(song | null)
├─ updateAwayStatus(song | null)
├─ updateChannelDescription(song | null)
└─ sendNowPlayingMessage(song) [only when song != null]
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
BotInstance.disconnect() → (no action needed, server cleans up)
```
## File Changes
| File | Change |
|------|--------|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
## TS3Client Layer Extensions
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
```typescript
execCommand(cmd: string): Promise<void>
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
fileTransferInitUpload(channelID: bigint, path: string, password: string,
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
```
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
## BotProfileManager Detail
```typescript
interface ProfileConfig {
avatarEnabled: boolean; // default true
descriptionEnabled: boolean; // default true
nicknameEnabled: boolean; // default true
awayStatusEnabled: boolean; // default true
channelDescEnabled: boolean; // default true
nowPlayingMsgEnabled: boolean; // default true
}
```
### Permission Handling
- Each feature has an independent `permDenied: boolean` flag.
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
- On `onConnect()` → reset all flags (new connection may have different permissions).
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
### Nickname Truncation
- Format: `♪ {songInfo} | {defaultNickname}`
- TS3 max nickname: 30 characters
- If total > 30: truncate songInfo, keep defaultNickname
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
### Avatar Upload Flow (TS3)
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
3. `uploadFileData(host, info, Readable.from(buffer))`
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
### Fire-and-Forget
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
## Database Migration
```sql
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
```
## Web API
```
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
```
## Constraints
- All profile operations are async, never block playback
- Uses existing `axios` dependency for image download
- MD5 via Node.js built-in `crypto`
- No new npm dependencies required
+95
View File
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull();
});
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1"));
+27 -12
View File
@@ -20,6 +20,7 @@ export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
add(song: QueuedSong): void {
this.songs.push(song);
@@ -36,33 +37,39 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
// Move the pointer back by one so next() in sequential mode advances
// to the song that shifted into the removed slot. Without this, the
// shifted song is silently skipped because current() incorrectly
// returns it (even though the player is still on the removed song)
// and next() then increments past it. currentIndex may become -1,
// which is fine — it represents "no current song" and next() will
// pick index 0.
this.currentIndex--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -81,12 +88,15 @@ export class PlayQueue {
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);
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
@@ -114,6 +124,7 @@ export class PlayQueue {
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -141,6 +152,10 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
+28 -1
View File
@@ -13,8 +13,9 @@ import {
type ParsedCommand,
} from "./commands.js";
import type { Logger } from "../logger.js";
import type { BotDatabase } from "../data/database.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
export interface BotInstanceOptions {
id: string;
@@ -60,6 +61,7 @@ export class BotInstance extends EventEmitter {
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
private profileManager: BotProfileManager;
constructor(options: BotInstanceOptions) {
super();
@@ -77,6 +79,14 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
const profileConfig = this.database.getProfileConfig(this.id);
this.profileManager = new BotProfileManager(
this.tsClient,
this.logger,
profileConfig,
options.tsOptions.nickname,
);
this.setupPlayerEvents();
this.setupTsEvents();
}
@@ -134,6 +144,7 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
this.profileManager.onConnect();
this.emit("connected");
}
@@ -306,6 +317,10 @@ export class BotInstance extends EventEmitter {
platform: song.platform,
coverUrl: song.coverUrl,
});
// Update bot presence (fire-and-forget — never blocks playback)
this.profileManager.onSongChange(song).catch((err) => {
this.logger.warn({ err }, "Profile update failed after song change");
});
this.emit("stateChange");
return true;
} catch (err) {
@@ -374,6 +389,9 @@ export class BotInstance extends EventEmitter {
private cmdStop(): string {
this.player.stop();
this.queue.clear();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on stop");
});
this.emit("stateChange");
return "Stopped and queue cleared";
}
@@ -424,6 +442,9 @@ export class BotInstance extends EventEmitter {
private cmdClear(): string {
this.player.stop();
this.queue.clear();
this.profileManager.onSongChange(null).catch((err) => {
this.logger.warn({ err }, "Profile restore failed on clear");
});
this.emit("stateChange");
return "Queue cleared";
}
@@ -590,9 +611,11 @@ export class BotInstance extends EventEmitter {
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
this.emit("stateChange");
} finally {
@@ -639,6 +662,10 @@ export class BotInstance extends EventEmitter {
return this.connected;
}
getProfileManager(): BotProfileManager {
return this.profileManager;
}
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
+407
View File
@@ -0,0 +1,407 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- Public API ---
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info("Avatar updated");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
await httpQuery.clientUpdate({ client_description: text });
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
}
this.logger.info("Description updated");
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const parts: string[] = [];
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
} else {
const nickname = this.buildNickname(song);
if (nickname) {
parts.push(`client_nickname=${escapeTS3(nickname)}`);
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
parts.push("client_away=0");
} else {
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
}
}
if (parts.length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: build a properties object
const props: Record<string, string | number> = {};
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
}
await httpQuery.clientUpdate(props);
} else {
// Use sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
}
this.logger.info("Client properties updated (nickname + away)");
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
if (
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter")
) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, err }, "Profile update failed");
}
}
}
+77
View File
@@ -33,6 +33,24 @@ export interface BotInstance {
identity?: string;
}
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -40,6 +58,8 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
close(): void;
}
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
}
function initTables(db: Database.Database): void {
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
return {
db,
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0;
},
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
close() {
db.close();
},
+66
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
@@ -7,9 +8,11 @@ import {
listChannels,
listClients,
clientMove,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
import {
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
/** Escape a string for use in TS3 ServerQuery-style commands. */
export function escapeTS3(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/ /g, "\\s")
.replace(/\//g, "\\/")
.replace(/\|/g, "\\p")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
export interface TS3ClientOptions {
host: string;
@@ -278,6 +294,56 @@ export class TS3Client extends EventEmitter {
}
}
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.execCommand(cmd);
}
/** Fire a command without waiting for the server's response. */
async sendCommandNoWait(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.sendCommandNoWait(cmd);
}
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
if (!this.client) throw new Error("Not connected");
return this.client.execCommandWithResponse(cmd);
}
async fileTransferInitUpload(
channelID: bigint,
path: string,
password: string,
size: bigint,
overwrite = true,
): Promise<FileUploadInfo> {
if (!this.client) throw new Error("Not connected");
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
}
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.uploadFileData(host, info, data);
}
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
if (!this.client) throw new Error("Not connected");
await fileTransferDeleteFile(this.client, channelID, paths);
}
/** The server host (needed for file transfer TCP connections). */
getHost(): string {
return this.options.host;
}
/** The current channel ID of this client. */
getChannelId(): bigint {
if (!this.client) return 0n;
return this.client.channelID();
}
private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void {
+21
View File
@@ -343,6 +343,27 @@ export function createPlayerRouter(
}
});
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => {
if (!database) {
res.json({ history: [] });