diff --git a/src/bot/profile.test.ts b/src/bot/profile.test.ts new file mode 100644 index 0000000..38f7d12 --- /dev/null +++ b/src/bot/profile.test.ts @@ -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; + 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); + }); +}); diff --git a/src/bot/profile.ts b/src/bot/profile.ts index 778ba68..039ee5a 100644 --- a/src/bot/profile.ts +++ b/src/bot/profile.ts @@ -25,6 +25,7 @@ export class BotProfileManager { private logger: Logger; private config: ProfileConfig; private defaultNickname: string; + private customAvatar: Buffer | null = null; /** Per-feature permission-denied flags. Reset on reconnect. */ private permDenied = { @@ -58,6 +59,11 @@ export class BotProfileManager { // --- 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 * stops (song == null). @@ -99,6 +105,10 @@ export class BotProfileManager { channelDesc: false, nowPlayingMsg: false, }; + if (!this.config.avatarEnabled && this.customAvatar) { + const gen = this.generation; + void this.applyIdleAvatar(gen); + } } getConfig(): ProfileConfig { @@ -171,6 +181,10 @@ export class BotProfileManager { } private async clearAvatar(gen: number): Promise { + if (this.customAvatar && this.customAvatar.length > 0) { + await this.applyIdleAvatar(gen); + return; + } try { await this.withTimeout( this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]), @@ -179,7 +193,6 @@ export class BotProfileManager { } catch { // 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; try { await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar="); @@ -188,6 +201,18 @@ export class BotProfileManager { } } + private async applyIdleAvatar(gen: number): Promise { + 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 { if (!this.config.descriptionEnabled || this.permDenied.description) return; try {