From ffb229b5139aa0a5d9ad768b0518521bee43fdc1 Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 14 May 2026 21:18:45 +0200 Subject: [PATCH] Fix multi channel happenings --- src/audio/AudioService.ts | 55 ++++++++++++++++++++++----------- src/modules/announcer.ts | 64 +++++++++++++++++++++++++++++++++------ src/modules/summon.ts | 2 +- src/modules/welcomer.ts | 2 +- 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/src/audio/AudioService.ts b/src/audio/AudioService.ts index 59c07ec..721d5ff 100644 --- a/src/audio/AudioService.ts +++ b/src/audio/AudioService.ts @@ -18,45 +18,64 @@ export interface AudioServiceOptions { tts: TTSRegistry; } +const SUMMON_LOCK_MS = 60_000; + +export interface MoveOptions { + summon?: boolean; +} + export class AudioService { readonly player: AudioPlayer = createAudioPlayer(); readonly queue: AudioQueue = new AudioQueue(this.player); - private joinedChannels: VoiceBasedChannel[] = []; - private connections = new Map(); + private currentChannel: VoiceBasedChannel | undefined; + private currentConnection: VoiceConnection | undefined; + private summonLockUntil = 0; constructor(private readonly opts: AudioServiceOptions) {} - getActiveVoiceChannel(): VoiceBasedChannel | undefined { - return this.joinedChannels[0]; + getCurrentChannel(): VoiceBasedChannel | undefined { + return this.currentChannel; } - isInVoiceChannel(channel: VoiceBasedChannel): boolean { - return this.joinedChannels.includes(channel); + getCurrentConnection(): VoiceConnection | undefined { + return this.currentConnection; } - getConnectionForVoiceChannel(channel: VoiceBasedChannel): VoiceConnection | undefined { - return this.connections.get(channel); + isInChannel(channel: VoiceBasedChannel): boolean { + return this.currentChannel?.id === channel.id; } - async joinChannel(channel: VoiceBasedChannel): Promise { - if (this.isInVoiceChannel(channel)) return; + isSummonLocked(): boolean { + if (this.summonLockUntil <= Date.now()) return false; + // Release the lock early if the summon channel has only the bot left. + if ((this.currentChannel?.members.size ?? 0) < 2) return false; + return true; + } + + async moveTo(channel: VoiceBasedChannel, opts: MoveOptions = {}): Promise { + if (opts.summon) this.summonLockUntil = Date.now() + SUMMON_LOCK_MS; + if (this.currentChannel?.id === channel.id) return; + const connection = joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, adapterCreator: createAdapter(channel), }); connection.subscribe(this.player); - this.joinedChannels.push(channel); - this.connections.set(channel, connection); + + this.currentConnection?.destroy(); + this.currentChannel = channel; + this.currentConnection = connection; } - async leaveChannel(channel: VoiceBasedChannel): Promise { - if (!this.isInVoiceChannel(channel)) return; - const conn = this.connections.get(channel); - this.joinedChannels = this.joinedChannels.filter((c) => c !== channel); - conn?.disconnect(); - this.connections.delete(channel); + async leave(): Promise { + if (!this.currentConnection) return; + this.queue.flush(); + this.currentConnection.destroy(); + this.currentConnection = undefined; + this.currentChannel = undefined; + this.summonLockUntil = 0; } /** diff --git a/src/modules/announcer.ts b/src/modules/announcer.ts index 86cbf21..7775f9f 100644 --- a/src/modules/announcer.ts +++ b/src/modules/announcer.ts @@ -1,21 +1,67 @@ +import type { Guild, VoiceBasedChannel } from "discord.js"; +import { ChannelType } from "discord.js"; import type { Module } from "./types.js"; +function chooseTargetChannel( + guild: Guild, + recentEventChannel: VoiceBasedChannel | null | undefined, + currentChannelId: string | undefined, +): VoiceBasedChannel | undefined { + let best: VoiceBasedChannel | undefined; + let bestCount = 0; + + for (const ch of guild.channels.cache.values()) { + if (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice) continue; + const voice = ch as VoiceBasedChannel; + const humans = voice.members.filter((m) => !m.user.bot).size; + if (humans === 0) continue; + + if (humans > bestCount) { + best = voice; + bestCount = humans; + continue; + } + if (humans === bestCount && best) { + // Tie-break: prefer the channel that just had activity, then the bot's current channel. + if (recentEventChannel && voice.id === recentEventChannel.id) { + best = voice; + } else if ( + currentChannelId && + voice.id === currentChannelId && + best.id !== recentEventChannel?.id + ) { + best = voice; + } + } + } + + return best; +} + export const announcer: Module = ({ client, audio, tts, t }) => { client.on("voiceStateUpdate", async (oldState, newState) => { if (newState.member?.user.bot) return; - if (oldState.channel && newState.channel) return; - const channel = oldState.channel ?? newState.channel; - if (!channel) return; - const joined = !oldState.channel; + const guild = newState.guild ?? oldState.guild; + const recentChannel = newState.channel ?? oldState.channel; - if (!joined && channel.members.size < 2) { - audio.queue.flush(); - await audio.leaveChannel(channel); - return; + if (!audio.isSummonLocked()) { + const target = chooseTargetChannel(guild, recentChannel, audio.getCurrentChannel()?.id); + const current = audio.getCurrentChannel(); + if (!target && current) { + await audio.leave(); + } else if (target && target.id !== current?.id) { + await audio.moveTo(target); + } } - await audio.joinChannel(channel); + const joined = !oldState.channel && !!newState.channel; + const left = !!oldState.channel && !newState.channel; + if (!joined && !left) return; + + const eventChannel = newState.channel ?? oldState.channel; + const current = audio.getCurrentChannel(); + if (!eventChannel || !current || eventChannel.id !== current.id) return; const username = newState.member?.displayName ?? oldState.member?.displayName ?? "someone"; const str = joined ? t("USER_JOINED", username) : t("USER_LEFT", username); diff --git a/src/modules/summon.ts b/src/modules/summon.ts index 1cb97d2..fb124d0 100644 --- a/src/modules/summon.ts +++ b/src/modules/summon.ts @@ -8,7 +8,7 @@ export const summon: Module = ({ audio, commands, rootDir }) => { commands.register("summon", async (_args, message) => { const channel = message.member?.voice.channel; if (!channel) return; - await audio.joinChannel(channel); + await audio.moveTo(channel, { summon: true }); respond(audio, sysmsg, message, "Hi!"); }); }; diff --git a/src/modules/welcomer.ts b/src/modules/welcomer.ts index 232096d..e674130 100644 --- a/src/modules/welcomer.ts +++ b/src/modules/welcomer.ts @@ -14,7 +14,7 @@ export const welcomer: Module = ({ client, audio, config, strings, rootDir }) => return; } const voiceChannel = channel as VoiceBasedChannel; - await audio.joinChannel(voiceChannel); + await audio.moveTo(voiceChannel, { summon: true }); audio.queue.add(sysstart); await audio.speak(voiceChannel, strings.WELCOME); });