mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(profile): validate TS6 HTTP status and stop escaping JSON body
Silent failure: logs showed "Client properties updated" / "Description updated" / "Avatar updated" even though the bot's nickname never changed and the avatar stayed as "loading image" on clients. Root causes: - TS6HttpQuery.clientUpdate ignored non-2xx responses, so 400 (bad parameter) and 403 (insufficient permission) were reported as success. - updateClientProperties built TS3-escaped strings (\\s for space) then split them back into JSON props, so TS6 received literal backslashes and rejected the nickname silently. - handleFeatureError only matched textual "permission" errors; HTTP 4xx statuses weren't recognised and the feature retried every song. - doAvatarUpload had no per-step logging, making it impossible to tell whether a broken avatar came from init, the TCP 30033 transfer, or the client_flag_avatar command. Fixes: - Add HttpQueryError (status/body/path); clientUpdate throws on non-2xx. - Build a raw property map in updateClientProperties; escape only on the TS3 wire path. - Log HTTP status and updated prop names on success. - handleFeatureError now treats HTTP 400/401/403 as unrecoverable. - Debug-log each step of doAvatarUpload plus bytes/elapsedMs on success. https://claude.ai/code/session_018NrpGWbQQTrahUVXyea5Jy
This commit is contained in:
2 files changed
+112
-25
No files matched your search
+66
-23
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
|
||||||
|
import { HttpQueryError } from "../ts-protocol/http-query.js";
|
||||||
import type { ProfileConfig } from "../data/database.js";
|
import type { ProfileConfig } from "../data/database.js";
|
||||||
import type { QueuedSong } from "../audio/queue.js";
|
import type { QueuedSong } from "../audio/queue.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
@@ -135,20 +136,37 @@ export class BotProfileManager {
|
|||||||
|
|
||||||
// Wrap the file-transfer sequence with a timeout — the TS3
|
// Wrap the file-transfer sequence with a timeout — the TS3
|
||||||
// full-client file transfer can silently hang.
|
// full-client file transfer can silently hang.
|
||||||
|
const start = Date.now();
|
||||||
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
|
||||||
this.logger.info("Avatar updated");
|
this.logger.info(
|
||||||
|
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
|
||||||
|
"Avatar updated",
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("avatar", err);
|
this.handleFeatureError("avatar", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Three-step upload. Each step is logged so the log can tell us whether
|
||||||
|
* a broken/loading avatar on the client is from:
|
||||||
|
* (a) init failing (no permission)
|
||||||
|
* (b) file transfer hanging on TCP 30033
|
||||||
|
* (c) client_flag_avatar not applying
|
||||||
|
* If (b) happens, the avatar MD5 would still be set in the past — leaving
|
||||||
|
* clients showing a placeholder. The flag is now only set after the TCP
|
||||||
|
* transfer resolves.
|
||||||
|
*/
|
||||||
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
|
||||||
const host = this.tsClient.getHost();
|
const host = this.tsClient.getHost();
|
||||||
|
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
|
||||||
const info = await this.tsClient.fileTransferInitUpload(
|
const info = await this.tsClient.fileTransferInitUpload(
|
||||||
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
0n, "/avatar", "", BigInt(imageBuffer.length), true,
|
||||||
);
|
);
|
||||||
|
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
|
||||||
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
|
||||||
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
const md5 = createHash("md5").update(imageBuffer).digest("hex");
|
||||||
|
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
|
||||||
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +196,11 @@ export class BotProfileManager {
|
|||||||
: "";
|
: "";
|
||||||
const httpQuery = this.tsClient.getHttpQuery();
|
const httpQuery = this.tsClient.getHttpQuery();
|
||||||
if (httpQuery) {
|
if (httpQuery) {
|
||||||
await httpQuery.clientUpdate({ client_description: text });
|
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
|
||||||
|
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
|
||||||
|
// be misreported as success.
|
||||||
|
const result = await httpQuery.clientUpdate({ client_description: text });
|
||||||
|
this.logger.info({ status: result.status }, "Description updated");
|
||||||
} else {
|
} else {
|
||||||
// clientupdate rejects client_description (error 1538).
|
// clientupdate rejects client_description (error 1538).
|
||||||
// Use clientedit on our own clid instead — this is what
|
// Use clientedit on our own clid instead — this is what
|
||||||
@@ -193,8 +215,8 @@ export class BotProfileManager {
|
|||||||
),
|
),
|
||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
|
this.logger.info("Description updated");
|
||||||
}
|
}
|
||||||
this.logger.info("Description updated");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("description", err);
|
this.handleFeatureError("description", err);
|
||||||
}
|
}
|
||||||
@@ -204,18 +226,25 @@ export class BotProfileManager {
|
|||||||
* Build and send a single `clientupdate` command that sets nickname
|
* Build and send a single `clientupdate` command that sets nickname
|
||||||
* and away status together, avoiding multiple round-trips that can
|
* and away status together, avoiding multiple round-trips that can
|
||||||
* cause command-queue timeouts on the TS3 protocol.
|
* cause command-queue timeouts on the TS3 protocol.
|
||||||
|
*
|
||||||
|
* Values are collected as raw strings/numbers. The TS6 HTTP path
|
||||||
|
* forwards them as JSON (the server expects real spaces, not `\s`);
|
||||||
|
* the TS3 wire path escapes them on the fly. Previously the code
|
||||||
|
* escaped upfront and then split the escaped string to build the
|
||||||
|
* JSON body, so TS6 received literal backslashes and silently
|
||||||
|
* rejected the update.
|
||||||
*/
|
*/
|
||||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||||
const parts: string[] = [];
|
const rawProps: Record<string, string | number> = {};
|
||||||
|
|
||||||
// --- Nickname ---
|
// --- Nickname ---
|
||||||
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
|
||||||
if (!song) {
|
if (!song) {
|
||||||
parts.push(`client_nickname=${escapeTS3(this.defaultNickname)}`);
|
rawProps.client_nickname = this.defaultNickname;
|
||||||
} else {
|
} else {
|
||||||
const nickname = this.buildNickname(song);
|
const nickname = this.buildNickname(song);
|
||||||
if (nickname) {
|
if (nickname) {
|
||||||
parts.push(`client_nickname=${escapeTS3(nickname)}`);
|
rawProps.client_nickname = nickname;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,31 +252,38 @@ export class BotProfileManager {
|
|||||||
// --- Away status ---
|
// --- Away status ---
|
||||||
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
|
||||||
if (song) {
|
if (song) {
|
||||||
parts.push("client_away=0");
|
rawProps.client_away = 0;
|
||||||
} else {
|
} else {
|
||||||
parts.push(`client_away=1 client_away_message=${escapeTS3("\u7B49\u5F85\u64AD\u653E")}`);
|
rawProps.client_away = 1;
|
||||||
|
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) return;
|
if (Object.keys(rawProps).length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const httpQuery = this.tsClient.getHttpQuery();
|
const httpQuery = this.tsClient.getHttpQuery();
|
||||||
if (httpQuery) {
|
if (httpQuery) {
|
||||||
// TS6: build a properties object
|
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
|
||||||
const props: Record<string, string | number> = {};
|
const result = await httpQuery.clientUpdate(rawProps);
|
||||||
for (const part of parts) {
|
this.logger.info(
|
||||||
const eq = part.indexOf("=");
|
{ status: result.status, props: Object.keys(rawProps) },
|
||||||
if (eq > 0) props[part.slice(0, eq)] = part.slice(eq + 1);
|
"Client properties updated (nickname + away)",
|
||||||
}
|
);
|
||||||
await httpQuery.clientUpdate(props);
|
|
||||||
} else {
|
} else {
|
||||||
// Use sendCommandNoWait: the TS3 full-client protocol often
|
// TS3 wire protocol: escape string values inline.
|
||||||
|
// sendCommandNoWait: the TS3 full-client protocol often
|
||||||
// doesn't return a timely error response for clientupdate,
|
// doesn't return a timely error response for clientupdate,
|
||||||
// causing execCommand to time out after 10s.
|
// causing execCommand to time out after 10s.
|
||||||
|
const parts = Object.entries(rawProps).map(([k, v]) =>
|
||||||
|
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
|
||||||
|
);
|
||||||
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
|
||||||
|
this.logger.info(
|
||||||
|
{ props: Object.keys(rawProps) },
|
||||||
|
"Client properties updated (nickname + away)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.logger.info("Client properties updated (nickname + away)");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Flag both features on permission error
|
// Flag both features on permission error
|
||||||
this.handleFeatureError("nickname", err);
|
this.handleFeatureError("nickname", err);
|
||||||
@@ -387,21 +423,28 @@ export class BotProfileManager {
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
): void {
|
): void {
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||||
|
const status = err instanceof HttpQueryError ? err.status : undefined;
|
||||||
|
const body = err instanceof HttpQueryError ? err.body : undefined;
|
||||||
// Disable the feature for this session on unrecoverable errors:
|
// Disable the feature for this session on unrecoverable errors:
|
||||||
// - permission / insufficient → server denies the action
|
// - permission / insufficient → server denies the action
|
||||||
// - invalid parameter → command not supported by this protocol
|
// - invalid parameter → command not supported by this protocol
|
||||||
if (
|
// - HTTP 401/403 → TS6 server rejects the API key/role
|
||||||
|
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
|
||||||
|
const isUnrecoverable =
|
||||||
msg.includes("permission") ||
|
msg.includes("permission") ||
|
||||||
msg.includes("insufficient") ||
|
msg.includes("insufficient") ||
|
||||||
msg.includes("invalid parameter")
|
msg.includes("invalid parameter") ||
|
||||||
) {
|
status === 400 ||
|
||||||
|
status === 401 ||
|
||||||
|
status === 403;
|
||||||
|
if (isUnrecoverable) {
|
||||||
this.permDenied[feature] = true;
|
this.permDenied[feature] = true;
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ feature, reason: msg },
|
{ feature, status, body, reason: msg },
|
||||||
"Feature disabled for this session (will retry after reconnect)",
|
"Feature disabled for this session (will retry after reconnect)",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn({ feature, err }, "Profile update failed");
|
this.logger.warn({ feature, status, body, err }, "Profile update failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,38 @@ export interface HttpQueryResult {
|
|||||||
body: unknown;
|
body: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the TS6 HTTP Query returns a non-2xx status.
|
||||||
|
*
|
||||||
|
* The previous implementation silently ignored the status code, so a 400
|
||||||
|
* (bad parameter) or 403 (insufficient permission) looked identical to
|
||||||
|
* success in logs. Callers that rely on the response being applied —
|
||||||
|
* nickname / description / away-status updates — should catch this and
|
||||||
|
* surface it rather than log "updated" for a request that was rejected.
|
||||||
|
*/
|
||||||
|
export class HttpQueryError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: unknown;
|
||||||
|
readonly path: string;
|
||||||
|
|
||||||
|
constructor(path: string, status: number, body: unknown) {
|
||||||
|
const bodySnippet = (() => {
|
||||||
|
if (body == null) return "";
|
||||||
|
const s = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
|
return s.length > 200 ? s.slice(0, 200) + "\u2026" : s;
|
||||||
|
})();
|
||||||
|
super(
|
||||||
|
`TS6 HTTP Query ${path} failed: status=${status}${
|
||||||
|
bodySnippet ? ` body=${bodySnippet}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
this.name = "HttpQueryError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TS6 HTTP Query client.
|
* TS6 HTTP Query client.
|
||||||
*
|
*
|
||||||
@@ -157,12 +189,24 @@ export class TS6HttpQuery {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update client properties (e.g., description) */
|
/**
|
||||||
|
* Update client properties (e.g., description, nickname, away).
|
||||||
|
*
|
||||||
|
* Throws HttpQueryError on non-2xx responses. The TS6 server returns
|
||||||
|
* 400 for invalid parameters and 403 for insufficient permissions;
|
||||||
|
* prior to this check the errors were silently dropped and callers
|
||||||
|
* logged a false "updated" success.
|
||||||
|
*/
|
||||||
async clientUpdate(
|
async clientUpdate(
|
||||||
properties: Record<string, string | number>,
|
properties: Record<string, string | number>,
|
||||||
sid = 1,
|
sid = 1,
|
||||||
): Promise<HttpQueryResult> {
|
): Promise<HttpQueryResult> {
|
||||||
return this.request("POST", `/1/clientupdate?sid=${sid}`, properties);
|
const path = `/1/clientupdate?sid=${sid}`;
|
||||||
|
const result = await this.request("POST", path, properties);
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
throw new HttpQueryError(path, result.status, result.body);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move a client to a channel */
|
/** Move a client to a channel */
|
||||||
|
|||||||
Reference in new issue
Block a user