From cbcb324e24e355eec3d2aa124d30522019960164 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:24:45 +0200 Subject: [PATCH] feat(federation): add federated call registry with ringing timeout (FED-009) --- packages/server/src/ws/handler.ts | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 3f4b49a0..e6b24e10 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -58,6 +58,19 @@ export interface DmRoomMeta { state: 'ringing' | 'active'; } +/** In-memory registry for federated calls on REMOTE instances. */ +export interface FederatedCallEntry { + dmChannelId: string; + federatedId: string; + callerId: string; // local stub userId of the caller + callerHomeUserId: string; + federatedCallHost: string; // peer origin of the host instance + livekitUrl: string; + tokens: Map; // homeUserId → LiveKit token + state: 'ringing' | 'active'; + startedAt: number; +} + export interface VoiceRoom { roomId: string; roomType: 'space' | 'dm'; @@ -85,6 +98,9 @@ class ConnectionManager { private pendingOfflineTimeouts: Map = new Map(); // roomId → Timeout for ringing DM rooms (60s auto-cleanup) private ringingTimeouts: Map = new Map(); + /** Federated calls where this instance is NOT the host. Keyed by local dmChannelId. */ + private federatedCalls: Map = new Map(); + private federatedCallTimeouts: Map = new Map(); // Space-muted/deafened users (moderator action) private spaceMutedUsers: Set = new Set(); // Stores spaceId:userId private spaceDeafenedUsers: Set = new Set(); // Stores spaceId:userId @@ -427,6 +443,53 @@ class ConnectionManager { return true; } + /** Register a federated call received via S2S. Adds 60s ringing timeout. */ + createFederatedCall(entry: FederatedCallEntry): void { + this.federatedCalls.set(entry.dmChannelId, entry); + + // 60s ringing timeout — mirrors host behavior + const timeout = setTimeout(() => { + this.federatedCallTimeouts.delete(entry.dmChannelId); + const call = this.federatedCalls.get(entry.dmChannelId); + if (call && call.state === 'ringing') { + this.federatedCalls.delete(entry.dmChannelId); + this.sendToDmMembers(entry.dmChannelId, { + type: 'dm_call_ended', + dmChannelId: entry.dmChannelId, + }); + } + }, 60_000); + this.federatedCallTimeouts.set(entry.dmChannelId, timeout); + } + + /** Get a federated call entry by local dmChannelId. */ + getFederatedCall(dmChannelId: string): FederatedCallEntry | undefined { + return this.federatedCalls.get(dmChannelId); + } + + /** Transition a federated call from ringing → active. */ + activateFederatedCall(dmChannelId: string): boolean { + const call = this.federatedCalls.get(dmChannelId); + if (!call || call.state !== 'ringing') return false; + call.state = 'active'; + const timeout = this.federatedCallTimeouts.get(dmChannelId); + if (timeout) { + clearTimeout(timeout); + this.federatedCallTimeouts.delete(dmChannelId); + } + return true; + } + + /** Remove a federated call entry and clear its timeout. */ + clearFederatedCall(dmChannelId: string): void { + this.federatedCalls.delete(dmChannelId); + const timeout = this.federatedCallTimeouts.get(dmChannelId); + if (timeout) { + clearTimeout(timeout); + this.federatedCallTimeouts.delete(dmChannelId); + } + } + /** Add a user to a room. Enforces one-room-per-user invariant. Returns the room or null if room doesn't exist. */ joinRoom(roomId: string, userId: string): VoiceRoom | null { const room = this.voiceRooms.get(roomId); @@ -1192,6 +1255,30 @@ function buildReadyPayload(userId: string): { } } + // Resolve this user's homeUserId for token lookup + const readyUser = db.select({ homeUserId: schema.users.homeUserId }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .get(); + const myHomeUserId = readyUser?.homeUserId || userId; + + // Also include federated calls (this instance is NOT the host) + for (const dm of dmMemberships) { + const fedCall = connectionManager.getFederatedCall(dm.dmChannelId); + if (fedCall) { + activeCalls.push({ + dmChannelId: dm.dmChannelId, + callerId: fedCall.callerId, + participants: [], + startedAt: fedCall.startedAt, + state: fedCall.state, + federatedCallHost: fedCall.federatedCallHost, + livekitUrl: fedCall.livekitUrl, + livekitToken: fedCall.tokens.get(myHomeUserId), // only this user's token + }); + } + } + // Build voice user states — includes both space and DM participants now const voiceUserStates: Record = {}; for (const chId of Object.keys(voiceStates)) {