merge: resolve conflict, keep idleTimer and profileManager

This commit is contained in:
NeoPecos committed 2026-04-13 17:43:04 +08:00
commit 0e992f2f5f
14 files changed
+1515 -414

No files matched your search

+18 -7
View File
@@ -35,28 +35,39 @@ function ffmpegWorks(bin: string): boolean {
}
}
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
/**
* Resolved once at module load.
*
* Priority: system FFmpeg → bundled ffmpeg-static.
*
* System-installed FFmpeg is always compatible with the running OS/container,
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
* (passes `ffmpeg -version` but crashes during actual audio processing due to
* incompatible glibc or missing shared libraries).
*/
const resolvedFfmpeg: string = (() => {
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// 2. Fall back to bundled ffmpeg-static binary
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32";
// Only try bundled binary if platform matches
if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
}
}
// Fall back to system ffmpeg
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg";
})();
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
+95
View File
@@ -150,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull();
});
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1"));
+27 -12
View File
@@ -20,6 +20,7 @@ export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
add(song: QueuedSong): void {
this.songs.push(song);
@@ -36,33 +37,39 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
// Move the pointer back by one so next() in sequential mode advances
// to the song that shifted into the removed slot. Without this, the
// shifted song is silently skipped because current() incorrectly
// returns it (even though the player is still on the removed song)
// and next() then increments past it. currentIndex may become -1,
// which is fine — it represents "no current song" and next() will
// pick index 0.
this.currentIndex--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
return removed;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.playedIndices.clear();
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -81,12 +88,15 @@ export class PlayQueue {
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0];
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.songs.length);
} while (nextIndex === this.currentIndex && this.songs.length > 1);
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) return null;
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
case PlayMode.RandomLoop: {
@@ -114,6 +124,7 @@ export class PlayQueue {
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -141,6 +152,10 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
+30 -1
View File
@@ -13,8 +13,9 @@ import {
type ParsedCommand,
} from "./commands.js";
import type { Logger } from "../logger.js";
import type { BotDatabase } from "../data/database.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
export interface BotInstanceOptions {
id: string;
@@ -60,8 +61,11 @@ export class BotInstance extends EventEmitter {
private disconnectEmitted = false;
private voteSkipUsers = new Set<string>();
private isAdvancing = false;
<<<<<<< HEAD
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private profileManager: BotProfileManager;
>>>>>>> origin/main
constructor(options: BotInstanceOptions) {
super();
@@ -79,6 +83,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();
}
@@ -140,6 +152,7 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
this.profileManager.onConnect();
this.emit("connected");
}
@@ -355,6 +368,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) {
@@ -423,6 +440,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";
}
@@ -473,6 +493,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";
}
@@ -639,9 +662,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 {
@@ -688,6 +713,10 @@ export class BotInstance extends EventEmitter {
return this.connected;
}
getProfileManager(): BotProfileManager {
return this.profileManager;
}
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
+407
View File
@@ -0,0 +1,407 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- Public API ---
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info("Avatar updated");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
// Bail if a newer song started while we were deleting
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
await httpQuery.clientUpdate({ client_description: text });
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
}
this.logger.info("Description updated");
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const parts: string[] = [];
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
} else {
const nickname = this.buildNickname(song);
if (nickname) {
parts.push(`client_nickname=${escapeTS3(nickname)}`);
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
parts.push("client_away=0");
} else {
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
}
}
if (parts.length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: build a properties object
const props: Record<string, string | number> = {};
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
}
await httpQuery.clientUpdate(props);
} else {
// Use sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
}
this.logger.info("Client properties updated (nickname + away)");
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
if (
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter")
) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, err }, "Profile update failed");
}
}
}
+77
View File
@@ -33,6 +33,24 @@ export interface BotInstance {
identity?: string;
}
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -40,6 +58,8 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
close(): void;
}
@@ -58,6 +78,20 @@ function migrateSchema(db: Database.Database): void {
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
}
function initTables(db: Database.Database): void {
@@ -127,6 +161,24 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
return {
db,
@@ -165,6 +217,31 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0;
},
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
close() {
db.close();
},
+66
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
@@ -7,9 +8,11 @@ import {
listChannels,
listClients,
clientMove,
fileTransferDeleteFile,
type Identity,
type TextMessage,
type ClientInfo,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
import {
@@ -20,6 +23,19 @@ import { TS6HttpQuery } from "./http-query.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
export type { FileUploadInfo } from "@honeybbq/teamspeak-client";
/** Escape a string for use in TS3 ServerQuery-style commands. */
export function escapeTS3(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/ /g, "\\s")
.replace(/\//g, "\\/")
.replace(/\|/g, "\\p")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
export interface TS3ClientOptions {
host: string;
@@ -278,6 +294,56 @@ export class TS3Client extends EventEmitter {
}
}
// --- Raw command & file transfer pass-through ---
async execCommand(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.execCommand(cmd);
}
/** Fire a command without waiting for the server's response. */
async sendCommandNoWait(cmd: string): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.sendCommandNoWait(cmd);
}
async execCommandWithResponse(cmd: string): Promise<Record<string, string>[]> {
if (!this.client) throw new Error("Not connected");
return this.client.execCommandWithResponse(cmd);
}
async fileTransferInitUpload(
channelID: bigint,
path: string,
password: string,
size: bigint,
overwrite = true,
): Promise<FileUploadInfo> {
if (!this.client) throw new Error("Not connected");
return this.client.fileTransferInitUpload(channelID, path, password, size, overwrite);
}
async uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void> {
if (!this.client) throw new Error("Not connected");
await this.client.uploadFileData(host, info, data);
}
async fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void> {
if (!this.client) throw new Error("Not connected");
await fileTransferDeleteFile(this.client, channelID, paths);
}
/** The server host (needed for file transfer TCP connections). */
getHost(): string {
return this.options.host;
}
/** The current channel ID of this client. */
getChannelId(): bigint {
if (!this.client) return 0n;
return this.client.channelID();
}
private voiceFramesSent = 0;
sendVoiceData(opusFrame: Buffer): void {
+21
View File
@@ -343,6 +343,27 @@ export function createPlayerRouter(
}
});
// --- Profile config endpoints ---
router.get("/:botId/profile", (req, res) => {
const bot = (req as any).bot;
res.json(bot.getProfileManager().getConfig());
});
router.put("/:botId/profile", (req, res) => {
try {
const bot = (req as any).bot;
const pm = bot.getProfileManager();
pm.updateConfig(req.body);
if (database) {
database.saveProfileConfig(bot.id, pm.getConfig());
}
res.json(pm.getConfig());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get("/:botId/history", (req, res) => {
if (!database) {
res.json({ history: [] });