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..6d8f5c3 --- /dev/null +++ b/src/bot/profile.ts @@ -0,0 +1,333 @@ +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; + +/** + * 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, + }; + + 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. + */ + async onSongChange(song: QueuedSong | null): Promise { + // 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); + // 2. Combined clientupdate (nickname + away in one fire-and-forget) + await this.updateClientProperties(song); + // 3. Description (TS6 only — instant skip on TS3) + 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 on new connection. */ + onConnect(): void { + 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): Promise { + if (!this.config.avatarEnabled || this.permDenied.avatar) return; + try { + if (!coverUrl) { + await this.clearAvatar(); + 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); + 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 6s timeout — the TS3 + // full-client file transfer can silently hang. + await Promise.race([ + this.doAvatarUpload(imageBuffer), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Avatar upload timed out")), 6000), + ), + ]); + 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(): Promise { + try { + await this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]); + } catch { + // File may not exist — that's fine + } + 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; + + // TS3 full-client protocol rejects client_description in clientupdate + // (error 1538 "invalid parameter"). Only TS6 HTTP Query supports it. + // On TS3 we rely on nickname to display song info instead. + const httpQuery = this.tsClient.getHttpQuery(); + if (!httpQuery) { + this.permDenied.description = true; + this.logger.info( + "Description not available on TS3 — using nickname to display song info", + ); + return; + } + + try { + const text = song + ? `${song.name} - ${song.artist} [${song.album}]` + : ""; + await httpQuery.clientUpdate({ client_description: text }); + 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 songInfo = `${song.name} - ${song.artist}`; + const prefix = "\u266A "; // ♪ + const sep = " - "; + const overhead = prefix.length + sep.length + this.defaultNickname.length; + if (overhead <= TS3_NICKNAME_MAX) { + const maxSongLen = TS3_NICKNAME_MAX - overhead; + const truncated = songInfo.length > maxSongLen + ? songInfo.slice(0, maxSongLen - 1) + "\u2026" + : songInfo; + parts.push(`client_nickname=${escapeTS3(`${prefix}${truncated}${sep}${this.defaultNickname}`)}`); + } + } + } + + // --- 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); + } + } + + 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=`, + ); + this.logger.debug("Channel description cleared"); + 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)}`, + ); + this.logger.debug("Channel description updated"); + } 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); + this.logger.debug("Now-playing message sent"); + } catch (err) { + this.handleFeatureError("nowPlayingMsg", err); + } + } + + // --- Helpers --- + + /** + * Append CDN resize parameters to get a thumbnail suitable for TS3 avatars. + * NetEase and QQ Music CDNs both support URL-based image resizing. + */ + private thumbnailUrl(url: string): string { + if (url.includes("music.126.net") || url.includes("netease")) { + // NetEase Cloud Music CDN: ?param=y + return url.includes("?") ? url : `${url}?param=200y200`; + } + if (url.includes("qqmusic") || url.includes("qq.com")) { + // QQ Music CDN: /200 suffix or similar + return url.replace(/\/\d+$/, "/200"); + } + // Other platforms — return as-is, size check will skip if too large + 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; + } + } + + 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: [] });