Compare commits

...
Author SHA1 Message Date
Claude 79c50c6f29 Fix QQ Music cookie auth flow against @sansenjian/qq-music-api 2.x
The previous implementation pointed at endpoints and response shapes that
don't match the current @sansenjian/qq-music-api. As a result the bot was
happily saving a user-supplied cookie to disk and sending it on every
request, but every downstream login check returned "not logged in".

- getAuthStatus:
    /getUserAvatar is not a registered route (the real one is
    /user/getUserAvatar), and even then that handler only builds a static
    avatar URL from a uin and never talks to QQ, so it can't validate a
    cookie. Parse uin from the cookie and round-trip it through
    /user/getUserPlaylists, which actually hits QQ Music with the cookie,
    then derive the avatar URL from the uin.

- getQrCode:
    checkQQLoginQr requires BOTH qrsig and ptqrtoken, but only qrsig was
    being returned as the poll key. Encode both into the opaque key.

- checkQrCodeStatus:
    /checkQQLoginQr is POST, not GET, and the response is
    { isOk, refresh, session: { cookie, ... } } rather than { code, cookie }.
    Switch to POST, pass both params, and read session.cookie on success.

https://claude.ai/code/session_01LH83VXwxPY8f9RQ5HgfPby
2026-04-10 17:39:42 +00:00
TIANYAO ZHANG a8eb1bebd9 Merge pull request #18 from ZHANGTIANYAO1/claude/add-update-instructions-9AOnV
Add update/upgrade instructions for all installation methods
2026-04-07 20:52:42 +08:00
Claude 5a7112e288 Add detailed update/upgrade instructions to README
Add a new "更新升级" section covering update steps for all deployment
methods: Windows, manual install, Docker, and Linux systemd.

https://claude.ai/code/session_01BrEtGk284qmnMwzi6zcEpD
2026-04-07 12:51:41 +00:00
TIANYAO ZHANG bbc776292b Merge pull request #17 from ZHANGTIANYAO1/claude/add-play-script-Ni84L
Add "play" npm script as alias for "start"
2026-04-07 20:46:01 +08:00
Claude d5a7351be7 Add missing "play" script to package.json
Adds a "play" script as an alias for "start" (both run `node dist/index.js`).
This resolves confusion where users might try `npm run play` based on the
bot's music-playing nature.

Fixes #11

https://claude.ai/code/session_01GC6qsKsmiroNkruLENDhPq
2026-04-07 12:44:18 +00:00
TIANYAO ZHANG 5f35c32a22 Merge pull request #16 from ZHANGTIANYAO1/claude/fix-docker-restart-crash-md7Wj
Persist bot identity and respect autoStart flag on restart
2026-04-07 20:41:35 +08:00
Claude 2559701e55 Fix Docker restart crash: respect autoStart flag and persist identity
Root cause: on Docker restart, loadSavedBots() unconditionally connected
ALL saved bots regardless of autoStart flag, causing a rapid
connect/disconnect loop. Additionally, bot identities were regenerated
on every restart, causing TS server conflicts with stale sessions.

Changes:
- loadSavedBots() now only auto-connects bots with autoStart=true
- startBot/stopBot persist autoStart state so restart behavior matches
  user intent
- Bot TS3 identity is persisted to database and reused across restarts
- Database schema migrated to include identity column
- TS3Client.connect() cleans up existing connection before reconnecting
- Stagger bot connections by 1s to avoid overwhelming the TS server

https://claude.ai/code/session_01L2kEV2M1QFWMCyPtLU5LgC
2026-04-07 12:15:39 +00:00
TIANYAO ZHANG b97f4a1d86 Merge pull request #15 from ZHANGTIANYAO1/claude/start-music-api-servers-o3HHa
Fix ffmpeg spawn failure causing infinite retry loop
2026-04-07 20:09:13 +08:00
Claude d1fc7baa5d Fix ffmpeg spawn failure causing infinite retry loop
Two issues fixed:
1. Cross-platform ffmpeg-static resolution: skip Windows .exe paths on Linux
   and always fall back to "ffmpeg" instead of a known-bad path
2. Prevent trackEnd cascade when ffmpeg spawn fails — track consecutive
   failures and stop after 3, suppressing trackEnd on spawn errors

https://claude.ai/code/session_013vHRF8BbDGjZLqheFS85Q6
2026-04-07 12:03:33 +00:00
TIANYAO ZHANG 01940e74dd Merge pull request #6 from ZHANGTIANYAO1/claude/setup-teamspeak-bot-QBfaS
Add comprehensive error handling to prevent process crashes
2026-04-03 01:42:04 +08:00
Claude 1c88dd7a35 Add comprehensive error handling to prevent process crashes
- Global uncaughtException/unhandledRejection handlers in index.ts
- FFmpeg stdout/stderr stream error handlers in player.ts
- HTTP server and WebSocket server error handlers in server.ts
- Safe WebSocket broadcast with try-catch in websocket.ts
- Catch async errors from textMessage handler in instance.ts
- Reset voiceFramesSent counter on reconnect in client.ts

https://claude.ai/code/session_01EjpEsC2GCsvwbu4n3XC8EE
2026-03-31 16:58:14 +00:00
12 changed files with 272 additions and 44 deletions

No files matched your search

+64
View File
@@ -124,6 +124,70 @@ sudo ./scripts/install.sh
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
## 更新升级
### Windows 用户
```
1. 双击 scripts\stop.bat 停止运行中的机器人(或手动关闭窗口)
2. 在项目目录打开命令行,执行 git pull
3. 双击 scripts\setup.bat 重新安装依赖并构建
4. 双击 scripts\start.bat 启动
```
### 手动安装用户(所有系统)
```bash
# 停止当前运行的机器人(Ctrl+C 或 kill 进程)
# 拉取最新代码
git pull
# 重新安装依赖(如有新增依赖)
npm install
cd web && npm install && cd ..
# 重新构建
npm run build
# 启动
npm start
```
### Docker 用户
```bash
cd scripts/docker
# 拉取最新代码
git pull
# 重新构建并启动(数据自动保留)
docker-compose up -d --build
```
> 数据(数据库、Cookie、日志)保存在 Docker 命名卷 `tsmusicbot-data` 中,更新不会丢失。
### Linux systemd 用户
```bash
# 停止服务
sudo systemctl stop tsmusicbot
# 拉取最新代码
git pull
# 重新安装依赖并构建
npm install
cd web && npm install && cd ..
npm run build
# 重新启动服务
sudo systemctl start tsmusicbot
```
> **提示:** 更新不会影响你的 `config.json` 配置文件、数据库和登录 Cookie,所有数据会自动保留。
## 使用说明
### 首次配置
+1
View File
@@ -8,6 +8,7 @@
"build": "tsc && npm run build:web",
"build:web": "cd web && npm run build",
"start": "node dist/index.js",
"play": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
+50 -5
View File
@@ -37,15 +37,23 @@ function ffmpegWorks(bin: string): boolean {
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
const resolvedFfmpeg: string = (() => {
if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
// 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: return whatever we have, will fail at runtime with clear error
return ffmpegPath ?? "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. */
@@ -79,6 +87,9 @@ export class AudioPlayer extends EventEmitter {
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private ffmpegPaused = false;
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
private consecutiveFailures = 0;
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
constructor(logger: Logger) {
super();
@@ -94,6 +105,18 @@ export class AudioPlayer extends EventEmitter {
this.seekOffset = seekSeconds;
this.framesPlayed = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
// Prevent rapid-fire spawn attempts when ffmpeg is broken
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error(
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
);
this.state = "idle";
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
return;
}
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
@@ -128,6 +151,14 @@ export class AudioPlayer extends EventEmitter {
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
// Prevent stream errors from crashing the process
this.ffmpeg.stdout!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stdout error");
});
this.ffmpeg.stderr!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stderr error");
});
let gotFirstData = false;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (!gotFirstData) {
@@ -152,6 +183,8 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
@@ -202,7 +235,13 @@ export class AudioPlayer extends EventEmitter {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
this.emit("trackEnd");
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
if (this.spawnFailed) {
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
} else {
this.consecutiveFailures = 0; // Reset on successful track completion
this.emit("trackEnd");
}
}
return;
}
@@ -295,12 +334,18 @@ export class AudioPlayer extends EventEmitter {
}
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
}
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol));
}
+9 -1
View File
@@ -99,7 +99,9 @@ export class BotInstance extends EventEmitter {
private setupTsEvents(): void {
this.tsClient.on("textMessage", (msg: TS3TextMessage) => {
this.handleTextMessage(msg);
this.handleTextMessage(msg).catch((err) => {
this.logger.error({ err }, "Unhandled error in text message handler");
});
});
this.tsClient.on("disconnected", () => {
@@ -261,6 +263,8 @@ export class BotInstance extends EventEmitter {
this.queue.add({ ...song, platform: provider.platform });
this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!);
if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`;
@@ -549,4 +553,8 @@ export class BotInstance extends EventEmitter {
isConnected(): boolean {
return this.connected;
}
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
}
+42 -9
View File
@@ -131,12 +131,25 @@ export class BotManager {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect();
// Mark as autoStart so it reconnects on Docker restart, and persist identity
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
}
}
stopBot(id: string): void {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
bot.disconnect();
// Mark as not autoStart so it stays stopped on Docker restart
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
this.database.saveBotInstance({ ...saved, autoStart: false });
}
}
async loadSavedBots(): Promise<void> {
@@ -150,6 +163,7 @@ export class BotManager {
port: saved.serverPort,
queryPort: 10011,
nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined,
},
@@ -163,18 +177,30 @@ export class BotManager {
this.bots.set(saved.id, bot);
// Auto-connect in background (non-blocking, won't affect other bots)
bot.connect().then(() => {
// Only auto-connect bots that have autoStart enabled
if (saved.autoStart) {
bot.connect().then(() => {
// Persist identity after successful connection for future restarts
this.persistBotIdentity(saved, bot);
this.logger.info(
{ botId: saved.id, name: saved.name },
"Auto-connected saved bot"
);
}).catch((err) => {
this.logger.error(
{ err, botId: saved.id, name: saved.name },
"Failed to auto-connect bot (start manually from Settings)"
);
});
// Stagger connections to avoid overwhelming the TS server
await new Promise((resolve) => setTimeout(resolve, 1000));
} else {
this.logger.info(
{ botId: saved.id, name: saved.name },
"Auto-connected saved bot"
"Loaded bot (autoStart disabled, not connecting)"
);
}).catch((err) => {
this.logger.error(
{ err, botId: saved.id, name: saved.name },
"Failed to auto-connect bot (start manually from Settings)"
);
});
}
}
this.logger.info(
@@ -183,6 +209,13 @@ export class BotManager {
);
}
private persistBotIdentity(saved: import("../data/database.js").BotInstance, bot: BotInstance): void {
const identity = bot.getIdentityExport();
if (identity && identity !== saved.identity) {
this.database.saveBotInstance({ ...saved, identity });
}
}
shutdown(): void {
for (const bot of this.bots.values()) {
bot.disconnect();
+24 -6
View File
@@ -24,6 +24,7 @@ export interface BotInstance {
defaultChannel: string;
channelPassword: string;
autoStart: boolean;
identity?: string;
}
export interface BotDatabase {
@@ -36,6 +37,15 @@ export interface BotDatabase {
close(): void;
}
function migrateSchema(db: Database.Database): void {
// Add identity column if it doesn't exist (migration for existing databases)
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
const hasIdentity = columns.some((c) => c.name === "identity");
if (!hasIdentity) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
}
function initTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS play_history (
@@ -58,7 +68,8 @@ function initTables(db: Database.Database): void {
nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL,
channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0
autoStart INTEGER NOT NULL DEFAULT 0,
identity TEXT
);
`);
}
@@ -67,6 +78,7 @@ export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
initTables(db);
migrateSchema(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -78,8 +90,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart)
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
@@ -87,7 +99,8 @@ export function createDatabase(dbPath: string): BotDatabase {
nickname = excluded.nickname,
defaultChannel = excluded.defaultChannel,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart
autoStart = excluded.autoStart,
identity = excluded.identity
`);
const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
@@ -109,14 +122,19 @@ export function createDatabase(dbPath: string): BotDatabase {
upsertInstance.run({
...instance,
autoStart: instance.autoStart ? 1 : 0,
identity: instance.identity ?? null,
});
},
getBotInstances() {
const rows = selectInstances.all() as Array<
Omit<BotInstance, "autoStart"> & { autoStart: number }
Omit<BotInstance, "autoStart" | "identity"> & { autoStart: number; identity: string | null }
>;
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 }));
return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
identity: r.identity ?? undefined,
}));
},
deleteBotInstance(id) {
+8
View File
@@ -25,6 +25,14 @@ async function main() {
saveConfig(CONFIG_PATH, config);
const logger = createLogger(LOG_DIR);
// Prevent unhandled errors from crashing the process
process.on("uncaughtException", (err) => {
logger.error({ err }, "Uncaught exception");
});
process.on("unhandledRejection", (reason) => {
logger.error({ reason }, "Unhandled promise rejection");
});
const db = createDatabase(DB_PATH);
const apiServer = createApiServerManager(
+46 -22
View File
@@ -153,29 +153,41 @@ export class QQMusicProvider implements MusicProvider {
async getQrCode(): Promise<QrCodeResult> {
const res = await this.api.get("/getQQLoginQr");
const qrsig = res.data?.qrsig ?? "";
const ptqrtoken = res.data?.ptqrtoken ?? "";
// @sansenjian/qq-music-api's checkQQLoginQr requires BOTH qrsig and
// ptqrtoken, so encode both into the opaque key the caller polls with.
return {
qrUrl: "",
qrImg: res.data?.img ?? "",
key: res.data?.qrsig ?? res.data?.ptqrtoken ?? "",
key: qrsig && ptqrtoken ? `${qrsig}|${ptqrtoken}` : "",
};
}
async checkQrCodeStatus(
key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
const res = await this.api.get("/checkQQLoginQr", {
params: { qrsig: key },
});
const code = res.data?.code ?? res.data?.response?.code;
if (code === 0) {
if (res.data?.cookie) {
this.cookie = res.data.cookie;
const [qrsig, ptqrtoken] = key.split("|");
if (!qrsig || !ptqrtoken) return "expired";
try {
// /checkQQLoginQr is POST, and the success body is
// { isOk, refresh, message, session: { cookie, ... } }
const res = await this.api.post("/checkQQLoginQr", null, {
params: { qrsig, ptqrtoken },
});
if (res.data?.isOk) {
const cookie = res.data?.session?.cookie;
if (cookie) {
this.cookie = cookie;
}
return "confirmed";
}
return "confirmed";
if (res.data?.refresh) return "expired";
// The upstream lib does not distinguish "scanned" from "waiting"
return "waiting";
} catch {
return "expired";
}
if (code === 1) return "scanned";
if (code === 2) return "waiting";
return "expired";
}
setCookie(cookie: string): void {
@@ -188,20 +200,32 @@ export class QQMusicProvider implements MusicProvider {
async getAuthStatus(): Promise<AuthStatus> {
if (!this.cookie) return { loggedIn: false };
// @sansenjian/qq-music-api does not expose a dedicated "am I logged in"
// endpoint. /user/getUserAvatar only builds a static avatar URL from a
// uin and never talks to QQ, so it cannot be used to validate a cookie.
// Instead, parse the uin from the cookie and round-trip it through
// /user/getUserPlaylists, which actually hits QQ Music's servers using
// the provided cookie. A successful response (code=0) means the cookie
// is still valid.
const uinMatch = this.cookie.match(/(?:^|;\s*)uin=([^;]+)/);
const uin = uinMatch?.[1];
if (!uin) return { loggedIn: false };
try {
const res = await this.api.get("/getUserAvatar", {
params: { ...this.cookieParams },
const res = await this.api.get("/user/getUserPlaylists", {
params: { uin, limit: 1, ...this.cookieParams },
});
if (res.data?.response?.data) {
return {
loggedIn: true,
nickname: res.data.response.data.nickname,
avatarUrl: res.data.response.data.headpic,
};
if (res.data?.response?.code !== 0) {
return { loggedIn: false };
}
return {
loggedIn: true,
nickname: `QQ ${uin}`,
avatarUrl: `https://q.qlogo.cn/headimg_dl?dst_uin=${uin}&spec=140`,
};
} catch {
// ignore
return { loggedIn: false };
}
return { loggedIn: false };
}
}
+13
View File
@@ -53,6 +53,18 @@ export class TS3Client extends EventEmitter {
}
async connect(): Promise<void> {
// Clean up any existing connection before creating a new one
if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting");
try {
await this.client.disconnect();
} catch {
// Ignore errors during cleanup
}
this.client = null;
this.clientId = 0;
}
const addr = `${this.options.host}:${this.options.port}`;
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");
@@ -114,6 +126,7 @@ export class TS3Client extends EventEmitter {
await this.client.connect();
await this.client.waitConnected();
this.clientId = this.client.clientID();
this.voiceFramesSent = 0;
this.logger.info({ clientId: this.clientId }, "Logged in (visible client)");
// Join default channel if specified
+3
View File
@@ -154,6 +154,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
bot.getPlayer().stop(); // Stop current playback first
bot.getPlayer().resetFailures(); // Reset on user-initiated play
const song = queue.playAt(index);
if (!song) {
res.status(400).json({ error: "Invalid queue index" });
@@ -200,6 +201,7 @@ export function createPlayerRouter(
// Stop current playback
bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) {
@@ -256,6 +258,7 @@ export function createPlayerRouter(
queue.add({ ...song, platform: provider.platform });
queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) {
res.json({ message: `Cannot play: ${song.name}` });
+7
View File
@@ -67,7 +67,14 @@ export function createWebServer(options: WebServerOptions): WebServer {
});
}
server.on("error", (err) => {
logger.error({ err }, "HTTP server error");
});
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("error", (err) => {
logger.error({ err }, "WebSocket server error");
});
const cleanupWs = setupWebSocket(wss, options.botManager, logger);
return {
+5 -1
View File
@@ -39,7 +39,11 @@ export function setupWebSocket(
const message = JSON.stringify(data);
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
try {
client.send(message);
} catch {
clients.delete(client);
}
}
}
};