mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-04 22:12:49 +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 { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Client, generateIdentity } from "@honeybbq/teamspeak-client";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
import { TS6HttpQuery } from "../ts-protocol/http-query.js";
|
||||||
import type { TS3Client } from "../ts-protocol/client.js";
|
import type { TS3Client } from "../ts-protocol/client.js";
|
||||||
import type { QueuedSong } from "../audio/queue.js";
|
import type { QueuedSong } from "../audio/queue.js";
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ function makeMockTs(): TS3Client & {
|
|||||||
get clearCalls() { return clears; },
|
get clearCalls() { return clears; },
|
||||||
getHost: () => "127.0.0.1",
|
getHost: () => "127.0.0.1",
|
||||||
getHttpQuery: () => null,
|
getHttpQuery: () => null,
|
||||||
|
getClientId: () => 17,
|
||||||
|
getChannelId: () => 5n,
|
||||||
|
execCommand: vi.fn().mockResolvedValue(undefined),
|
||||||
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||||
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -213,7 +218,7 @@ describe("BotProfileManager channel description follows the bot (#159)", () => {
|
|||||||
ts.cid = 5n;
|
ts.cid = 5n;
|
||||||
(ts as any).getChannelId = () => ts.cid;
|
(ts as any).getChannelId = () => ts.cid;
|
||||||
channelEdits = () =>
|
channelEdits = () =>
|
||||||
(ts.sendCommandNoWait as any).mock.calls
|
(ts.execCommand as any).mock.calls
|
||||||
.map((c: any[]) => c[0] as string)
|
.map((c: any[]) => c[0] as string)
|
||||||
.filter((cmd: string) => cmd.startsWith("channeledit"));
|
.filter((cmd: string) => cmd.startsWith("channeledit"));
|
||||||
});
|
});
|
||||||
@@ -264,3 +269,174 @@ describe("BotProfileManager channel description follows the bot (#159)", () => {
|
|||||||
expect(channelEdits()).toHaveLength(1);
|
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). */
|
/** Timeout for file-transfer operations (upload / delete). */
|
||||||
const FILE_TRANSFER_TIMEOUT_MS = 6000;
|
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,
|
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
|
||||||
* away status, channel description, now-playing messages).
|
* away status, channel description, now-playing messages).
|
||||||
@@ -57,6 +64,8 @@ export class BotProfileManager {
|
|||||||
* the generation changed, a newer update has superseded them.
|
* the generation changed, a newer update has superseded them.
|
||||||
*/
|
*/
|
||||||
private generation = 0;
|
private generation = 0;
|
||||||
|
/** Channel moves supersede channel writes without cancelling avatar work. */
|
||||||
|
private channelGeneration = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
tsClient: TS3Client,
|
tsClient: TS3Client,
|
||||||
@@ -119,20 +128,25 @@ export class BotProfileManager {
|
|||||||
*/
|
*/
|
||||||
async onSongChange(song: QueuedSong | null): Promise<void> {
|
async onSongChange(song: QueuedSong | null): Promise<void> {
|
||||||
const gen = ++this.generation;
|
const gen = ++this.generation;
|
||||||
|
this.channelGeneration++;
|
||||||
this.currentSong = song;
|
this.currentSong = song;
|
||||||
|
const context = this.createUpdateContext();
|
||||||
|
|
||||||
// 1. Avatar first — file transfer uses its own response tracker and
|
// 1. Avatar first — file transfer uses its own response tracker and
|
||||||
// must run before sendCommandNoWait calls whose orphaned responses
|
// must run before sendCommandNoWait calls whose orphaned responses
|
||||||
// could confuse the command matcher.
|
// could confuse the command matcher.
|
||||||
await this.updateAvatar(song?.coverUrl ?? null, gen);
|
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)
|
// 2. Checked clientupdate sent by the visible client itself.
|
||||||
await this.updateClientProperties(song);
|
await this.updateClientProperties(song, context);
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
// 3. Description (clientedit on TS3, httpQuery on TS6)
|
||||||
await this.updateDescription(song);
|
await this.updateDescription(song, context);
|
||||||
// 4. Channel description (fire-and-forget channeledit)
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
await this.updateChannelDescription(song);
|
// 4. Checked channel description update.
|
||||||
|
await this.updateChannelDescription(song, context);
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
// 5. Now-playing chat message
|
// 5. Now-playing chat message
|
||||||
if (song) await this.sendNowPlayingMessage(song);
|
if (song) await this.sendNowPlayingMessage(song);
|
||||||
}
|
}
|
||||||
@@ -140,6 +154,7 @@ export class BotProfileManager {
|
|||||||
/** Reset permission-denied flags and bump generation on new connection. */
|
/** Reset permission-denied flags and bump generation on new connection. */
|
||||||
onConnect(): void {
|
onConnect(): void {
|
||||||
this.generation++;
|
this.generation++;
|
||||||
|
this.channelGeneration++;
|
||||||
this.currentSong = null;
|
this.currentSong = null;
|
||||||
// Channel ids are per-server; never carry one across a (re)connect.
|
// Channel ids are per-server; never carry one across a (re)connect.
|
||||||
this.channelDescCid = null;
|
this.channelDescCid = null;
|
||||||
@@ -168,19 +183,20 @@ export class BotProfileManager {
|
|||||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||||
const oldChannelId = this.channelDescCid;
|
const oldChannelId = this.channelDescCid;
|
||||||
if (oldChannelId === newChannelId) return;
|
if (oldChannelId === newChannelId) return;
|
||||||
|
this.channelGeneration++;
|
||||||
|
const context = this.createUpdateContext();
|
||||||
|
const song = this.currentSong;
|
||||||
try {
|
try {
|
||||||
if (oldChannelId !== null) {
|
if (oldChannelId !== null) {
|
||||||
await this.tsClient.sendCommandNoWait(
|
if (!await this.writeChannelDescription(oldChannelId, "", context)) return;
|
||||||
`channeledit cid=${oldChannelId} channel_description=`,
|
|
||||||
);
|
|
||||||
this.channelDescCid = null;
|
this.channelDescCid = null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("channelDesc", err);
|
if (this.isCurrentChannelUpdate(context)) this.handleFeatureError("channelDesc", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.currentSong) {
|
if (song) {
|
||||||
await this.updateChannelDescription(this.currentSong, newChannelId);
|
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.config.descriptionEnabled || this.permDenied.description) return;
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = song
|
const text = song
|
||||||
? `${song.name} - ${song.artist} [${song.album}]`
|
? `${song.name} - ${song.artist} [${song.album}]`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const clid = this.tsClient.getClientId();
|
const clid = context.clientId;
|
||||||
if (clid <= 0) return;
|
if (clid <= 0) return;
|
||||||
|
|
||||||
const httpQuery = this.tsClient.getHttpQuery();
|
const httpQuery = context.httpQuery;
|
||||||
|
|
||||||
if (httpQuery) {
|
if (httpQuery) {
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
@@ -306,6 +323,7 @@ export class BotProfileManager {
|
|||||||
const result = await httpQuery.clientEdit(clid, {
|
const result = await httpQuery.clientEdit(clid, {
|
||||||
client_description: text,
|
client_description: text,
|
||||||
});
|
});
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ status: result.status, clid },
|
{ status: result.status, clid },
|
||||||
@@ -318,27 +336,22 @@ export class BotProfileManager {
|
|||||||
),
|
),
|
||||||
5000,
|
5000,
|
||||||
);
|
);
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
|
|
||||||
this.logger.info({ clid }, "Description updated");
|
this.logger.info({ clid }, "Description updated");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("description", err);
|
if (this.isCurrentUpdate(context)) this.handleFeatureError("description", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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. The full client sends this command on both
|
||||||
* cause command-queue timeouts on the TS3 protocol.
|
* TS3 and TS6, with a return code so permission failures are observable.
|
||||||
*
|
|
||||||
* 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, context: ProfileUpdateContext): Promise<void> {
|
||||||
|
if (!this.isCurrentUpdate(context) || context.clientId <= 0) return;
|
||||||
const rawProps: Record<string, string | number> = {};
|
const rawProps: Record<string, string | number> = {};
|
||||||
|
|
||||||
// --- Nickname ---
|
// --- Nickname ---
|
||||||
@@ -374,20 +387,23 @@ export class BotProfileManager {
|
|||||||
: `${key}=${value}`,
|
: `${key}=${value}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.tsClient.sendCommandNoWait(
|
await this.withTimeout(
|
||||||
`clientupdate ${parts.join(" ")}`,
|
this.tsClient.execCommand(`clientupdate ${parts.join(" ")}`),
|
||||||
|
5000,
|
||||||
);
|
);
|
||||||
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
|
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{
|
{
|
||||||
clid: this.tsClient.getClientId(),
|
clid: context.clientId,
|
||||||
props: Object.keys(rawProps),
|
props: Object.keys(rawProps),
|
||||||
},
|
},
|
||||||
"Client properties updated (nickname + away)",
|
"Client properties updated (nickname + away)",
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.handleFeatureError("nickname", err);
|
if (!this.isCurrentUpdate(context)) return;
|
||||||
this.handleFeatureError("awayStatus", err);
|
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(
|
private async updateChannelDescription(
|
||||||
song: QueuedSong | null,
|
song: QueuedSong | null,
|
||||||
|
context: ProfileUpdateContext,
|
||||||
targetChannelId?: bigint,
|
targetChannelId?: bigint,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
|
||||||
|
if (!this.isCurrentChannelUpdate(context) || context.clientId <= 0) return;
|
||||||
|
|
||||||
try {
|
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
|
// TS6 full-client may report channelID() as 0 even after the
|
||||||
// visible music client has already joined a channel.
|
// visible music client has already joined a channel.
|
||||||
// Fall back to HTTP Query and resolve our real clid -> cid.
|
// Fall back to HTTP Query and resolve our real clid -> cid.
|
||||||
if (channelId === 0n) {
|
if (channelId === 0n) {
|
||||||
const httpQuery = this.tsClient.getHttpQuery();
|
const httpQuery = context.httpQuery;
|
||||||
const clid = this.tsClient.getClientId();
|
const clid = context.clientId;
|
||||||
|
|
||||||
if (httpQuery && clid > 0) {
|
if (httpQuery && clid > 0) {
|
||||||
const result = await httpQuery.clientList();
|
const result = await httpQuery.clientList();
|
||||||
|
if (!this.isCurrentChannelUpdate(context)) return;
|
||||||
|
|
||||||
const payload = result.body as {
|
const payload = result.body as {
|
||||||
body?: Array<Record<string, string>>;
|
body?: Array<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const me = payload.body?.find(
|
const me = payload?.body?.find(
|
||||||
(client) => Number(client.clid) === clid,
|
(client) => Number(client.clid) === clid,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -478,43 +501,12 @@ export class BotProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
const target = this.channelDescCid ?? channelId;
|
if (channelId <= 0n) return;
|
||||||
|
if (await this.writeChannelDescription(channelId, "", context)) this.channelDescCid = null;
|
||||||
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;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelId === 0n) return;
|
if (channelId <= 0n) return;
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`♪ 正在播放: ${song.name} - ${song.artist}`,
|
`♪ 正在播放: ${song.name} - ${song.artist}`,
|
||||||
@@ -525,40 +517,41 @@ export class BotProfileManager {
|
|||||||
// HTTP Query uses a normal JSON string, so use real newlines here.
|
// HTTP Query uses a normal JSON string, so use real newlines here.
|
||||||
const desc = lines.join("\n");
|
const desc = lines.join("\n");
|
||||||
|
|
||||||
const httpQuery = this.tsClient.getHttpQuery();
|
if (await this.writeChannelDescription(channelId, desc, context)) this.channelDescCid = channelId;
|
||||||
|
|
||||||
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;
|
|
||||||
} catch (err) {
|
} 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> {
|
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
|
||||||
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
|
||||||
try {
|
try {
|
||||||
@@ -571,6 +564,25 @@ export class BotProfileManager {
|
|||||||
|
|
||||||
// --- Helpers ---
|
// --- 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.
|
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
|
||||||
* NetEase and QQ Music CDNs support URL-based image resizing.
|
* NetEase and QQ Music CDNs support URL-based image resizing.
|
||||||
|
|||||||
@@ -167,7 +167,12 @@ export class TS6HttpQuery {
|
|||||||
|
|
||||||
/** List clients on a virtual server */
|
/** List clients on a virtual server */
|
||||||
async clientList(sid = 1): Promise<HttpQueryResult> {
|
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 */
|
/** List channels on a virtual server */
|
||||||
|
|||||||
Reference in new issue
Block a user