mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
This commit is contained in:
1 parent
5647cf6d36
commit
da5b34f5f4
6 files changed
+649
-1
No files matched your search
@@ -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
|
||||||
+28
-1
@@ -13,8 +13,9 @@ import {
|
|||||||
type ParsedCommand,
|
type ParsedCommand,
|
||||||
} from "./commands.js";
|
} from "./commands.js";
|
||||||
import type { Logger } from "../logger.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 type { BotConfig } from "../data/config.js";
|
||||||
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
|
||||||
export interface BotInstanceOptions {
|
export interface BotInstanceOptions {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,6 +61,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
private disconnectEmitted = false;
|
private disconnectEmitted = false;
|
||||||
private voteSkipUsers = new Set<string>();
|
private voteSkipUsers = new Set<string>();
|
||||||
private isAdvancing = false;
|
private isAdvancing = false;
|
||||||
|
private profileManager: BotProfileManager;
|
||||||
|
|
||||||
constructor(options: BotInstanceOptions) {
|
constructor(options: BotInstanceOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -77,6 +79,14 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
this.queue = new PlayQueue();
|
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.setupPlayerEvents();
|
||||||
this.setupTsEvents();
|
this.setupTsEvents();
|
||||||
}
|
}
|
||||||
@@ -134,6 +144,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
throw new Error("Connect aborted by concurrent disconnect");
|
throw new Error("Connect aborted by concurrent disconnect");
|
||||||
}
|
}
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
this.profileManager.onConnect();
|
||||||
this.emit("connected");
|
this.emit("connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +317,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
platform: song.platform,
|
platform: song.platform,
|
||||||
coverUrl: song.coverUrl,
|
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");
|
this.emit("stateChange");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -374,6 +389,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
|
this.logger.warn({ err }, "Profile restore failed on stop");
|
||||||
|
});
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Stopped and queue cleared";
|
return "Stopped and queue cleared";
|
||||||
}
|
}
|
||||||
@@ -424,6 +442,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
|
this.profileManager.onSongChange(null).catch((err) => {
|
||||||
|
this.logger.warn({ err }, "Profile restore failed on clear");
|
||||||
|
});
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
return "Queue cleared";
|
return "Queue cleared";
|
||||||
}
|
}
|
||||||
@@ -590,9 +611,11 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (!started) {
|
if (!started) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
|
this.profileManager.onSongChange(null).catch(() => {});
|
||||||
}
|
}
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -639,6 +662,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.connected;
|
return this.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProfileManager(): BotProfileManager {
|
||||||
|
return this.profileManager;
|
||||||
|
}
|
||||||
|
|
||||||
getIdentityExport(): string | undefined {
|
getIdentityExport(): string | undefined {
|
||||||
return this.tsClient.getIdentityExport();
|
return this.tsClient.getIdentityExport();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
// 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<ProfileConfig>): void {
|
||||||
|
Object.assign(this.config, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal update methods ---
|
||||||
|
|
||||||
|
private async updateAvatar(coverUrl: string | null): Promise<void> {
|
||||||
|
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<never>((_, 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<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(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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=`,
|
||||||
|
);
|
||||||
|
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<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);
|
||||||
|
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=<w>y<h>
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,24 @@ export interface BotInstance {
|
|||||||
identity?: string;
|
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 {
|
export interface BotDatabase {
|
||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||||
@@ -40,6 +58,8 @@ export interface BotDatabase {
|
|||||||
saveBotInstance(instance: BotInstance): void;
|
saveBotInstance(instance: BotInstance): void;
|
||||||
getBotInstances(): BotInstance[];
|
getBotInstances(): BotInstance[];
|
||||||
deleteBotInstance(id: string): boolean;
|
deleteBotInstance(id: string): boolean;
|
||||||
|
getProfileConfig(botId: string): ProfileConfig;
|
||||||
|
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
|
|||||||
if (!names.includes("serverPassword")) {
|
if (!names.includes("serverPassword")) {
|
||||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
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 {
|
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 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 {
|
return {
|
||||||
db,
|
db,
|
||||||
|
|
||||||
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
|
|||||||
return result.changes > 0;
|
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() {
|
close() {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import {
|
import {
|
||||||
Client as TS3FullClient,
|
Client as TS3FullClient,
|
||||||
generateIdentity as genTS3Identity,
|
generateIdentity as genTS3Identity,
|
||||||
@@ -7,9 +8,11 @@ import {
|
|||||||
listChannels,
|
listChannels,
|
||||||
listClients,
|
listClients,
|
||||||
clientMove,
|
clientMove,
|
||||||
|
fileTransferDeleteFile,
|
||||||
type Identity,
|
type Identity,
|
||||||
type TextMessage,
|
type TextMessage,
|
||||||
type ClientInfo,
|
type ClientInfo,
|
||||||
|
type FileUploadInfo,
|
||||||
} from "@honeybbq/teamspeak-client";
|
} from "@honeybbq/teamspeak-client";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
|
|||||||
|
|
||||||
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
export { CODEC_OPUS_MUSIC } from "./voice.js";
|
||||||
export type { ServerProtocol } from "./protocol-detect.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 {
|
export interface TS3ClientOptions {
|
||||||
host: string;
|
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;
|
private voiceFramesSent = 0;
|
||||||
|
|
||||||
sendVoiceData(opusFrame: Buffer): void {
|
sendVoiceData(opusFrame: Buffer): void {
|
||||||
|
|||||||
@@ -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) => {
|
router.get("/:botId/history", (req, res) => {
|
||||||
if (!database) {
|
if (!database) {
|
||||||
res.json({ history: [] });
|
res.json({ history: [] });
|
||||||
|
|||||||
Reference in new issue
Block a user