Count listeners from the room so the bot leaves empty channels

The bot stayed in a channel everyone had left. revoice.js stores voice
users by participant identity but removes them by participant name — and
Stoat sets those to different things (the user id and username#tag) — so
its list never shrinks and the channel never looked empty to us.

Participants are now counted from the LiveKit room directly, and the
check also runs on a 30s interval, so a participant event that never
arrives cannot strand the bot either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:03:28 +03:00
co-authored by Claude Opus 5
parent 49b87dc172
commit 1eec893d15
+21 -2
View File
@@ -80,6 +80,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */ /** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
private expectingStop = false; private expectingStop = false;
private leaveTimer: NodeJS.Timeout | null = null; private leaveTimer: NodeJS.Timeout | null = null;
private presenceTimer: NodeJS.Timeout | null = null;
private ticker: NodeJS.Timeout | null = null; private ticker: NodeJS.Timeout | null = null;
private readonly log; private readonly log;
@@ -214,11 +215,19 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.setStatus("idle"); this.setStatus("idle");
this.checkEmptyChannel(); this.checkEmptyChannel();
// Belt and braces: a participant event we never receive would otherwise
// leave the bot sitting in an empty channel indefinitely.
this.presenceTimer = setInterval(() => this.checkEmptyChannel(), 30_000);
this.presenceTimer.unref?.();
this.log.info({ channelId }, "voice connection established"); this.log.info({ channelId }, "voice connection established");
} }
async leaveVoice(): Promise<void> { async leaveVoice(): Promise<void> {
this.cancelLeaveTimer(); this.cancelLeaveTimer();
if (this.presenceTimer) {
clearInterval(this.presenceTimer);
this.presenceTimer = null;
}
this.stopScreenShare(); this.stopScreenShare();
this.stopTicker(); this.stopTicker();
this.teardownPlayback(); this.teardownPlayback();
@@ -244,9 +253,19 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.publish(); this.publish();
} }
/**
* Counted straight from the LiveKit room. revoice's own list cannot be
* trusted: it stores users by participant identity but removes them by
* participant name, so nobody is ever dropped and the channel never looks
* empty — the bot would sit in it forever.
*/
private listenersInChannel(): number {
return this.connection?.room?.remoteParticipants?.size ?? 0;
}
private checkEmptyChannel(): void { private checkEmptyChannel(): void {
if (!this.connection) return; if (!this.connection) return;
if (this.connection.getUsers().length > 0) { if (this.listenersInChannel() > 0) {
this.cancelLeaveTimer(); this.cancelLeaveTimer();
return; return;
} }
@@ -549,7 +568,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
this.cancelLeaveTimer(); this.cancelLeaveTimer();
if (config.EMPTY_TIMEOUT_SECONDS <= 0 || !this.connection) return; if (config.EMPTY_TIMEOUT_SECONDS <= 0 || !this.connection) return;
this.leaveTimer = setTimeout(() => { this.leaveTimer = setTimeout(() => {
if (this.connection && this.connection.getUsers().length > 0) return; if (this.listenersInChannel() > 0) return;
this.notify("👋 В канале никого не осталось, выхожу."); this.notify("👋 В канале никого не осталось, выхожу.");
void this.leaveVoice(); void this.leaveVoice();
}, config.EMPTY_TIMEOUT_SECONDS * 1000); }, config.EMPTY_TIMEOUT_SECONDS * 1000);