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;
}
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<VoiceBasedChannel, VoiceConnection>();
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<void> {
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<void> {
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<void> {
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<void> {
if (!this.currentConnection) return;
this.queue.flush();
this.currentConnection.destroy();
this.currentConnection = undefined;
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";
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);

View File

@@ -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!");
});
};

View File

@@ -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);
});