Fix: Separate voice groups so radio + soundboard play in parallel
Each plugin now uses its own @discordjs/voice group: - Radio: group='radio' - Soundboard: group='soundboard' This prevents joinVoiceChannel from one bot overwriting the other bot's connection. Both bots can now play simultaneously in the same voice channel. Removed claimVoice system (not needed with separate bots). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3cd9f6f169
commit
b9a9347356
2 changed files with 12 additions and 19 deletions
|
|
@ -7,7 +7,6 @@ import {
|
||||||
import type { VoiceBasedChannel } from 'discord.js';
|
import type { VoiceBasedChannel } from 'discord.js';
|
||||||
import { ChannelType } from 'discord.js';
|
import { ChannelType } from 'discord.js';
|
||||||
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
||||||
import { claimVoice, releaseVoice } from '../../core/plugin.js';
|
|
||||||
import { sseBroadcast } from '../../core/sse.js';
|
import { sseBroadcast } from '../../core/sse.js';
|
||||||
import { getState, setState } from '../../core/persistence.js';
|
import { getState, setState } from '../../core/persistence.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -67,9 +66,8 @@ function stopStream(guildId: string): void {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
try { state.ffmpeg.kill('SIGKILL'); } catch {}
|
try { state.ffmpeg.kill('SIGKILL'); } catch {}
|
||||||
try { state.player.stop(true); } catch {}
|
try { state.player.stop(true); } catch {}
|
||||||
try { getVoiceConnection(guildId)?.destroy(); } catch {}
|
try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {}
|
||||||
guildRadioState.delete(guildId);
|
guildRadioState.delete(guildId);
|
||||||
releaseVoice(guildId, 'radio');
|
|
||||||
broadcastState(guildId);
|
broadcastState(guildId);
|
||||||
console.log(`[Radio] Stopped stream in guild ${guildId}`);
|
console.log(`[Radio] Stopped stream in guild ${guildId}`);
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +95,7 @@ async function startStream(
|
||||||
guildId,
|
guildId,
|
||||||
adapterCreator: guild.voiceAdapterCreator,
|
adapterCreator: guild.voiceAdapterCreator,
|
||||||
selfDeaf: true,
|
selfDeaf: true,
|
||||||
|
group: 'radio',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -128,7 +127,7 @@ async function startStream(
|
||||||
if (guildRadioState.has(guildId)) {
|
if (guildRadioState.has(guildId)) {
|
||||||
console.log(`[Radio] ffmpeg exited (code ${code}), cleaning up`);
|
console.log(`[Radio] ffmpeg exited (code ${code}), cleaning up`);
|
||||||
guildRadioState.delete(guildId);
|
guildRadioState.delete(guildId);
|
||||||
try { connection.destroy(); } catch {}
|
try { getVoiceConnection(guildId, 'radio')?.destroy(); } catch {}
|
||||||
broadcastState(guildId);
|
broadcastState(guildId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -150,9 +149,6 @@ async function startStream(
|
||||||
stopStream(guildId);
|
stopStream(guildId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Claim voice for this guild (stops other plugins like soundboard)
|
|
||||||
claimVoice(guildId, 'radio', () => stopStream(guildId));
|
|
||||||
|
|
||||||
// State tracken
|
// State tracken
|
||||||
const channelName = 'name' in channel ? (channel as any).name : voiceChannelId;
|
const channelName = 'name' in channel ? (channel as any).name : voiceChannelId;
|
||||||
guildRadioState.set(guildId, {
|
guildRadioState.set(guildId, {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import sodium from 'libsodium-wrappers';
|
||||||
import nacl from 'tweetnacl';
|
import nacl from 'tweetnacl';
|
||||||
import { ChannelType, Events, type VoiceState, type Message } from 'discord.js';
|
import { ChannelType, Events, type VoiceState, type Message } from 'discord.js';
|
||||||
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
import type { Plugin, PluginContext } from '../../core/plugin.js';
|
||||||
import { claimVoice } from '../../core/plugin.js';
|
|
||||||
import { sseBroadcast } from '../../core/sse.js';
|
import { sseBroadcast } from '../../core/sse.js';
|
||||||
|
|
||||||
// ── Config (env) ──
|
// ── Config (env) ──
|
||||||
|
|
@ -239,7 +238,7 @@ async function ensureConnectionReady(connection: VoiceConnection, channelId: str
|
||||||
try { connection.destroy(); } catch {}
|
try { connection.destroy(); } catch {}
|
||||||
guildAudioState.delete(guildId);
|
guildAudioState.delete(guildId);
|
||||||
console.log(`${SB} Creating fresh connection (attempt 3)...`);
|
console.log(`${SB} Creating fresh connection (attempt 3)...`);
|
||||||
const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`));
|
newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`));
|
||||||
newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`));
|
newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`));
|
||||||
try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; }
|
try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; }
|
||||||
|
|
@ -270,13 +269,13 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
catch {
|
catch {
|
||||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
||||||
else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {}
|
else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {}
|
||||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||||||
}
|
}
|
||||||
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
||||||
console.warn(`${SB} Connection destroyed, recreating...`);
|
console.warn(`${SB} Connection destroyed, recreating...`);
|
||||||
connectedSince.delete(state.guildId);
|
connectedSince.delete(state.guildId);
|
||||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
|
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild);
|
||||||
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
} else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) {
|
||||||
isReconnecting = true;
|
isReconnecting = true;
|
||||||
|
|
@ -287,7 +286,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
||||||
console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`);
|
console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`);
|
||||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); }
|
||||||
else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {}
|
else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {}
|
||||||
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -333,8 +332,6 @@ let _pluginCtx: PluginContext | null = null;
|
||||||
|
|
||||||
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
|
async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise<void> {
|
||||||
console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`);
|
console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`);
|
||||||
// Claim voice for this guild (stops radio if playing)
|
|
||||||
claimVoice(guildId, 'soundboard', () => { /* soundboard cleanup handled by lifecycle */ });
|
|
||||||
const ctx = _pluginCtx!;
|
const ctx = _pluginCtx!;
|
||||||
const guild = ctx.client.guilds.cache.get(guildId);
|
const guild = ctx.client.guilds.cache.get(guildId);
|
||||||
if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }
|
if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); }
|
||||||
|
|
@ -342,7 +339,7 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
||||||
let state = guildAudioState.get(guildId);
|
let state = guildAudioState.get(guildId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
console.log(`${SB} No existing audio state, creating new connection...`);
|
console.log(`${SB} No existing audio state, creating new connection...`);
|
||||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
// Debug: catch ALL state transitions and errors from the start
|
// Debug: catch ALL state transitions and errors from the start
|
||||||
connection.on('stateChange', (o: any, n: any) => {
|
connection.on('stateChange', (o: any, n: any) => {
|
||||||
console.log(`${SB} [conn] ${o.status} → ${n.status}`);
|
console.log(`${SB} [conn] ${o.status} → ${n.status}`);
|
||||||
|
|
@ -364,10 +361,10 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
||||||
|
|
||||||
// Channel-Wechsel
|
// Channel-Wechsel
|
||||||
try {
|
try {
|
||||||
const current = getVoiceConnection(guildId);
|
const current = getVoiceConnection(guildId, 'soundboard');
|
||||||
if (current && current.joinConfig?.channelId !== channelId) {
|
if (current && current.joinConfig?.channelId !== channelId) {
|
||||||
current.destroy();
|
current.destroy();
|
||||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||||
connection.subscribe(player);
|
connection.subscribe(player);
|
||||||
state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
|
state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) };
|
||||||
|
|
@ -377,8 +374,8 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (!getVoiceConnection(guildId)) {
|
if (!getVoiceConnection(guildId, 'soundboard')) {
|
||||||
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false });
|
const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' });
|
||||||
const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||||
connection.subscribe(player);
|
connection.subscribe(player);
|
||||||
state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
|
state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue