mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66ae948371 | ||
|
|
fecda7cce3 | ||
|
|
b9e42d543c | ||
|
|
4406eeacbe | ||
|
|
0408f6ce3c | ||
|
|
47b69a1f95 | ||
|
|
0e992f2f5f | ||
|
|
7785cc972b | ||
|
|
4b412e6277 | ||
|
|
f0a0ae5f2c | ||
|
|
a56dc5bc85 | ||
|
|
1a663156c3 | ||
|
|
e92628a6b4 | ||
|
|
b4ad78ce84 | ||
|
|
30aced6d36 | ||
|
|
da5b34f5f4 | ||
|
|
010aa8aa8c | ||
|
|
05edca4a9a | ||
|
|
e2b1a5e055 | ||
|
|
4eac2e4dde |
No files matched your search
@@ -34,6 +34,7 @@
|
||||
- **B站视频音频提取** — 搜索B站视频,自动提取DASH最高码率音频流播放
|
||||
- **B站热门推荐** — 首页展示B站热门视频和个性化推荐(登录后更准确)
|
||||
- **QR码登录** — 扫码登录网易云/QQ音乐/哔哩哔哩账号,Cookie 自动持久化
|
||||
- **机器人形象自动更新** — 播放时自动更新头像(专辑封面)、昵称(当前歌曲)、描述、Away 状态、频道描述,停止时恢复默认值。每项功能独立可配置,权限不足时自动降级
|
||||
- **多机器人独立播放** — 多个机器人同时在不同服务器或频道播放不同音乐,每个机器人独立的播放队列、进度和音量,WebUI 一键切换控制
|
||||
- **播放历史** — 自动记录所有播放过的歌曲
|
||||
- **懒加载机制** — 歌单只存储元数据,播放时才获取链接(避免链接过期)
|
||||
@@ -41,7 +42,8 @@
|
||||
|
||||
## 截图
|
||||
|
||||
> *截图即将添加*
|
||||
> <img width="2568" height="1408" alt="musicbot1" src="https://github.com/user-attachments/assets/47ba4f62-fae3-4c17-a7f7-b53f00885672" />
|
||||
> <img width="2568" height="1408" alt="musicbot2" src="https://github.com/user-attachments/assets/42f4bef7-d41b-49e3-8c13-b4ce6c822dba" />
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -288,7 +290,8 @@ teamspeak-music-bot/
|
||||
│ ├── bot/ # 机器人核心
|
||||
│ │ ├── commands.ts # 文字命令解析器(前缀、别名、权限)
|
||||
│ │ ├── instance.ts # Bot 实例(绑定 TS3 + 播放器 + 音源)
|
||||
│ │ └── manager.ts # 多实例生命周期管理
|
||||
│ │ ├── manager.ts # 多实例生命周期管理
|
||||
│ │ └── profile.ts # 机器人形象管理(头像/昵称/描述/Away/频道描述)
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── config.ts # JSON 配置文件
|
||||
│ │ └── database.ts # SQLite 数据库(播放历史、实例持久化)
|
||||
@@ -473,6 +476,15 @@ A:`git pull` 拉取最新代码,然后 `npm install && npm run build && npm
|
||||
|
||||
### 最新版本
|
||||
|
||||
**机器人形象自动更新(Bot Profile)**
|
||||
|
||||
- **播放时自动更新 TS 形象**:头像(专辑封面缩略图)、昵称(`♪ 歌名 - 歌手 - 原昵称`)、描述(歌曲信息)、Away 状态、频道描述、"正在播放"频道消息,全部随歌曲切换自动更新。
|
||||
- **停止播放时恢复默认**:头像清除、昵称恢复、Away 显示"等待播放"、描述和频道描述清空。
|
||||
- **权限安全**:每项功能独立检测权限,权限不足时自动禁用该功能(不影响其他功能和播放),重连后重试。
|
||||
- **独立可配置**:6 项功能可通过 REST API(`GET/PUT /api/player/:botId/profile`)独立开关,配置持久化到数据库。
|
||||
- **竞争条件防护**:generation 计数器防止快速切歌时旧头像覆盖新头像;UTF-8 字节长度截断中文昵称;文件传输操作带超时保护。
|
||||
- **TS3 适配**:描述通过 `clientedit`(非 `clientupdate`)设置,需要 `b_client_modify_description` 权限;昵称和 Away 通过合并的单条 `clientupdate` 避免命令队列超时。
|
||||
|
||||
**协议层 & 稳定性**
|
||||
|
||||
- **升级 `@honeybbq/teamspeak-client` 到 `0.2.1`**,移除内置 TS6 兼容层(`ts6-compat.ts`),改用库自带的通用 `clientinit` 协议(`3.?.? [Build: 5680278000]`),TS3/TS6 单一代码路径。
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Bot Profile Manager — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
|
||||
|
||||
## Features
|
||||
|
||||
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|
||||
|---|---------|---------------|-----------------|---------------|---------------|
|
||||
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
|
||||
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
|
||||
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
|
||||
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
resolveAndPlay() success / stop / clear / playNext exhausted
|
||||
↓
|
||||
BotInstance → BotProfileManager.onSongChange(song | null)
|
||||
├─ updateAvatar(coverUrl | null) [fire-and-forget]
|
||||
├─ updateDescription(song | null)
|
||||
├─ updateNickname(song | null)
|
||||
├─ updateAwayStatus(song | null)
|
||||
├─ updateChannelDescription(song | null)
|
||||
└─ sendNowPlayingMessage(song) [only when song != null]
|
||||
|
||||
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
|
||||
BotInstance.disconnect() → (no action needed, server cleans up)
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
|
||||
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
|
||||
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
|
||||
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
|
||||
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
|
||||
|
||||
## TS3Client Layer Extensions
|
||||
|
||||
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
|
||||
|
||||
```typescript
|
||||
execCommand(cmd: string): Promise<void>
|
||||
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
|
||||
fileTransferInitUpload(channelID: bigint, path: string, password: string,
|
||||
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
|
||||
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
|
||||
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
|
||||
```
|
||||
|
||||
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
|
||||
|
||||
## BotProfileManager Detail
|
||||
|
||||
```typescript
|
||||
interface ProfileConfig {
|
||||
avatarEnabled: boolean; // default true
|
||||
descriptionEnabled: boolean; // default true
|
||||
nicknameEnabled: boolean; // default true
|
||||
awayStatusEnabled: boolean; // default true
|
||||
channelDescEnabled: boolean; // default true
|
||||
nowPlayingMsgEnabled: boolean; // default true
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Handling
|
||||
|
||||
- Each feature has an independent `permDenied: boolean` flag.
|
||||
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
|
||||
- On `onConnect()` → reset all flags (new connection may have different permissions).
|
||||
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
|
||||
|
||||
### Nickname Truncation
|
||||
|
||||
- Format: `♪ {songInfo} | {defaultNickname}`
|
||||
- TS3 max nickname: 30 characters
|
||||
- If total > 30: truncate songInfo, keep defaultNickname
|
||||
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
|
||||
|
||||
### Avatar Upload Flow (TS3)
|
||||
|
||||
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
|
||||
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
|
||||
3. `uploadFileData(host, info, Readable.from(buffer))`
|
||||
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
|
||||
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
|
||||
|
||||
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
|
||||
|
||||
### Fire-and-Forget
|
||||
|
||||
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
|
||||
|
||||
## Database Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
|
||||
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
|
||||
```
|
||||
|
||||
## Web API
|
||||
|
||||
```
|
||||
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
|
||||
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- All profile operations are async, never block playback
|
||||
- Uses existing `axios` dependency for image download
|
||||
- MD5 via Node.js built-in `crypto`
|
||||
- No new npm dependencies required
|
||||
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("random mode with single song returns null on next", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode plays each song exactly once then stops", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play();
|
||||
const played = new Set<string>();
|
||||
played.add(queue.current()!.id);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const song = queue.next();
|
||||
if (!song) break;
|
||||
played.add(song.id);
|
||||
}
|
||||
// All 3 songs should have been played
|
||||
expect(played).toEqual(new Set(["A", "B", "C"]));
|
||||
// next() after all played should return null
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: removing currently-playing song does not skip others", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.add(makeSong("D"));
|
||||
queue.play(); // plays A (index 0)
|
||||
const second = queue.next()!; // plays some song
|
||||
// Remove the currently-playing song
|
||||
const curIdx = queue.getCurrentIndex();
|
||||
queue.remove(curIdx);
|
||||
// Remaining songs (excluding A and the removed song) should all be reachable
|
||||
const played = new Set<string>();
|
||||
played.add("A"); // already played via play()
|
||||
played.add(second.id); // played and then removed
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
played.add(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// All 4 original songs should have been played or accounted for
|
||||
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
|
||||
});
|
||||
|
||||
it("random mode: prev does not cause duplicate plays", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.add(makeSong("C"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B or C
|
||||
queue.prev(); // go back — this song is now marked as played
|
||||
// Exhaust remaining songs
|
||||
const ids: string[] = [];
|
||||
let song = queue.next();
|
||||
while (song) {
|
||||
ids.push(song.id);
|
||||
song = queue.next();
|
||||
}
|
||||
// No song ID should appear more than once across the entire session
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("random mode: adding song mid-playback includes the new song", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A
|
||||
queue.next(); // plays B
|
||||
// Add a new song while all existing songs have been played
|
||||
queue.add(makeSong("C"));
|
||||
const song = queue.next();
|
||||
expect(song).not.toBeNull();
|
||||
expect(song!.id).toBe("C");
|
||||
// After C, should stop
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode: setMode preserves current song as played", () => {
|
||||
queue.add(makeSong("A"));
|
||||
queue.add(makeSong("B"));
|
||||
queue.play(); // plays A in sequential mode
|
||||
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
|
||||
// next() should only return B, never A again
|
||||
const song = queue.next();
|
||||
expect(song?.id).toBe("B");
|
||||
expect(queue.next()).toBeNull();
|
||||
});
|
||||
|
||||
it("random-loop mode never returns null", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("1"));
|
||||
|
||||
+27
-12
@@ -20,6 +20,7 @@ export class PlayQueue {
|
||||
private songs: QueuedSong[] = [];
|
||||
private currentIndex = -1;
|
||||
private mode: PlayMode = PlayMode.Sequential;
|
||||
private playedIndices = new Set<number>();
|
||||
|
||||
add(song: QueuedSong): void {
|
||||
this.songs.push(song);
|
||||
@@ -36,33 +37,39 @@ export class PlayQueue {
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (index === this.currentIndex) {
|
||||
// Move the pointer back by one so next() in sequential mode advances
|
||||
// to the song that shifted into the removed slot. Without this, the
|
||||
// shifted song is silently skipped because current() incorrectly
|
||||
// returns it (even though the player is still on the removed song)
|
||||
// and next() then increments past it. currentIndex may become -1,
|
||||
// which is fine — it represents "no current song" and next() will
|
||||
// pick index 0.
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
// Rebuild playedIndices to account for shifted indices
|
||||
const newPlayed = new Set<number>();
|
||||
for (const idx of this.playedIndices) {
|
||||
if (idx === index) continue;
|
||||
newPlayed.add(idx > index ? idx - 1 : idx);
|
||||
}
|
||||
this.playedIndices = newPlayed;
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.songs = [];
|
||||
this.currentIndex = -1;
|
||||
this.playedIndices.clear();
|
||||
}
|
||||
|
||||
play(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
this.playedIndices.clear();
|
||||
this.currentIndex = 0;
|
||||
this.playedIndices.add(0);
|
||||
return this.songs[0];
|
||||
}
|
||||
|
||||
playAt(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
this.playedIndices.clear();
|
||||
this.currentIndex = index;
|
||||
this.playedIndices.add(index);
|
||||
return this.songs[index];
|
||||
}
|
||||
|
||||
@@ -81,12 +88,15 @@ export class PlayQueue {
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
case PlayMode.Random: {
|
||||
if (this.songs.length === 1) return this.songs[0];
|
||||
let nextIndex: number;
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||
const unplayed: number[] = [];
|
||||
for (let i = 0; i < this.songs.length; i++) {
|
||||
if (!this.playedIndices.has(i)) unplayed.push(i);
|
||||
}
|
||||
if (unplayed.length === 0) return null;
|
||||
const nextIndex =
|
||||
unplayed[Math.floor(Math.random() * unplayed.length)];
|
||||
this.currentIndex = nextIndex;
|
||||
this.playedIndices.add(nextIndex);
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.RandomLoop: {
|
||||
@@ -114,6 +124,7 @@ export class PlayQueue {
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
@@ -141,6 +152,10 @@ export class PlayQueue {
|
||||
|
||||
setMode(mode: PlayMode): void {
|
||||
this.mode = mode;
|
||||
this.playedIndices.clear();
|
||||
if (this.currentIndex >= 0) {
|
||||
this.playedIndices.add(this.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
|
||||
Regular → Executable
+77
-1
@@ -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,9 @@ export class BotInstance extends EventEmitter {
|
||||
private disconnectEmitted = false;
|
||||
private voteSkipUsers = new Set<string>();
|
||||
private isAdvancing = false;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private channelUserCount = 0;
|
||||
private profileManager: BotProfileManager;
|
||||
|
||||
constructor(options: BotInstanceOptions) {
|
||||
super();
|
||||
@@ -77,6 +81,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();
|
||||
}
|
||||
@@ -121,6 +133,10 @@ export class BotInstance extends EventEmitter {
|
||||
this.disconnectEmitted = true;
|
||||
this.emit("disconnected");
|
||||
});
|
||||
|
||||
this.tsClient.on("connected", () => {
|
||||
this._startIdlePoller();
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -134,10 +150,12 @@ export class BotInstance extends EventEmitter {
|
||||
throw new Error("Connect aborted by concurrent disconnect");
|
||||
}
|
||||
this.connected = true;
|
||||
this.profileManager.onConnect();
|
||||
this.emit("connected");
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._cancelIdleTimer();
|
||||
this.player.stop();
|
||||
this.connected = false;
|
||||
if (!this.disconnectEmitted) {
|
||||
@@ -147,6 +165,48 @@ export class BotInstance extends EventEmitter {
|
||||
this.tsClient.disconnect();
|
||||
}
|
||||
|
||||
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
|
||||
updateIdleTimeout(minutes: number): void {
|
||||
this.config.idleTimeoutMinutes = minutes;
|
||||
if (minutes === 0) this._cancelIdleTimer();
|
||||
}
|
||||
|
||||
private _startIdlePoller(): void {
|
||||
// 每 30 秒检查一次频道人数
|
||||
const poll = async () => {
|
||||
if (!this.connected) return;
|
||||
try {
|
||||
const clients = await this.tsClient.getClientsInChannel();
|
||||
const userCount = clients.length - 1; // 排除 bot 自身
|
||||
if (userCount <= 0) {
|
||||
this._scheduleIdleCheck();
|
||||
} else {
|
||||
this._cancelIdleTimer();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setTimeout(poll, 30_000);
|
||||
};
|
||||
setTimeout(poll, 30_000);
|
||||
}
|
||||
|
||||
private _scheduleIdleCheck(): void {
|
||||
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
|
||||
const minutes = this.config.idleTimeoutMinutes ?? 0;
|
||||
if (!this.connected || minutes <= 0) return;
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (!this.connected) return;
|
||||
this.logger.info({ idleMinutes: minutes }, "Channel empty, disconnecting due to idle timeout");
|
||||
this.disconnect();
|
||||
}, minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
private _cancelIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
|
||||
const parsed = parseCommand(
|
||||
msg.message,
|
||||
@@ -306,6 +366,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 +438,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 +491,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 +660,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 +711,10 @@ export class BotInstance extends EventEmitter {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getProfileManager(): BotProfileManager {
|
||||
return this.profileManager;
|
||||
}
|
||||
|
||||
getIdentityExport(): string | undefined {
|
||||
return this.tsClient.getIdentityExport();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import axios from "axios";
|
||||
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
||||
import type { ProfileConfig } from "../data/database.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
const TS3_NICKNAME_MAX = 30;
|
||||
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
|
||||
const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
*
|
||||
* Every update is permission-safe: if a feature fails due to insufficient
|
||||
* server permissions, it silently disables itself until the next reconnect.
|
||||
*/
|
||||
export class BotProfileManager {
|
||||
private tsClient: TS3Client;
|
||||
private logger: Logger;
|
||||
private config: ProfileConfig;
|
||||
private defaultNickname: string;
|
||||
|
||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||
private permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Monotonically increasing generation counter. Incremented on every
|
||||
* onSongChange / onConnect call. Long-running operations (avatar
|
||||
* download/upload) check this before committing their result — if
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
logger: Logger,
|
||||
config: ProfileConfig,
|
||||
defaultNickname: string,
|
||||
) {
|
||||
this.tsClient = tsClient;
|
||||
this.logger = logger.child({ component: "profile" });
|
||||
this.config = { ...config };
|
||||
this.defaultNickname = defaultNickname;
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/**
|
||||
* Called when a new song starts playing (song != null) or playback
|
||||
* stops (song == null).
|
||||
*
|
||||
* Commands are serialized to avoid overwhelming the TS3 command queue.
|
||||
* Nickname + away status are merged into a single `clientupdate` call.
|
||||
*
|
||||
* A generation counter guards against stale updates: if a newer
|
||||
* onSongChange fires while the avatar is still downloading, the old
|
||||
* update is discarded.
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
|
||||
// 1. Avatar first — file transfer uses its own response tracker and
|
||||
// must run before sendCommandNoWait calls whose orphaned responses
|
||||
// could confuse the command matcher.
|
||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
||||
if (this.generation !== gen) return; // superseded
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.permDenied = {
|
||||
avatar: false,
|
||||
description: false,
|
||||
nickname: false,
|
||||
awayStatus: false,
|
||||
channelDesc: false,
|
||||
nowPlayingMsg: false,
|
||||
};
|
||||
}
|
||||
|
||||
getConfig(): ProfileConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
updateConfig(partial: Partial<ProfileConfig>): void {
|
||||
Object.assign(this.config, partial);
|
||||
}
|
||||
|
||||
// --- Internal update methods ---
|
||||
|
||||
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
|
||||
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
|
||||
try {
|
||||
if (!coverUrl) {
|
||||
await this.clearAvatar(gen);
|
||||
return;
|
||||
}
|
||||
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
|
||||
const thumbUrl = this.thumbnailUrl(coverUrl);
|
||||
const imageBuffer = await this.downloadImage(thumbUrl);
|
||||
|
||||
// Check generation after the slow download — bail if superseded.
|
||||
if (this.generation !== gen) return;
|
||||
|
||||
if (!imageBuffer || imageBuffer.length === 0) return;
|
||||
if (imageBuffer.length > AVATAR_MAX_BYTES) {
|
||||
this.logger.warn(
|
||||
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
|
||||
"Cover image still too large after resize — skipping avatar update",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrap the file-transfer sequence with a timeout — the TS3
|
||||
// full-client file transfer can silently hang.
|
||||
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
||||
this.logger.info("Avatar updated");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
||||
const host = this.tsClient.getHost();
|
||||
const info = await this.tsClient.fileTransferInitUpload(
|
||||
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
||||
);
|
||||
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
||||
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
||||
}
|
||||
|
||||
private async clearAvatar(gen: number): Promise<void> {
|
||||
try {
|
||||
await this.withTimeout(
|
||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||
FILE_TRANSFER_TIMEOUT_MS,
|
||||
);
|
||||
} catch {
|
||||
// File may not exist or transfer timed out — that's fine
|
||||
}
|
||||
// Bail if a newer song started while we were deleting
|
||||
if (this.generation !== gen) return;
|
||||
try {
|
||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("avatar", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
await httpQuery.clientUpdate({ client_description: text });
|
||||
} else {
|
||||
// clientupdate rejects client_description (error 1538).
|
||||
// Use clientedit on our own clid instead — this is what
|
||||
// TS3AudioBot does via TSLib's ChangeDescription().
|
||||
const clid = this.tsClient.getClientId();
|
||||
if (clid <= 0) return;
|
||||
// Use a 5s timeout — if clientedit hangs, don't block the
|
||||
// remaining profile updates (channeledit, now-playing msg).
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
this.logger.info("Description updated");
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send a single `clientupdate` command that sets nickname
|
||||
* and away status together, avoiding multiple round-trips that can
|
||||
* cause command-queue timeouts on the TS3 protocol.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
const parts: string[] = [];
|
||||
|
||||
// --- Nickname ---
|
||||
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
||||
if (!song) {
|
||||
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
|
||||
} else {
|
||||
const nickname = this.buildNickname(song);
|
||||
if (nickname) {
|
||||
parts.push(`client_nickname=${escapeTS3(nickname)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Away status ---
|
||||
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
||||
if (song) {
|
||||
parts.push("client_away=0");
|
||||
} else {
|
||||
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
try {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
if (httpQuery) {
|
||||
// TS6: build a properties object
|
||||
const props: Record<string, string | number> = {};
|
||||
for (const part of parts) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
|
||||
}
|
||||
await httpQuery.clientUpdate(props);
|
||||
} else {
|
||||
// Use sendCommandNoWait: the TS3 full-client protocol often
|
||||
// doesn't return a timely error response for clientupdate,
|
||||
// causing execCommand to time out after 10s.
|
||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||
}
|
||||
this.logger.info("Client properties updated (nickname + away)");
|
||||
} catch (err) {
|
||||
// Flag both features on permission error
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nickname string that fits within TS3_NICKNAME_MAX.
|
||||
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
|
||||
* not characters.
|
||||
*/
|
||||
private buildNickname(song: QueuedSong): string | null {
|
||||
const songInfo = `${song.name} - ${song.artist}`;
|
||||
const prefix = "\u266A "; // ♪
|
||||
const sep = " - ";
|
||||
const suffix = `${sep}${this.defaultNickname}`;
|
||||
|
||||
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
|
||||
if (overheadBytes >= TS3_NICKNAME_MAX) {
|
||||
// Default nickname alone is too long with decoration — skip
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
|
||||
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
|
||||
return `${prefix}${truncated}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
|
||||
* Appends an ellipsis if truncation occurred, taking its byte cost
|
||||
* into account. Never splits a multi-byte character.
|
||||
*/
|
||||
private truncateUtf8(str: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
|
||||
const ellipsis = "\u2026"; // …
|
||||
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
|
||||
const target = maxBytes - ellipsisBytes;
|
||||
if (target <= 0) return ellipsis;
|
||||
// Walk characters, accumulating byte length
|
||||
let byteLen = 0;
|
||||
let end = 0;
|
||||
for (const ch of str) {
|
||||
const chBytes = Buffer.byteLength(ch, "utf8");
|
||||
if (byteLen + chBytes > target) break;
|
||||
byteLen += chBytes;
|
||||
end += ch.length; // ch.length handles surrogate pairs
|
||||
}
|
||||
return str.slice(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
try {
|
||||
const channelId = this.tsClient.getChannelId();
|
||||
if (channelId === 0n) return; // unknown channel
|
||||
|
||||
if (!song) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
|
||||
`\u4E13\u8F91: ${song.album}`, // 专辑:
|
||||
`\u5E73\u53F0: ${song.platform}`, // 平台:
|
||||
];
|
||||
const desc = lines.join("\\n");
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
|
||||
await this.tsClient.sendTextMessage(text);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("nowPlayingMsg", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
* BiliBili and YouTube covers fall through to the size-check guard.
|
||||
*/
|
||||
private thumbnailUrl(url: string): string {
|
||||
if (url.includes("music.126.net") || url.includes("netease")) {
|
||||
return url.includes("?") ? url : `${url}?param=200y200`;
|
||||
}
|
||||
if (url.includes("qqmusic") || url.includes("qq.com")) {
|
||||
return url.replace(/\/\d+$/, "/200");
|
||||
}
|
||||
if (url.includes("bilivideo") || url.includes("hdslb")) {
|
||||
// BiliBili CDN supports @<w>w_<h>h suffix
|
||||
return url.includes("@") ? url : `${url}@200w_200h`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private async downloadImage(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const resp = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 8000,
|
||||
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
|
||||
});
|
||||
return Buffer.from(resp.data);
|
||||
} catch (err) {
|
||||
this.logger.warn({ err, url }, "Failed to download cover image");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Race a promise against a timeout. */
|
||||
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private handleFeatureError(
|
||||
feature: keyof typeof this.permDenied,
|
||||
err: unknown,
|
||||
): void {
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
// Disable the feature for this session on unrecoverable errors:
|
||||
// - permission / insufficient → server denies the action
|
||||
// - invalid parameter → command not supported by this protocol
|
||||
if (
|
||||
msg.includes("permission") ||
|
||||
msg.includes("insufficient") ||
|
||||
msg.includes("invalid parameter")
|
||||
) {
|
||||
this.permDenied[feature] = true;
|
||||
this.logger.info(
|
||||
{ feature, reason: msg },
|
||||
"Feature disabled for this session (will retry after reconnect)",
|
||||
);
|
||||
} else {
|
||||
this.logger.warn({ feature, err }, "Profile update failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Regular → Executable
+12
@@ -13,6 +13,15 @@ export interface BotConfig {
|
||||
adminGroups: number[];
|
||||
autoReturnDelay: number;
|
||||
autoPauseOnEmpty: boolean;
|
||||
idleTimeoutMinutes: number;
|
||||
// Public base URL used when generating share links (e.g. the bot专属链接).
|
||||
// Leave empty to use the browser's current origin. Example:
|
||||
// "https://music.example.com" or "http://1.2.3.4:3000"
|
||||
publicUrl: string;
|
||||
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
|
||||
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
|
||||
// behind HTTPS-terminating proxies.
|
||||
trustProxy: boolean;
|
||||
}
|
||||
|
||||
export function getDefaultConfig(): BotConfig {
|
||||
@@ -28,6 +37,9 @@ export function getDefaultConfig(): BotConfig {
|
||||
adminGroups: [],
|
||||
autoReturnDelay: 300,
|
||||
autoPauseOnEmpty: true,
|
||||
idleTimeoutMinutes: 0,
|
||||
publicUrl: "",
|
||||
trustProxy: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,24 @@ export interface BotInstance {
|
||||
identity?: string;
|
||||
}
|
||||
|
||||
export interface ProfileConfig {
|
||||
avatarEnabled: boolean;
|
||||
descriptionEnabled: boolean;
|
||||
nicknameEnabled: boolean;
|
||||
awayStatusEnabled: boolean;
|
||||
channelDescEnabled: boolean;
|
||||
nowPlayingMsgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
|
||||
avatarEnabled: true,
|
||||
descriptionEnabled: true,
|
||||
nicknameEnabled: true,
|
||||
awayStatusEnabled: true,
|
||||
channelDescEnabled: true,
|
||||
nowPlayingMsgEnabled: true,
|
||||
};
|
||||
|
||||
export interface BotDatabase {
|
||||
db: Database.Database;
|
||||
addPlayHistory(entry: PlayHistoryEntry): void;
|
||||
@@ -40,6 +58,8 @@ export interface BotDatabase {
|
||||
saveBotInstance(instance: BotInstance): void;
|
||||
getBotInstances(): BotInstance[];
|
||||
deleteBotInstance(id: string): boolean;
|
||||
getProfileConfig(botId: string): ProfileConfig;
|
||||
saveProfileConfig(botId: string, config: ProfileConfig): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
|
||||
if (!names.includes("serverPassword")) {
|
||||
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
// Profile feature flags
|
||||
const profileCols = [
|
||||
"profile_avatar_enabled",
|
||||
"profile_description_enabled",
|
||||
"profile_nickname_enabled",
|
||||
"profile_away_enabled",
|
||||
"profile_channel_desc_enabled",
|
||||
"profile_now_playing_enabled",
|
||||
];
|
||||
for (const col of profileCols) {
|
||||
if (!names.includes(col)) {
|
||||
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initTables(db: Database.Database): void {
|
||||
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
|
||||
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
|
||||
|
||||
const selectProfileConfig = db.prepare(`
|
||||
SELECT profile_avatar_enabled, profile_description_enabled,
|
||||
profile_nickname_enabled, profile_away_enabled,
|
||||
profile_channel_desc_enabled, profile_now_playing_enabled
|
||||
FROM bot_instances WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateProfileConfig = db.prepare(`
|
||||
UPDATE bot_instances SET
|
||||
profile_avatar_enabled = @avatar,
|
||||
profile_description_enabled = @description,
|
||||
profile_nickname_enabled = @nickname,
|
||||
profile_away_enabled = @away,
|
||||
profile_channel_desc_enabled = @channelDesc,
|
||||
profile_now_playing_enabled = @nowPlaying
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
return {
|
||||
db,
|
||||
|
||||
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
getProfileConfig(botId) {
|
||||
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
|
||||
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
|
||||
return {
|
||||
avatarEnabled: row.profile_avatar_enabled === 1,
|
||||
descriptionEnabled: row.profile_description_enabled === 1,
|
||||
nicknameEnabled: row.profile_nickname_enabled === 1,
|
||||
awayStatusEnabled: row.profile_away_enabled === 1,
|
||||
channelDescEnabled: row.profile_channel_desc_enabled === 1,
|
||||
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
|
||||
};
|
||||
},
|
||||
|
||||
saveProfileConfig(botId, config) {
|
||||
updateProfileConfig.run({
|
||||
id: botId,
|
||||
avatar: config.avatarEnabled ? 1 : 0,
|
||||
description: config.descriptionEnabled ? 1 : 0,
|
||||
nickname: config.nicknameEnabled ? 1 : 0,
|
||||
away: config.awayStatusEnabled ? 1 : 0,
|
||||
channelDesc: config.channelDescEnabled ? 1 : 0,
|
||||
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
|
||||
});
|
||||
},
|
||||
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
|
||||
Regular → Executable
+5
-1
@@ -71,6 +71,7 @@ async function main() {
|
||||
bilibiliProvider,
|
||||
database: db,
|
||||
config,
|
||||
configPath: CONFIG_PATH,
|
||||
logger,
|
||||
cookieStore,
|
||||
staticDir: STATIC_DIR,
|
||||
@@ -78,7 +79,10 @@ async function main() {
|
||||
await webServer.start();
|
||||
|
||||
logger.info({ webPort: config.webPort }, "TSMusicBot started");
|
||||
logger.info(`WebUI: http://localhost:${config.webPort}`);
|
||||
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
|
||||
logger.info(
|
||||
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
|
||||
);
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info("Shutting down...");
|
||||
|
||||
@@ -76,7 +76,14 @@ export function createApiServerManager(
|
||||
);
|
||||
} else {
|
||||
const qqModule = (await import("@sansenjian/qq-music-api")) as any;
|
||||
const koaApp = qqModule.default ?? qqModule;
|
||||
// The module's export structure varies between versions:
|
||||
// 2.2.11+: default → Koa app (has .listen)
|
||||
// 2.2.10: default → wrapper object whose .default is the Koa app
|
||||
// older: module itself may be the Koa app
|
||||
const candidate = qqModule.default ?? qqModule;
|
||||
const koaApp = typeof candidate.listen === "function"
|
||||
? candidate
|
||||
: candidate.default ?? null;
|
||||
if (koaApp && typeof koaApp.listen === "function") {
|
||||
qqMusicServer = await new Promise<Server>((resolve, reject) => {
|
||||
const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () =>
|
||||
|
||||
@@ -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;
|
||||
@@ -172,6 +188,9 @@ export class TS3Client extends EventEmitter {
|
||||
};
|
||||
|
||||
this.client = new TS3FullClient(this.identity, addr, this.options.nickname, {
|
||||
// Forward server password to the protocol library so it can be
|
||||
// included in clientinit for password-protected servers
|
||||
serverPassword: this.options.serverPassword,
|
||||
logger: {
|
||||
debug: (msg) => this.logger.debug(msg),
|
||||
info: (msg) => this.logger.info(msg),
|
||||
@@ -278,6 +297,56 @@ export class TS3Client extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raw command & file transfer pass-through ---
|
||||
|
||||
async execCommand(cmd: string): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.execCommand(cmd);
|
||||
}
|
||||
|
||||
/** Fire a command without waiting for the server's response. */
|
||||
async sendCommandNoWait(cmd: string): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.sendCommandNoWait(cmd);
|
||||
}
|
||||
|
||||
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
return this.client.execCommandWithResponse(cmd);
|
||||
}
|
||||
|
||||
async fileTransferInitUpload(
|
||||
channelID: bigint,
|
||||
path: string,
|
||||
password: string,
|
||||
size: bigint,
|
||||
overwrite = true,
|
||||
): Promise<FileUploadInfo> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
|
||||
}
|
||||
|
||||
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await this.client.uploadFileData(host, info, data);
|
||||
}
|
||||
|
||||
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
|
||||
if (!this.client) throw new Error("Not connected");
|
||||
await fileTransferDeleteFile(this.client, channelID, paths);
|
||||
}
|
||||
|
||||
/** The server host (needed for file transfer TCP connections). */
|
||||
getHost(): string {
|
||||
return this.options.host;
|
||||
}
|
||||
|
||||
/** The current channel ID of this client. */
|
||||
getChannelId(): bigint {
|
||||
if (!this.client) return 0n;
|
||||
return this.client.channelID();
|
||||
}
|
||||
|
||||
private voiceFramesSent = 0;
|
||||
|
||||
sendVoiceData(opusFrame: Buffer): void {
|
||||
|
||||
Regular → Executable
+23
@@ -1,11 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import type { BotManager } from "../../bot/manager.js";
|
||||
import type { BotConfig } from "../../data/config.js";
|
||||
import { saveConfig } from "../../data/config.js";
|
||||
import type { Logger } from "../../logger.js";
|
||||
|
||||
export function createBotRouter(
|
||||
botManager: BotManager,
|
||||
config: BotConfig,
|
||||
configPath: string,
|
||||
logger: Logger
|
||||
): Router {
|
||||
const router = Router();
|
||||
@@ -115,6 +117,27 @@ export function createBotRouter(
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/bot/settings — 读取全局 bot 行为设置
|
||||
router.get("/settings", (_req, res) => {
|
||||
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
|
||||
});
|
||||
|
||||
// POST /api/bot/settings — 保存全局 bot 行为设置
|
||||
router.post("/settings", (req, res) => {
|
||||
const { idleTimeoutMinutes } = req.body;
|
||||
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
|
||||
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
|
||||
return;
|
||||
}
|
||||
config.idleTimeoutMinutes = idleTimeoutMinutes;
|
||||
saveConfig(configPath, config);
|
||||
// 通知所有 bot 实例更新定时器
|
||||
for (const bot of botManager.getAllBots()) {
|
||||
bot.updateIdleTimeout(idleTimeoutMinutes);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -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: [] });
|
||||
|
||||
Regular → Executable
+12
-1
@@ -22,6 +22,7 @@ export interface WebServerOptions {
|
||||
bilibiliProvider: MusicProvider;
|
||||
database: BotDatabase;
|
||||
config: BotConfig;
|
||||
configPath: string;
|
||||
logger: Logger;
|
||||
cookieStore?: CookieStore;
|
||||
staticDir?: string;
|
||||
@@ -37,11 +38,21 @@ export function createWebServer(options: WebServerOptions): WebServer {
|
||||
const server = http.createServer(app);
|
||||
const logger = options.logger.child({ component: "web" });
|
||||
|
||||
if (options.config.trustProxy) {
|
||||
// Honor X-Forwarded-* from a reverse proxy (nginx/Caddy/Cloudflare).
|
||||
app.set("trust proxy", true);
|
||||
}
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/config/public-url", (_req, res) => {
|
||||
const raw = (options.config.publicUrl ?? "").trim();
|
||||
res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null });
|
||||
});
|
||||
|
||||
app.use(
|
||||
"/api/bot",
|
||||
createBotRouter(options.botManager, options.config, logger)
|
||||
createBotRouter(options.botManager, options.config, options.configPath, logger)
|
||||
);
|
||||
app.use(
|
||||
"/api/music",
|
||||
|
||||
@@ -59,10 +59,30 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div v-if="linkDialog.open" class="link-dialog-backdrop" @click="closeLinkDialog">
|
||||
<div class="link-dialog" @click.stop>
|
||||
<div class="link-dialog-title">{{ linkDialog.name }} 的专属链接</div>
|
||||
<div class="link-dialog-hint">选中文本并按 Ctrl/Cmd+C 复制,或点击下方按钮</div>
|
||||
<input
|
||||
ref="linkInputRef"
|
||||
class="link-dialog-input"
|
||||
:value="linkDialog.url"
|
||||
readonly
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
/>
|
||||
<div class="link-dialog-actions">
|
||||
<button class="link-dialog-btn primary" @click="copyLinkFromDialog">
|
||||
{{ linkDialog.copied ? '已复制' : '复制链接' }}
|
||||
</button>
|
||||
<button class="link-dialog-btn" @click="closeLinkDialog">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
|
||||
@@ -71,17 +91,92 @@ const activeBot = computed(() => store.activeBot);
|
||||
const dropdownOpen = ref(false);
|
||||
const selectorRef = ref<HTMLElement | null>(null);
|
||||
const togglingBots = ref<Record<string, boolean>>({});
|
||||
const linkInputRef = ref<HTMLInputElement | null>(null);
|
||||
const publicBaseUrl = ref<string | null>(null);
|
||||
|
||||
const linkDialog = reactive({
|
||||
open: false,
|
||||
url: '',
|
||||
name: '',
|
||||
copied: false,
|
||||
});
|
||||
|
||||
function selectBot(id: string) {
|
||||
store.setActiveBotId(id);
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function copyBotLink(id: string) {
|
||||
const url = `${window.location.origin}/bot/${id}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
dropdownOpen.value = false;
|
||||
});
|
||||
function resolveBaseUrl(): string {
|
||||
const base = publicBaseUrl.value;
|
||||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
async function tryClipboardWrite(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
ta.style.pointerEvents = 'none';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBotLink(id: string) {
|
||||
const bot = store.bots.find((b) => b.id === id);
|
||||
const url = `${resolveBaseUrl()}/bot/${id}`;
|
||||
linkDialog.url = url;
|
||||
linkDialog.name = bot?.name ?? '机器人';
|
||||
linkDialog.copied = false;
|
||||
linkDialog.open = true;
|
||||
dropdownOpen.value = false;
|
||||
// Try to copy silently; user can still manually select if it fails.
|
||||
const ok = await tryClipboardWrite(url);
|
||||
if (ok) linkDialog.copied = true;
|
||||
await nextTick();
|
||||
linkInputRef.value?.focus();
|
||||
linkInputRef.value?.select();
|
||||
}
|
||||
|
||||
async function copyLinkFromDialog() {
|
||||
const ok = await tryClipboardWrite(linkDialog.url);
|
||||
if (ok) {
|
||||
linkDialog.copied = true;
|
||||
} else {
|
||||
linkInputRef.value?.focus();
|
||||
linkInputRef.value?.select();
|
||||
}
|
||||
}
|
||||
|
||||
function closeLinkDialog() {
|
||||
linkDialog.open = false;
|
||||
}
|
||||
|
||||
async function loadPublicBaseUrl() {
|
||||
try {
|
||||
const res = await fetch('/api/config/public-url');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { publicUrl?: string | null };
|
||||
if (data.publicUrl) publicBaseUrl.value = data.publicUrl;
|
||||
} catch {
|
||||
// ignore — fall back to window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePower(bot: { id: string; connected: boolean; name: string }) {
|
||||
@@ -108,6 +203,7 @@ function onClickOutside(e: MouseEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onClickOutside);
|
||||
loadPublicBaseUrl();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -376,4 +472,87 @@ onUnmounted(() => {
|
||||
transition: opacity var(--transition-fast);
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.link-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.link-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.link-dialog-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.link-dialog-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.link-dialog-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
user-select: all;
|
||||
-webkit-user-select: all;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.link-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.link-dialog-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--hover-bg);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Regular → Executable
+43
@@ -371,6 +371,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Idle Timeout -->
|
||||
<section class="settings-section">
|
||||
<h2 class="section-title">行为设置</h2>
|
||||
<div class="setting-row">
|
||||
<div class="setting-label">
|
||||
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
|
||||
<div>
|
||||
<div>闲置自动退出</div>
|
||||
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prefix-input-wrap">
|
||||
<input
|
||||
v-model.number="idleTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-sm"
|
||||
style="max-width:80px"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span style="font-size:13px; opacity:0.7">分钟</span>
|
||||
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -661,10 +687,27 @@ async function savePrefix() {
|
||||
// Prefix is saved client-side for now
|
||||
}
|
||||
|
||||
// Idle timeout
|
||||
const idleTimeout = ref(0);
|
||||
|
||||
async function loadIdleTimeout() {
|
||||
try {
|
||||
const res = await axios.get('/api/bot/settings');
|
||||
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function saveIdleTimeout() {
|
||||
try {
|
||||
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchBots(); // Refresh bot status on page visit
|
||||
checkAuthStatus();
|
||||
loadQuality();
|
||||
loadIdleTimeout();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
Reference in new issue
Block a user