mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(profile): custom avatar with idle/playback precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
edd0fc58eb
commit
ffa27d7224
2 files changed
+106
-1
No files matched your search
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { BotProfileManager } from "./profile.js";
|
||||||
|
import type { TS3Client } from "../ts-protocol/client.js";
|
||||||
|
|
||||||
|
function makeMockTs(): TS3Client & {
|
||||||
|
uploadCalls: Buffer[];
|
||||||
|
clearCalls: number;
|
||||||
|
} {
|
||||||
|
const calls: Buffer[] = [];
|
||||||
|
let clears = 0;
|
||||||
|
const ts: any = {
|
||||||
|
uploadCalls: calls,
|
||||||
|
get clearCalls() { return clears; },
|
||||||
|
getHost: () => "127.0.0.1",
|
||||||
|
getHttpQuery: () => null,
|
||||||
|
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
|
||||||
|
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of stream) chunks.push(c as Buffer);
|
||||||
|
calls.push(Buffer.concat(chunks));
|
||||||
|
}),
|
||||||
|
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
|
||||||
|
if (/client_flag_avatar=$/.test(cmd)) clears++;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
|
||||||
|
|
||||||
|
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
|
||||||
|
const cfgOff = { ...cfgOn, avatarEnabled: false };
|
||||||
|
|
||||||
|
describe("BotProfileManager custom avatar precedence", () => {
|
||||||
|
let ts: ReturnType<typeof makeMockTs>;
|
||||||
|
beforeEach(() => { ts = makeMockTs(); });
|
||||||
|
|
||||||
|
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
const custom = Buffer.from([1, 2, 3, 4]);
|
||||||
|
pm.setCustomAvatar(custom);
|
||||||
|
await pm.onSongChange(null);
|
||||||
|
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
|
||||||
|
expect(ts.clearCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on stop with no custom avatar, falls back to clear", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
await pm.onSongChange(null);
|
||||||
|
expect(ts.clearCalls).toBe(1);
|
||||||
|
expect(ts.uploadCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([9, 9]));
|
||||||
|
await pm.onConnect();
|
||||||
|
// onConnect fires the upload but may be async fire-and-forget; flush microtasks:
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
expect(ts.uploadCalls.length).toBe(1);
|
||||||
|
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
|
||||||
|
await pm.onConnect();
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
expect(ts.uploadCalls.length).toBe(0);
|
||||||
|
expect(ts.clearCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
|
||||||
|
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
|
||||||
|
pm.setCustomAvatar(Buffer.from([1]));
|
||||||
|
pm.setCustomAvatar(null);
|
||||||
|
await pm.onSongChange(null);
|
||||||
|
expect(ts.clearCalls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-1
@@ -25,6 +25,7 @@ export class BotProfileManager {
|
|||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private config: ProfileConfig;
|
private config: ProfileConfig;
|
||||||
private defaultNickname: string;
|
private defaultNickname: string;
|
||||||
|
private customAvatar: Buffer | null = null;
|
||||||
|
|
||||||
/** Per-feature permission-denied flags. Reset on reconnect. */
|
/** Per-feature permission-denied flags. Reset on reconnect. */
|
||||||
private permDenied = {
|
private permDenied = {
|
||||||
@@ -58,6 +59,11 @@ export class BotProfileManager {
|
|||||||
|
|
||||||
// --- Public API ---
|
// --- Public API ---
|
||||||
|
|
||||||
|
/** Set/clear the persistent idle avatar. Pass null to remove. */
|
||||||
|
setCustomAvatar(buffer: Buffer | null): void {
|
||||||
|
this.customAvatar = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when a new song starts playing (song != null) or playback
|
* Called when a new song starts playing (song != null) or playback
|
||||||
* stops (song == null).
|
* stops (song == null).
|
||||||
@@ -99,6 +105,10 @@ export class BotProfileManager {
|
|||||||
channelDesc: false,
|
channelDesc: false,
|
||||||
nowPlayingMsg: false,
|
nowPlayingMsg: false,
|
||||||
};
|
};
|
||||||
|
if (!this.config.avatarEnabled && this.customAvatar) {
|
||||||
|
const gen = this.generation;
|
||||||
|
void this.applyIdleAvatar(gen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getConfig(): ProfileConfig {
|
getConfig(): ProfileConfig {
|
||||||
@@ -171,6 +181,10 @@ export class BotProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async clearAvatar(gen: number): Promise<void> {
|
private async clearAvatar(gen: number): Promise<void> {
|
||||||
|
if (this.customAvatar && this.customAvatar.length > 0) {
|
||||||
|
await this.applyIdleAvatar(gen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.withTimeout(
|
await this.withTimeout(
|
||||||
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
|
||||||
@@ -179,7 +193,6 @@ export class BotProfileManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// File may not exist or transfer timed out — that's fine
|
// File may not exist or transfer timed out — that's fine
|
||||||
}
|
}
|
||||||
// Bail if a newer song started while we were deleting
|
|
||||||
if (this.generation !== gen) return;
|
if (this.generation !== gen) return;
|
||||||
try {
|
try {
|
||||||
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
|
||||||
@@ -188,6 +201,18 @@ export class BotProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async applyIdleAvatar(gen: number): Promise<void> {
|
||||||
|
if (!this.customAvatar || this.customAvatar.length === 0) return;
|
||||||
|
if (this.permDenied.avatar) return;
|
||||||
|
try {
|
||||||
|
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
|
||||||
|
if (this.generation !== gen) return;
|
||||||
|
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
|
||||||
|
} catch (err) {
|
||||||
|
this.handleFeatureError("avatar", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
private async updateDescription(song: QueuedSong | null): Promise<void> {
|
||||||
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
if (!this.config.descriptionEnabled || this.permDenied.description) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user