diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 4fc0638..c0697b7 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -456,11 +456,16 @@ export class BotInstance extends EventEmitter { this.voiceDucking.removeSpeaker(event.id); void this.refreshOccupancy(); }); - this.tsClient.on("clientMoved", (event: { id: number }) => { + this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => { if (event.id === this.tsClient.getClientId()) { // Moving the bot invalidates every activity deadline from its old // channel even if no individual leave events arrive. this.voiceDucking.reset(false); + // Carry the now-playing channel description over to the new + // channel instead of leaving it stale in the old one (#159). + this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => { + this.logger.warn({ err }, "Channel description move update failed"); + }); } else { this.voiceDucking.removeSpeaker(event.id); } diff --git a/src/bot/profile.test.ts b/src/bot/profile.test.ts index 59ebf93..2575e74 100644 --- a/src/bot/profile.test.ts +++ b/src/bot/profile.test.ts @@ -202,3 +202,65 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => { expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true); }); }); + +describe("BotProfileManager channel description follows the bot (#159)", () => { + const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true }; + let ts: ReturnType & { cid: bigint }; + let channelEdits: () => string[]; + + beforeEach(() => { + ts = makeMockTs() as any; + ts.cid = 5n; + (ts as any).getChannelId = () => ts.cid; + channelEdits = () => + (ts.sendCommandNoWait as any).mock.calls + .map((c: any[]) => c[0] as string) + .filter((cmd: string) => cmd.startsWith("channeledit")); + }); + + it("clears the old channel and fills the new one when moved while playing", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + expect(channelEdits()).toEqual([ + expect.stringMatching(/^channeledit cid=5 channel_description=\S+/), + ]); + + ts.cid = 9n; + await pm.onChannelMoved(9n); + + const edits = channelEdits(); + expect(edits[1]).toBe("channeledit cid=5 channel_description="); + expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/); + }); + + it("stopping after a move clears the channel the bot is in now, not the old one", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + ts.cid = 9n; + await pm.onChannelMoved(9n); + await pm.onSongChange(null); + expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description="); + }); + + it("a move while idle touches no channel description", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + ts.cid = 9n; + await pm.onChannelMoved(9n); + expect(channelEdits()).toEqual([]); + }); + + it("a move is ignored when the channel description feature is off", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot"); + await pm.onSongChange(fakeSong); + ts.cid = 9n; + await pm.onChannelMoved(9n); + expect(channelEdits()).toEqual([]); + }); + + it("an event for the channel the description is already in is a no-op", async () => { + const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot"); + await pm.onSongChange(fakeSong); + await pm.onChannelMoved(5n); + expect(channelEdits()).toHaveLength(1); + }); +}); diff --git a/src/bot/profile.ts b/src/bot/profile.ts index 3c04ef7..3ce0d20 100644 --- a/src/bot/profile.ts +++ b/src/bot/profile.ts @@ -32,6 +32,13 @@ export class BotProfileManager { * pushed immediately (idle) or wait for the next stop event (playing). */ private currentSong: QueuedSong | null = null; + /** + * Channel whose description currently holds our now-playing text, or null + * if we have not written one. Remembered so that when the bot is moved we + * can still clean up the channel it was taken out of (#159) — by then + * getChannelId() already reports the new channel. + */ + private channelDescCid: bigint | null = null; /** Per-feature permission-denied flags. Reset on reconnect. */ private permDenied = { @@ -134,6 +141,8 @@ export class BotProfileManager { onConnect(): void { this.generation++; this.currentSong = null; + // Channel ids are per-server; never carry one across a (re)connect. + this.channelDescCid = null; this.permDenied = { avatar: false, description: false, @@ -150,6 +159,31 @@ export class BotProfileManager { } } + /** + * Called when the bot itself has been moved to another channel (#159). + * Clears the now-playing text from the channel it left and, if a song is + * playing, writes it to the channel it is in now. + */ + async onChannelMoved(newChannelId: bigint): Promise { + if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return; + const oldChannelId = this.channelDescCid; + if (oldChannelId === newChannelId) return; + try { + if (oldChannelId !== null) { + await this.tsClient.sendCommandNoWait( + `channeledit cid=${oldChannelId} channel_description=`, + ); + this.channelDescCid = null; + } + } catch (err) { + this.handleFeatureError("channelDesc", err); + return; + } + if (this.currentSong) { + await this.updateChannelDescription(this.currentSong, newChannelId); + } + } + getConfig(): ProfileConfig { return { ...this.config }; } @@ -400,18 +434,25 @@ export class BotProfileManager { return str.slice(0, end) + ellipsis; } - private async updateChannelDescription(song: QueuedSong | null): Promise { + private async updateChannelDescription( + song: QueuedSong | null, + targetChannelId?: bigint, + ): Promise { if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return; try { - const channelId = this.tsClient.getChannelId(); - if (channelId === 0n) return; // unknown channel - + const channelId = targetChannelId ?? this.tsClient.getChannelId(); if (!song) { + // Prefer the channel we actually wrote to, in case a move event + // was missed and the bot is somewhere else now. + const target = this.channelDescCid ?? channelId; + if (target === 0n) return; // unknown channel await this.tsClient.sendCommandNoWait( - `channeledit cid=${channelId} channel_description=`, + `channeledit cid=${target} channel_description=`, ); + this.channelDescCid = null; return; } + if (channelId === 0n) return; // unknown channel const lines = [ `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放: @@ -422,6 +463,7 @@ export class BotProfileManager { await this.tsClient.sendCommandNoWait( `channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`, ); + this.channelDescCid = channelId; } catch (err) { this.handleFeatureError("channelDesc", err); }