-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Description
Summary
Implement voice channel presence tracking to automatically sync participants with RallyRound when users join/leave the VC during an active session.
Background
Discord VC presence is the source of truth for who is "in" a session. When users join or leave the voice channel, DiscordStats must notify RallyRound so the participant list stays accurate.
Requirements
Voice State Update Handler
Listen for Discord.js voiceStateUpdate events:
// src/events/voice-state.ts
client.on('voiceStateUpdate', async (oldState, newState) => {
const session = sessionRegistry.getSessionByChannel(
newState.channelId || oldState.channelId
);
if (!session) return; // No active session in this channel
if (!oldState.channelId && newState.channelId) {
// User joined VC
await handleUserJoined(session, newState);
} else if (oldState.channelId && !newState.channelId) {
// User left VC
await handleUserLeft(session, oldState);
} else if (oldState.channelId !== newState.channelId) {
// User moved channels
await handleUserMoved(session, oldState, newState);
}
});User Joined Handler
When a user joins the session's VC:
async function handleUserJoined(sessionId: string, state: VoiceState): Promise<void> {
const user = state.member?.user;
if (!user || user.bot) return; // Ignore bots
try {
await rallyRoundClient.addParticipant(getBotToken(), sessionId, {
odiscordId: user.id,
discordUsername: user.username,
discordAvatar: user.avatar,
source: 'discord'
});
// Optionally announce in text channel
await announceJoin(sessionId, user);
} catch (error) {
if (error.statusCode === 200) {
// Already in session, ignore
} else {
console.error('Failed to add participant:', error);
}
}
}User Left Handler
When a user leaves the session's VC:
async function handleUserLeft(sessionId: string, state: VoiceState): Promise<void> {
const user = state.member?.user;
if (!user || user.bot) return;
try {
const result = await rallyRoundClient.removeParticipant(
getBotToken(),
sessionId,
user.id
);
// If they were speaker or in queue, RallyRound handles cleanup
if (result.wasSpeaker) {
await announceInChannel(sessionId, `${user.username} left while speaking`);
}
} catch (error) {
console.error('Failed to remove participant:', error);
}
}Initial Sync on Session Start
When a session starts, sync all current VC members:
async function syncExistingMembers(
sessionId: string,
voiceChannel: VoiceChannel
): Promise<void> {
const members = voiceChannel.members.filter(m => !m.user.bot);
for (const [_, member] of members) {
await rallyRoundClient.addParticipant(getBotToken(), sessionId, {
odiscordId: member.user.id,
discordUsername: member.user.username,
discordAvatar: member.user.avatar,
source: 'discord'
});
}
}Bot VC Connection
The bot should join the VC when a session starts (for sound effects):
// src/voice/connection.ts
import { joinVoiceChannel, VoiceConnection } from '@discordjs/voice';
const connections: Map<string, VoiceConnection> = new Map();
async function joinSessionVC(
guildId: string,
channelId: string,
sessionId: string
): Promise<VoiceConnection> {
const connection = joinVoiceChannel({
channelId,
guildId,
adapterCreator: guild.voiceAdapterCreator,
selfDeaf: false,
selfMute: true // Bot doesn't need to speak initially
});
connections.set(sessionId, connection);
connection.on('stateChange', (oldState, newState) => {
if (newState.status === 'disconnected') {
handleBotDisconnect(sessionId);
}
});
return connection;
}
async function leaveSessionVC(sessionId: string): Promise<void> {
const connection = connections.get(sessionId);
if (connection) {
connection.destroy();
connections.delete(sessionId);
}
}Reconnection Handling
Handle bot disconnection gracefully:
async function handleBotDisconnect(sessionId: string): Promise<void> {
const session = sessionRegistry.getSession(sessionId);
if (!session) return;
// Notify facilitator
await sendToChannel(session.textChannelId,
"⚠️ Bot disconnected from voice. Sound effects unavailable. Attempting to reconnect..."
);
// Attempt reconnect with backoff
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
await joinSessionVC(session.guildId, session.voiceChannelId, sessionId);
await sendToChannel(session.textChannelId, "✅ Bot reconnected to voice.");
return;
} catch (error) {
attempts++;
await sleep(2000 * attempts); // Exponential backoff
}
}
await sendToChannel(session.textChannelId,
"❌ Could not reconnect to voice. Session continues without sound effects."
);
}Permission Checks
Check if user is in VC before allowing commands:
function isUserInSessionVC(
guildId: string,
odiscordId: string,
voiceChannelId: string
): boolean {
const guild = client.guilds.cache.get(guildId);
if (!guild) return false;
const member = guild.members.cache.get(odiscordId);
if (!member) return false;
return member.voice.channelId === voiceChannelId;
}Acceptance Criteria
- Bot joins VC when session starts
- All existing VC members synced on session start
- New VC joins trigger
addParticipantAPI call - VC leaves trigger
removeParticipantAPI call - Channel moves handled correctly
- Bot users (other bots) are ignored
- Bot reconnects automatically if disconnected
- Facilitator notified of bot disconnect
-
isUserInSessionVCcheck used by commands
Testing
- Start session, verify all VC members synced
- Join VC during session, verify participant added
- Leave VC, verify participant removed
- Disconnect bot manually, verify reconnection
- Move between channels, verify correct handling
Related
- Used by: Bot Commands (Issue Discord OAuth Provider for RallyRound Integration #3) for permission checks
- Used by: Sound Effects (Issue Voice Channel Presence Tracking #6) for VC connection
Metadata
Metadata
Assignees
Labels
No labels