refactor: unify backend voice signaling with VoiceRoom abstraction

Replace dual voiceStates + activeCalls maps with a single VoiceRoom
system that tracks both server channels and DM calls uniformly.

Fixes four bugs:
- voice_status silently dropped for DM call participants
- DM calls not cleaned up on WebSocket disconnect
- DM call state missing from ready payload on reconnect
- No spatial tracking of DM call participants
This commit is contained in:
Jannis Braun
2026-02-23 22:15:41 +01:00
parent 628d417723
commit 653e59bfb2
4 changed files with 504 additions and 200 deletions
+188 -125
View File
@@ -2,6 +2,7 @@ import { eq, inArray, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
import { isMember, getChannelServerId, isDmMember } from '../utils/permissions.js';
import { broadcastDmMessage } from '../routes/dm.js';
import type { User, MessageWithUser, Attachment, DmMessageWithUser } from '@opencord/shared';
@@ -366,6 +367,37 @@ function handlePresenceUpdate(event: Record<string, unknown>, userId: string): v
});
}
// ─── Voice Handlers (Unified Room API) ─────────────────────────────────────
/** Helper: broadcast a voice leave and auto-end empty DM calls. */
function broadcastRoomLeave(roomId: string, room: VoiceRoom, userId: string): void {
if (room.roomType === 'server') {
const meta = room.metadata as ServerRoomMeta;
connectionManager.sendToServer(meta.serverId, {
type: 'voice_state_update',
channelId: roomId,
userId,
action: 'leave',
});
} else {
connectionManager.sendToDmMembers(roomId, {
type: 'voice_state_update',
channelId: roomId,
userId,
action: 'leave',
});
// Auto-end call if DM room is now empty and was active
const updatedRoom = connectionManager.getRoom(roomId);
if (updatedRoom && updatedRoom.participants.size === 0 && (updatedRoom.metadata as DmRoomMeta).state === 'active') {
connectionManager.destroyRoom(roomId);
connectionManager.sendToDmMembers(roomId, {
type: 'dm_call_ended',
dmChannelId: roomId,
});
}
}
}
function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
@@ -385,13 +417,13 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return;
}
// If the user is already in this exact channel (e.g. WS reconnect re-registration),
// If the user is already in this exact room (e.g. WS reconnect re-registration),
// skip the leave+join broadcast to avoid visual flicker for other users.
const currentChannel = connectionManager.getUserVoiceChannel(userId);
if (currentChannel === channelId) {
const currentRoom = connectionManager.getUserRoom(userId);
if (currentRoom && currentRoom.roomId === channelId) {
const status = connectionManager.getVoiceUserStatus(userId);
if (status) {
connectionManager.sendToServer(serverId, {
connectionManager.sendToRoom(channelId, {
type: 'voice_status_update',
userId,
channelId,
@@ -404,25 +436,35 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return;
}
// Leave any current voice channel
const previousChannel = connectionManager.leaveAllVoice(userId);
if (previousChannel) {
const prevServerId = getChannelServerId(previousChannel);
if (prevServerId) {
connectionManager.sendToServer(prevServerId, {
type: 'voice_state_update',
channelId: previousChannel,
userId,
action: 'leave',
});
// Leave current room (server OR DM)
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
broadcastRoomLeave(left.roomId, left.room, userId);
}
// Cancel any ringing DM rooms where this user is the caller
// (edge case: user starts DM call then joins server voice before anyone accepts)
for (const [roomId, room] of connectionManager.getAllRooms()) {
if (room.roomType === 'dm') {
const meta = room.metadata as DmRoomMeta;
if (meta.state === 'ringing' && meta.callerId === userId) {
connectionManager.destroyRoom(roomId);
connectionManager.sendToDmMembers(roomId, {
type: 'dm_call_ended',
dmChannelId: roomId,
});
}
}
}
// Join new voice channel
connectionManager.joinVoice(channelId, userId);
// Lazy-create server room
connectionManager.createRoom(channelId, 'server', { type: 'server', serverId });
// Broadcast to server
connectionManager.sendToServer(serverId, {
// Join room
connectionManager.joinRoom(channelId, userId);
// Broadcast join
connectionManager.sendToRoom(channelId, {
type: 'voice_state_update',
channelId,
userId,
@@ -432,7 +474,7 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
// Also broadcast current voice status if it exists (persisted during moves)
const status = connectionManager.getVoiceUserStatus(userId);
if (status) {
connectionManager.sendToServer(serverId, {
connectionManager.sendToRoom(channelId, {
type: 'voice_status_update',
userId,
channelId,
@@ -445,20 +487,40 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
}
function handleVoiceLeave(userId: string): void {
const channelId = connectionManager.leaveAllVoice(userId);
if (channelId) {
const serverId = getChannelServerId(channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
type: 'voice_state_update',
channelId,
userId,
action: 'leave',
});
}
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
broadcastRoomLeave(left.roomId, left.room, userId);
}
connectionManager.clearVoiceUserStatus(userId);
}
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
const isMuted = event.isMuted === true;
const isDeafened = event.isDeafened === true;
const isCameraOn = event.isCameraOn === true;
const isScreenSharing = event.isScreenSharing === true;
// BUG FIX: uses unified getUserRoom() instead of server-only getUserVoiceChannel()
// This now works for both server channels AND DM calls.
const userRoom = connectionManager.getUserRoom(userId);
if (!userRoom) return;
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing);
// sendToRoom routes to sendToServer for server rooms, sendToDmMembers for DM rooms
connectionManager.sendToRoom(userRoom.roomId, {
type: 'voice_status_update',
userId,
channelId: userRoom.roomId,
isMuted,
isDeafened,
isCameraOn,
isScreenSharing,
});
}
// ─── DM Message Handlers ───────────────────────────────────────────────────
function handleDmMessageCreate(event: Record<string, unknown>, userId: string): void {
const dmChannelId = event.dmChannelId as string;
const content = event.content as string;
@@ -650,6 +712,8 @@ function handleDmMessageDelete(event: Record<string, unknown>, userId: string):
}
}
// ─── Reaction Handlers ─────────────────────────────────────────────────────
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
const messageId = event.messageId as string;
const emoji = event.emoji as string;
@@ -720,6 +784,8 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
}
}
// ─── Read State Handler ────────────────────────────────────────────────────
function handleChannelAck(event: Record<string, unknown>, userId: string): void {
const channelId = event.channelId as string;
const messageId = event.messageId as string;
@@ -765,32 +831,7 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
});
}
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
const isMuted = event.isMuted === true;
const isDeafened = event.isDeafened === true;
const isCameraOn = event.isCameraOn === true;
const isScreenSharing = event.isScreenSharing === true;
const channelId = connectionManager.getUserVoiceChannel(userId);
if (!channelId) return;
const serverId = getChannelServerId(channelId);
if (!serverId) return;
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing);
connectionManager.sendToServer(serverId, {
type: 'voice_status_update',
userId,
channelId,
isMuted,
isDeafened,
isCameraOn,
isScreenSharing,
});
}
// ─── DM Call Handlers ──────────────────────────────────────────────────────────
// ─── DM Call Handlers (Unified Room API) ───────────────────────────────────
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
const dmChannelId = event.dmChannelId as string;
@@ -804,30 +845,41 @@ function handleDmCallStart(event: Record<string, unknown>, userId: string, usern
return;
}
// Try to start the call (fails if already active)
const started = connectionManager.startCall(dmChannelId, userId);
if (!started) {
// Leave current room if in one
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
broadcastRoomLeave(left.roomId, left.room, userId);
}
connectionManager.clearVoiceUserStatus(userId);
// Cancel any other ringing rooms started by this user
for (const [roomId, room] of connectionManager.getAllRooms()) {
if (room.roomType === 'dm' && roomId !== dmChannelId) {
const meta = room.metadata as DmRoomMeta;
if (meta.state === 'ringing' && meta.callerId === userId) {
connectionManager.destroyRoom(roomId);
connectionManager.sendToDmMembers(roomId, {
type: 'dm_call_ended',
dmChannelId: roomId,
});
}
}
}
// Create DM room in ringing state (fails if already active)
const created = connectionManager.createDmRoom(dmChannelId, userId);
if (!created) {
connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' });
return;
}
// Find the other DM member(s) and send incoming call notification
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
if (member.userId !== userId) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_incoming',
dmChannelId,
callerId: userId,
callerName: username,
});
}
}
// Ring other members
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_incoming',
dmChannelId,
callerId: userId,
callerName: username,
}, userId);
}
function handleDmCallAccept(event: Record<string, unknown>, userId: string): void {
@@ -842,25 +894,51 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string): voi
return;
}
const activeCall = connectionManager.getActiveCall(dmChannelId);
if (!activeCall) {
const room = connectionManager.getRoom(dmChannelId);
if (!room || room.roomType !== 'dm') {
connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' });
return;
}
// Notify all DM members that the call was accepted
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
const meta = room.metadata as DmRoomMeta;
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_accepted',
dmChannelId,
});
// Activate the room (ringing → active)
connectionManager.activateDmRoom(dmChannelId);
// Leave current rooms for both caller and acceptor
const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId);
if (callerLeft) {
broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId);
}
const acceptorLeft = connectionManager.leaveCurrentRoom(userId);
if (acceptorLeft) {
broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId);
}
// Join both participants
connectionManager.joinRoom(dmChannelId, meta.callerId);
connectionManager.joinRoom(dmChannelId, userId);
// Notify all DM members that the call was accepted
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_accepted',
dmChannelId,
});
// Broadcast voice_state_update join for both participants
// This populates the frontend's voiceUsers map generically
connectionManager.sendToDmMembers(dmChannelId, {
type: 'voice_state_update',
channelId: dmChannelId,
userId: meta.callerId,
action: 'join',
});
connectionManager.sendToDmMembers(dmChannelId, {
type: 'voice_state_update',
channelId: dmChannelId,
userId,
action: 'join',
});
}
function handleDmCallReject(event: Record<string, unknown>, userId: string): void {
@@ -875,27 +953,17 @@ function handleDmCallReject(event: Record<string, unknown>, userId: string): voi
return;
}
const activeCall = connectionManager.getActiveCall(dmChannelId);
if (!activeCall) {
return; // No active call, silently ignore
}
const room = connectionManager.getRoom(dmChannelId);
if (!room) return; // No active call, silently ignore
// End the call since it was rejected
connectionManager.endCall(dmChannelId);
// Destroy room
connectionManager.destroyRoom(dmChannelId);
// Notify all DM members that the call was rejected
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_rejected',
dmChannelId,
});
}
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_rejected',
dmChannelId,
});
}
function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
@@ -910,25 +978,20 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
return;
}
const activeCall = connectionManager.getActiveCall(dmChannelId);
if (!activeCall) {
return; // No active call, silently ignore
const room = connectionManager.getRoom(dmChannelId);
if (!room) return; // No active call, silently ignore
// Clear voice user states for all participants
for (const participantId of room.participants) {
connectionManager.clearVoiceUserStatus(participantId);
}
// End the call
connectionManager.endCall(dmChannelId);
// Destroy room (removes all participants from userToRoom)
connectionManager.destroyRoom(dmChannelId);
// Notify all DM members that the call ended
const db = getDb();
const dmMembers = db.select()
.from(schema.dmMembers)
.where(eq(schema.dmMembers.dmChannelId, dmChannelId))
.all();
for (const member of dmMembers) {
connectionManager.sendToUser(member.userId, {
type: 'dm_call_ended',
dmChannelId,
});
}
connectionManager.sendToDmMembers(dmChannelId, {
type: 'dm_call_ended',
dmChannelId,
});
}