diff --git a/README.md b/README.md index d62856f..eb22110 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ - **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放 - **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确) - **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化 +- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级 - **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制 - **播放历史** — 自动记录所有播放过的歌曲 - **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期) @@ -289,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 数据库(播放历史、实例持久化) @@ -474,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 单一代码路径。 diff --git a/docs/superpowers/specs/2026-04-12-bot-profile-design.md b/docs/superpowers/specs/2026-04-12-bot-profile-design.md new file mode 100644 index 0000000..10eb423 --- /dev/null +++ b/docs/superpowers/specs/2026-04-12-bot-profile-design.md @@ -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=` | 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 +execCommandWithResponse(cmd: string): Promise[]> +fileTransferInitUpload(channelID: bigint, path: string, password: string, + size: bigint, overwrite?: boolean): Promise +uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise +fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise +``` + +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 → 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 diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 58192bb..3b1f49a 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -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(); 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(); } diff --git a/src/bot/profile.ts b/src/bot/profile.ts new file mode 100644 index 0000000..8994fa8 --- /dev/null +++ b/src/bot/profile.ts @@ -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 { + 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): void { + Object.assign(this.config, partial); + } + + // --- Internal update methods --- + + private async updateAvatar(coverUrl: string | null, gen: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + 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_h suffix + return url.includes("@") ? url : `${url}@200w_200h`; + } + return url; + } + + private async downloadImage(url: string): Promise { + 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(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, 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"); + } + } +} diff --git a/src/data/database.ts b/src/data/database.ts index 8e193e1..3ca4631 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -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 | 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(); }, diff --git a/src/ts-protocol/client.ts b/src/ts-protocol/client.ts index fdf2f1a..b597317 100644 --- a/src/ts-protocol/client.ts +++ b/src/ts-protocol/client.ts @@ -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 { + 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 { + if (!this.client) throw new Error("Not connected"); + await this.client.sendCommandNoWait(cmd); + } + + async execCommandWithResponse(cmd: string): Promise[]> { + 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 { + 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 { + if (!this.client) throw new Error("Not connected"); + await this.client.uploadFileData(host, info, data); + } + + async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise { + 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 { diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 4cd55b3..330ea49 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -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: [] });