Fix multi channel happenings

This commit is contained in:
2026-05-14 21:18:45 +02:00
parent c8d4c4c02f
commit ffb229b513
4 changed files with 94 additions and 29 deletions

View File

@@ -18,45 +18,64 @@ export interface AudioServiceOptions {
tts: TTSRegistry; tts: TTSRegistry;
} }
const SUMMON_LOCK_MS = 60_000;
export interface MoveOptions {
summon?: boolean;
}
export class AudioService { export class AudioService {
readonly player: AudioPlayer = createAudioPlayer(); readonly player: AudioPlayer = createAudioPlayer();
readonly queue: AudioQueue = new AudioQueue(this.player); readonly queue: AudioQueue = new AudioQueue(this.player);
private joinedChannels: VoiceBasedChannel[] = []; private currentChannel: VoiceBasedChannel | undefined;
private connections = new Map<VoiceBasedChannel, VoiceConnection>(); private currentConnection: VoiceConnection | undefined;
private summonLockUntil = 0;
constructor(private readonly opts: AudioServiceOptions) {} constructor(private readonly opts: AudioServiceOptions) {}
getActiveVoiceChannel(): VoiceBasedChannel | undefined { getCurrentChannel(): VoiceBasedChannel | undefined {
return this.joinedChannels[0]; return this.currentChannel;
} }
isInVoiceChannel(channel: VoiceBasedChannel): boolean { getCurrentConnection(): VoiceConnection | undefined {
return this.joinedChannels.includes(channel); return this.currentConnection;
} }
getConnectionForVoiceChannel(channel: VoiceBasedChannel): VoiceConnection | undefined { isInChannel(channel: VoiceBasedChannel): boolean {
return this.connections.get(channel); return this.currentChannel?.id === channel.id;
} }
async joinChannel(channel: VoiceBasedChannel): Promise<void> { isSummonLocked(): boolean {
if (this.isInVoiceChannel(channel)) return; 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<void> {
if (opts.summon) this.summonLockUntil = Date.now() + SUMMON_LOCK_MS;
if (this.currentChannel?.id === channel.id) return;
const connection = joinVoiceChannel({ const connection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
adapterCreator: createAdapter(channel), adapterCreator: createAdapter(channel),
}); });
connection.subscribe(this.player); 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<void> { async leave(): Promise<void> {
if (!this.isInVoiceChannel(channel)) return; if (!this.currentConnection) return;
const conn = this.connections.get(channel); this.queue.flush();
this.joinedChannels = this.joinedChannels.filter((c) => c !== channel); this.currentConnection.destroy();
conn?.disconnect(); this.currentConnection = undefined;
this.connections.delete(channel); this.currentChannel = undefined;
this.summonLockUntil = 0;
} }
/** /**

View File

@@ -1,21 +1,67 @@
import type { Guild, VoiceBasedChannel } from "discord.js";
import { ChannelType } from "discord.js";
import type { Module } from "./types.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 }) => { export const announcer: Module = ({ client, audio, tts, t }) => {
client.on("voiceStateUpdate", async (oldState, newState) => { client.on("voiceStateUpdate", async (oldState, newState) => {
if (newState.member?.user.bot) return; 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) { if (!audio.isSummonLocked()) {
audio.queue.flush(); const target = chooseTargetChannel(guild, recentChannel, audio.getCurrentChannel()?.id);
await audio.leaveChannel(channel); const current = audio.getCurrentChannel();
return; 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 username = newState.member?.displayName ?? oldState.member?.displayName ?? "someone";
const str = joined ? t("USER_JOINED", username) : t("USER_LEFT", username); const str = joined ? t("USER_JOINED", username) : t("USER_LEFT", username);

View File

@@ -8,7 +8,7 @@ export const summon: Module = ({ audio, commands, rootDir }) => {
commands.register("summon", async (_args, message) => { commands.register("summon", async (_args, message) => {
const channel = message.member?.voice.channel; const channel = message.member?.voice.channel;
if (!channel) return; if (!channel) return;
await audio.joinChannel(channel); await audio.moveTo(channel, { summon: true });
respond(audio, sysmsg, message, "Hi!"); respond(audio, sysmsg, message, "Hi!");
}); });
}; };

View File

@@ -14,7 +14,7 @@ export const welcomer: Module = ({ client, audio, config, strings, rootDir }) =>
return; return;
} }
const voiceChannel = channel as VoiceBasedChannel; const voiceChannel = channel as VoiceBasedChannel;
await audio.joinChannel(voiceChannel); await audio.moveTo(voiceChannel, { summon: true });
audio.queue.add(sysstart); audio.queue.add(sysstart);
await audio.speak(voiceChannel, strings.WELCOME); await audio.speak(voiceChannel, strings.WELCOME);
}); });