From 1318d6e50f04c9d9075eca2f4db7c25baa59dc84 Mon Sep 17 00:00:00 2001 From: Murilo Duran <92951229+MuDuran@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:41:49 -0300 Subject: [PATCH] fix(voice): stop stranding ghost participants in voice channels Two defects in how voice state is reconciled leave users listed in voice channels they are no longer in. 1. `VoiceChannelMove` was an unimplemented `// todo`. Its payload is `{ user, from, to, state }`, so when the server reports that someone switched voice channels the client never removed them from `from` and never added them to `to` -- a permanent ghost in the old channel *and* a missing participant in the new one, for the life of the tab. Both sides are now resolved and null-checked independently, mirroring the `VoiceChannelJoin`/`VoiceChannelLeave` cases either side of it, so one uncached channel does not skip the other. 2. `Ready` pruned only the channels the server chose to mention. The `.clear()` sat inside the per-channel loop, making reconciliation a per-channel merge rather than a rebuild, so a channel the server omits -- very likely how an emptied channel is represented -- kept its stale entries through every reconnect. The clear is now a separate pass over every known channel, still guarded on `voice_states` being present at all: if the server sends nothing, clearing everything would discard good state, which is worse than the bug. Also records why the non-creating `client.channels.get()` lookup in that block is safe -- channels are hydrated from `event.channels` a few lines above -- rather than changing it to `getOrCreate`, which has no channel data to create from. Co-Authored-By: Claude Opus 5 Signed-off-by: Murilo Duran <92951229+MuDuran@users.noreply.github.com> --- src/events/v1.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/events/v1.ts b/src/events/v1.ts index 868a472f..7054c0f9 100644 --- a/src/events/v1.ts +++ b/src/events/v1.ts @@ -320,11 +320,23 @@ export async function handleEvent( } if (event.voice_states) { + // The server only includes channels that currently have voice + // participants, so a channel that just emptied out is silently + // omitted rather than sent with an empty participant list. If we + // only cleared the channels mentioned below, an omitted channel's + // stale entries would survive this reconciliation (and every + // reconnect after it). Clear every known channel up front so the + // loop below is a full rebuild, not a per-channel merge. + for (const channel of client.channels.values()) { + channel.voiceParticipants.clear(); + } + for (const state of event.voice_states) { + // Non-creating lookup is safe: channels are always hydrated + // above (see event.channels handling a few lines up) before we + // get here, so there is no channel data left to create from. const channel = client.channels.get(state.id); if (channel) { - channel.voiceParticipants.clear(); - for (const participant of state.participants) { channel.voiceParticipants.set( participant.id, @@ -1013,7 +1025,19 @@ export async function handleEvent( break; } case "VoiceChannelMove": { - // todo + const from = client.channels.getOrPartial(event.from); + if (from) { + from.voiceParticipants.delete(event.user); + } + + const to = client.channels.getOrPartial(event.to); + if (to) { + to.voiceParticipants.set( + event.state.id, + new VoiceParticipant(client, event.state), + ); + } + // todo: event break; } case "UserVoiceStateUpdate": {