mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 05:52:50 +08:00
fix: fence profile updates and check TeamSpeak permission failures
This commit is contained in:
1 parent
b9c79c8138
commit
f8b03acca3
3 files changed
+294
-101
No files matched your search
+177
-1
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Client, generateIdentity } from "@honeybbq/teamspeak-client";
|
||||
import { BotProfileManager } from "./profile.js";
|
||||
import { TS6HttpQuery } from "../ts-protocol/http-query.js";
|
||||
import type { TS3Client } from "../ts-protocol/client.js";
|
||||
import type { QueuedSong } from "../audio/queue.js";
|
||||
|
||||
@@ -14,6 +16,9 @@ function makeMockTs(): TS3Client & {
|
||||
get clearCalls() { return clears; },
|
||||
getHost: () => "127.0.0.1",
|
||||
getHttpQuery: () => null,
|
||||
getClientId: () => 17,
|
||||
getChannelId: () => 5n,
|
||||
execCommand: vi.fn().mockResolvedValue(undefined),
|
||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -213,7 +218,7 @@ describe("BotProfileManager channel description follows the bot (#159)", () => {
|
||||
ts.cid = 5n;
|
||||
(ts as any).getChannelId = () => ts.cid;
|
||||
channelEdits = () =>
|
||||
(ts.sendCommandNoWait as any).mock.calls
|
||||
(ts.execCommand as any).mock.calls
|
||||
.map((c: any[]) => c[0] as string)
|
||||
.filter((cmd: string) => cmd.startsWith("channeledit"));
|
||||
});
|
||||
@@ -264,3 +269,174 @@ describe("BotProfileManager channel description follows the bot (#159)", () => {
|
||||
expect(channelEdits()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeHttpProfile(partial: Partial<typeof cfgOff> = {}) {
|
||||
const ts = makeMockTs() as any;
|
||||
const state = { clid: 17, cid: 5n };
|
||||
const descriptions = new Map<number, string>();
|
||||
const http = new TS6HttpQuery({ host: "127.0.0.1", port: 10080 });
|
||||
const request = vi.spyOn(http, "request").mockImplementation(async (_method, path, body) => {
|
||||
if (path.includes("clientlist")) {
|
||||
return { status: 200, body: { body: [{ clid: String(state.clid), cid: String(state.cid) }], status: { code: 0, message: "ok" } } };
|
||||
}
|
||||
if (path.includes("channeledit")) descriptions.set(Number(body!.cid), String(body!.channel_description));
|
||||
return { status: 200, body: { status: { code: 0, message: "ok" } } };
|
||||
});
|
||||
ts.getHttpQuery = () => http;
|
||||
ts.getClientId = () => state.clid;
|
||||
ts.getChannelId = () => state.cid;
|
||||
const logger: any = { child: () => logger, info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const pm = new BotProfileManager(ts, logger, { ...cfgOff, ...partial }, "Bot");
|
||||
return { pm, ts, state, http, request, descriptions, logger };
|
||||
}
|
||||
|
||||
describe("BotProfileManager checked TS6 profile lifecycle", () => {
|
||||
it("clears the old channel through HTTP Query when moved, even without full-client edit permission", async () => {
|
||||
const { pm, ts, state, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.execCommand.mockRejectedValue(new Error("insufficient client permissions"));
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(descriptions.get(5)).toContain("X - Y");
|
||||
state.cid = 9n;
|
||||
await pm.onChannelMoved(9n);
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
expect(ts.sendCommandNoWait).not.toHaveBeenCalled();
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards an old client-list reply after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
state.cid = 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("channeledit"))).toEqual([]);
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("does not restore the previous remembered channel when a write completes after reconnect", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 200, body: { status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
await pm.onSongChange(null);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "" });
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when playback stops", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onSongChange(null);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.get(5)).toBe("");
|
||||
});
|
||||
|
||||
it("discards a pending channel lookup when the bot is moved", async () => {
|
||||
const { pm, ts, request, descriptions } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
const lookup = deferred<any>();
|
||||
request.mockImplementationOnce(() => lookup.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
await pm.onChannelMoved(9n);
|
||||
lookup.resolve({ status: 200, body: { body: [{ clid: "17", cid: "5" }], status: { code: 0, message: "ok" } } });
|
||||
await update;
|
||||
expect(descriptions.has(5)).toBe(false);
|
||||
expect(descriptions.get(9)).toContain("X - Y");
|
||||
});
|
||||
|
||||
it("does not disable the new connection after an old write returns a permission failure", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
const write = deferred<any>();
|
||||
request.mockImplementationOnce(() => write.promise);
|
||||
const update = pm.onSongChange(fakeSong);
|
||||
await flush();
|
||||
state.clid = 21;
|
||||
state.cid = 9n;
|
||||
pm.onConnect();
|
||||
write.resolve({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await update;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 9, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
});
|
||||
|
||||
it("resolves an unknown channel and sends raw newlines with one targeted description update", async () => {
|
||||
const { pm, ts, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
state.cid = 5n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request.mock.calls.filter((call) => call[1].includes("clientedit"))).toEqual([
|
||||
["POST", "/1/clientedit?sid=1", { clid: 17, client_description: "X - Y [Z]" }],
|
||||
]);
|
||||
expect(request).toHaveBeenLastCalledWith("POST", "/1/channeledit?sid=1", { cid: 5, channel_description: "♪ 正在播放: X - Y\n专辑: Z\n平台: netease" });
|
||||
expect(ts.execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve or write a disconnected client", async () => {
|
||||
const { pm, state, request } = makeHttpProfile({ channelDescEnabled: true, descriptionEnabled: true });
|
||||
state.clid = 0;
|
||||
state.cid = 0n;
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports HTTP lookup permission errors once and retries after reconnect", async () => {
|
||||
const { pm, ts, request } = makeHttpProfile({ channelDescEnabled: true });
|
||||
ts.getChannelId = () => 0n;
|
||||
request.mockResolvedValue({ status: 403, body: { status: { code: 2568, message: "insufficient client permissions" } } });
|
||||
await pm.onSongChange(fakeSong);
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(fakeSong);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks self clientupdate permission responses and retries only after reconnect", async () => {
|
||||
const { pm, ts, request, logger } = makeHttpProfile({ nicknameEnabled: true, awayStatusEnabled: true });
|
||||
const client: any = new Client(generateIdentity(0), "127.0.0.1:9987", "Bot");
|
||||
const commands: string[] = [];
|
||||
client.handler.sendPacket = vi.fn((_type, data: Buffer) => {
|
||||
const command = data.toString();
|
||||
commands.push(command);
|
||||
const returnCode = command.match(/return_code=(\d+)/)?.[1];
|
||||
client.handler.onPacket({ typeFlagged: 2, data: Buffer.from(`error id=2568 msg=insufficient\\sclient\\spermissions${returnCode ? ` return_code=${returnCode}` : ""}`) });
|
||||
});
|
||||
ts.sendCommandNoWait.mockImplementation((command: string) => client.sendCommandNoWait(command));
|
||||
ts.execCommand.mockImplementation((command: string) => client.execCommand(command));
|
||||
await pm.onSongChange(null);
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]).toMatch(/^clientupdate client_nickname=Bot client_away=1 client_away_message=等待播放 return_code=\d+$/);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(logger.info.mock.calls.some((call: any[]) => call[1] === "Client properties updated (nickname + away)")).toBe(false);
|
||||
pm.onConnect();
|
||||
await pm.onSongChange(null);
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+111
-99
@@ -13,6 +13,13 @@ const AVATAR_MAX_BYTES = 200 * 1024;
|
||||
/** Timeout for file-transfer operations (upload / delete). */
|
||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
||||
|
||||
interface ProfileUpdateContext {
|
||||
generation: number;
|
||||
channelGeneration: number;
|
||||
clientId: number;
|
||||
httpQuery: ReturnType<TS3Client["getHttpQuery"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||
* away status, channel description, now-playing messages).
|
||||
@@ -57,6 +64,8 @@ export class BotProfileManager {
|
||||
* the generation changed, a newer update has superseded them.
|
||||
*/
|
||||
private generation = 0;
|
||||
/** Channel moves supersede channel writes without cancelling avatar work. */
|
||||
private channelGeneration = 0;
|
||||
|
||||
constructor(
|
||||
tsClient: TS3Client,
|
||||
@@ -119,20 +128,25 @@ export class BotProfileManager {
|
||||
*/
|
||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||
const gen = ++this.generation;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = song;
|
||||
const context = this.createUpdateContext();
|
||||
|
||||
// 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
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
|
||||
await this.updateClientProperties(song);
|
||||
// 2. Checked clientupdate sent by the visible client itself.
|
||||
await this.updateClientProperties(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||
await this.updateDescription(song);
|
||||
// 4. Channel description (fire-and-forget channeledit)
|
||||
await this.updateChannelDescription(song);
|
||||
await this.updateDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 4. Checked channel description update.
|
||||
await this.updateChannelDescription(song, context);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
// 5. Now-playing chat message
|
||||
if (song) await this.sendNowPlayingMessage(song);
|
||||
}
|
||||
@@ -140,6 +154,7 @@ export class BotProfileManager {
|
||||
/** Reset permission-denied flags and bump generation on new connection. */
|
||||
onConnect(): void {
|
||||
this.generation++;
|
||||
this.channelGeneration++;
|
||||
this.currentSong = null;
|
||||
// Channel ids are per-server; never carry one across a (re)connect.
|
||||
this.channelDescCid = null;
|
||||
@@ -168,19 +183,20 @@ export class BotProfileManager {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
const oldChannelId = this.channelDescCid;
|
||||
if (oldChannelId === newChannelId) return;
|
||||
this.channelGeneration++;
|
||||
const context = this.createUpdateContext();
|
||||
const song = this.currentSong;
|
||||
try {
|
||||
if (oldChannelId !== null) {
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`channeledit cid=${oldChannelId} channel_description=`,
|
||||
);
|
||||
if (!await this.writeChannelDescription(oldChannelId, "", context)) return;
|
||||
this.channelDescCid = null;
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
return;
|
||||
}
|
||||
if (this.currentSong) {
|
||||
await this.updateChannelDescription(this.currentSong, newChannelId);
|
||||
if (song) {
|
||||
await this.updateChannelDescription(song, context, newChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,18 +302,19 @@ export class BotProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||
private async updateDescription(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
try {
|
||||
const text = song
|
||||
? `${song.name} - ${song.artist} [${song.album}]`
|
||||
: "";
|
||||
|
||||
const clid = this.tsClient.getClientId();
|
||||
const clid = context.clientId;
|
||||
if (clid <= 0) return;
|
||||
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
const httpQuery = context.httpQuery;
|
||||
|
||||
if (httpQuery) {
|
||||
// IMPORTANT:
|
||||
@@ -306,6 +323,7 @@ export class BotProfileManager {
|
||||
const result = await httpQuery.clientEdit(clid, {
|
||||
client_description: text,
|
||||
});
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info(
|
||||
{ status: result.status, clid },
|
||||
@@ -318,27 +336,22 @@ export class BotProfileManager {
|
||||
),
|
||||
5000,
|
||||
);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info({ clid }, "Description updated");
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFeatureError("description", err);
|
||||
if (this.isCurrentUpdate(context)) 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.
|
||||
*
|
||||
* 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.
|
||||
* and away status together. The full client sends this command on both
|
||||
* TS3 and TS6, with a return code so permission failures are observable.
|
||||
*/
|
||||
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
|
||||
private async updateClientProperties(song: QueuedSong | null, context: ProfileUpdateContext): Promise<void> {
|
||||
if (!this.isCurrentUpdate(context) || context.clientId <= 0) return;
|
||||
const rawProps: Record<string, string | number> = {};
|
||||
|
||||
// --- Nickname ---
|
||||
@@ -374,20 +387,23 @@ export class BotProfileManager {
|
||||
: `${key}=${value}`,
|
||||
);
|
||||
|
||||
await this.tsClient.sendCommandNoWait(
|
||||
`clientupdate ${parts.join(" ")}`,
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(`clientupdate ${parts.join(" ")}`),
|
||||
5000,
|
||||
);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
clid: this.tsClient.getClientId(),
|
||||
clid: context.clientId,
|
||||
props: Object.keys(rawProps),
|
||||
},
|
||||
"Client properties updated (nickname + away)",
|
||||
);
|
||||
} catch (err) {
|
||||
this.handleFeatureError("nickname", err);
|
||||
this.handleFeatureError("awayStatus", err);
|
||||
if (!this.isCurrentUpdate(context)) return;
|
||||
if (rawProps.client_nickname !== undefined) this.handleFeatureError("nickname", err);
|
||||
if (rawProps.client_away !== undefined) this.handleFeatureError("awayStatus", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,28 +454,35 @@ export class BotProfileManager {
|
||||
|
||||
private async updateChannelDescription(
|
||||
song: QueuedSong | null,
|
||||
context: ProfileUpdateContext,
|
||||
targetChannelId?: bigint,
|
||||
): Promise<void> {
|
||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||
if (!this.isCurrentChannelUpdate(context) || context.clientId <= 0) return;
|
||||
|
||||
try {
|
||||
let channelId = targetChannelId ?? this.tsClient.getChannelId();
|
||||
// A stop already knows which channel to clear if a write succeeded.
|
||||
// Avoid a needless client-list lookup that could prevent that cleanup.
|
||||
let channelId = !song && this.channelDescCid !== null
|
||||
? this.channelDescCid
|
||||
: targetChannelId ?? this.tsClient.getChannelId();
|
||||
|
||||
// TS6 full-client may report channelID() as 0 even after the
|
||||
// visible music client has already joined a channel.
|
||||
// Fall back to HTTP Query and resolve our real clid -> cid.
|
||||
if (channelId === 0n) {
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
const clid = this.tsClient.getClientId();
|
||||
const httpQuery = context.httpQuery;
|
||||
const clid = context.clientId;
|
||||
|
||||
if (httpQuery && clid > 0) {
|
||||
const result = await httpQuery.clientList();
|
||||
if (!this.isCurrentChannelUpdate(context)) return;
|
||||
|
||||
const payload = result.body as {
|
||||
body?: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
const me = payload.body?.find(
|
||||
const me = payload?.body?.find(
|
||||
(client) => Number(client.clid) === clid,
|
||||
);
|
||||
|
||||
@@ -478,43 +501,12 @@ export class BotProfileManager {
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
const target = this.channelDescCid ?? channelId;
|
||||
|
||||
if (target === 0n) return;
|
||||
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
|
||||
if (httpQuery) {
|
||||
const result = await httpQuery.channelEdit(Number(target), {
|
||||
channel_description: "",
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
status: result.status,
|
||||
cid: target.toString(),
|
||||
},
|
||||
"Channel description cleared",
|
||||
);
|
||||
} else {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`channeledit cid=${target} channel_description=`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
|
||||
this.logger.info(
|
||||
{ cid: target.toString() },
|
||||
"Channel description cleared",
|
||||
);
|
||||
}
|
||||
|
||||
this.channelDescCid = null;
|
||||
if (channelId <= 0n) return;
|
||||
if (await this.writeChannelDescription(channelId, "", context)) this.channelDescCid = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId === 0n) return;
|
||||
if (channelId <= 0n) return;
|
||||
|
||||
const lines = [
|
||||
`♪ 正在播放: ${song.name} - ${song.artist}`,
|
||||
@@ -525,40 +517,41 @@ export class BotProfileManager {
|
||||
// HTTP Query uses a normal JSON string, so use real newlines here.
|
||||
const desc = lines.join("\n");
|
||||
|
||||
const httpQuery = this.tsClient.getHttpQuery();
|
||||
|
||||
if (httpQuery) {
|
||||
const result = await httpQuery.channelEdit(Number(channelId), {
|
||||
channel_description: desc,
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
status: result.status,
|
||||
cid: channelId.toString(),
|
||||
},
|
||||
"Channel description updated",
|
||||
);
|
||||
} else {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
|
||||
this.logger.info(
|
||||
{ cid: channelId.toString() },
|
||||
"Channel description updated",
|
||||
);
|
||||
}
|
||||
|
||||
this.channelDescCid = channelId;
|
||||
if (await this.writeChannelDescription(channelId, desc, context)) this.channelDescCid = channelId;
|
||||
} catch (err) {
|
||||
this.handleFeatureError("channelDesc", err);
|
||||
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Both move cleanup and ordinary writes use the same checked transport. */
|
||||
private async writeChannelDescription(
|
||||
channelId: bigint,
|
||||
description: string,
|
||||
context: ProfileUpdateContext,
|
||||
): Promise<boolean> {
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
let status: number | undefined;
|
||||
if (context.httpQuery) {
|
||||
const result = await context.httpQuery.channelEdit(Number(channelId), {
|
||||
channel_description: description,
|
||||
});
|
||||
status = result.status;
|
||||
} else {
|
||||
await this.withTimeout(
|
||||
this.tsClient.execCommand(
|
||||
`channeledit cid=${channelId} channel_description=${escapeTS3(description)}`,
|
||||
),
|
||||
5000,
|
||||
);
|
||||
}
|
||||
if (!this.isCurrentChannelUpdate(context)) return false;
|
||||
this.logger.info(
|
||||
{ status, cid: channelId.toString() },
|
||||
description ? "Channel description updated" : "Channel description cleared",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||
try {
|
||||
@@ -571,6 +564,25 @@ export class BotProfileManager {
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private createUpdateContext(): ProfileUpdateContext {
|
||||
return {
|
||||
generation: this.generation,
|
||||
channelGeneration: this.channelGeneration,
|
||||
clientId: this.tsClient.getClientId(),
|
||||
httpQuery: this.tsClient.getHttpQuery(),
|
||||
};
|
||||
}
|
||||
|
||||
private isCurrentUpdate(context: ProfileUpdateContext): boolean {
|
||||
return context.generation === this.generation &&
|
||||
context.clientId === this.tsClient.getClientId() &&
|
||||
context.httpQuery === this.tsClient.getHttpQuery();
|
||||
}
|
||||
|
||||
private isCurrentChannelUpdate(context: ProfileUpdateContext): boolean {
|
||||
return this.isCurrentUpdate(context) && context.channelGeneration === this.channelGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||
|
||||
@@ -167,7 +167,12 @@ export class TS6HttpQuery {
|
||||
|
||||
/** List clients on a virtual server */
|
||||
async clientList(sid = 1): Promise<HttpQueryResult> {
|
||||
return this.request("GET", `/1/clientlist?sid=${sid}`);
|
||||
const path = `/1/clientlist?sid=${sid}`;
|
||||
const result = await this.request("GET", path);
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new HttpQueryError(path, result.status, result.body);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** List channels on a virtual server */
|
||||
|
||||
Reference in new issue
Block a user