From 2f47d32136a41cc9484a3d68af96c887c5f62fff Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:30:27 +0200 Subject: [PATCH] feat(federation): handleDmCallAccept/Reject/End with federated call support (FED-009) --- packages/server/src/ws/events.ts | 205 ++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 2 deletions(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 5e665069..6b9cc085 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -1432,6 +1432,40 @@ function handleDmCallAccept(event: Record, userId: string, ws: const room = connectionManager.getRoom(dmChannelId); if (!room || room.roomType !== 'dm') { + // Check if this is a federated call (this instance is not the host) + const fedCall = connectionManager.getFederatedCall(dmChannelId); + if (fedCall) { + // Transition local state + connectionManager.activateFederatedCall(dmChannelId); + + // Broadcast locally + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_accepted', + dmChannelId, + }); + + // Send accept to host (fire-and-forget) + const db = getDb(); + const user = db.select({ homeUserId: schema.users.homeUserId }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .get(); + const homeUserId = user?.homeUserId || userId; + + sendCallRelay(fedCall.federatedCallHost, [{ + eventType: 'dm_call_accept', + messageId: generateSnowflake(), + encryptionVersion: 0, + timestamp: Date.now(), + federatedId: fedCall.federatedId, + call: { + acceptor: { homeUserId, homeInstance: getOurOrigin() }, + }, + }]).catch(err => console.error('[federation] Failed to send dm_call_accept:', err)); + + return; + } + connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); return; } @@ -1487,6 +1521,10 @@ function handleDmCallAccept(event: Record, userId: string, ws: userId, action: 'join', }); + + // Federation: notify remote instances that the call was accepted + sendFederatedCallAccept(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallAccept error:', err)); } function handleDmCallReject(event: Record, userId: string): void { @@ -1502,7 +1540,38 @@ function handleDmCallReject(event: Record, userId: string): voi } const room = connectionManager.getRoom(dmChannelId); - if (!room) return; // No active call, silently ignore + if (!room) { + // Check federated call registry + const fedCall = connectionManager.getFederatedCall(dmChannelId); + if (fedCall) { + connectionManager.clearFederatedCall(dmChannelId); + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_rejected', + dmChannelId, + }); + + const db = getDb(); + const user = db.select({ homeUserId: schema.users.homeUserId }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .get(); + const homeUserId = user?.homeUserId || userId; + + sendCallRelay(fedCall.federatedCallHost, [{ + eventType: 'dm_call_reject', + messageId: generateSnowflake(), + encryptionVersion: 0, + timestamp: Date.now(), + federatedId: fedCall.federatedId, + call: { + rejector: { homeUserId, homeInstance: getOurOrigin() }, + }, + }]).catch(err => console.error('[federation] Failed to send dm_call_reject:', err)); + + return; + } + return; // No active call, silently ignore + } // Extract caller ID before destroying the room const meta = room.metadata as DmRoomMeta; @@ -1518,6 +1587,10 @@ function handleDmCallReject(event: Record, userId: string): voi type: 'dm_call_rejected', dmChannelId, }); + + // Federation: notify remote instances the call was rejected (= ended for 1-on-1) + sendFederatedCallEnd(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallEnd error:', err)); } function handleDmCallEnd(event: Record, userId: string): void { @@ -1533,7 +1606,37 @@ function handleDmCallEnd(event: Record, userId: string): void { } const room = connectionManager.getRoom(dmChannelId); - if (!room) return; // No active call, silently ignore + if (!room) { + const fedCall = connectionManager.getFederatedCall(dmChannelId); + if (fedCall) { + connectionManager.clearFederatedCall(dmChannelId); + connectionManager.sendToDmMembers(dmChannelId, { + type: 'dm_call_ended', + dmChannelId, + }); + + const db = getDb(); + const user = db.select({ homeUserId: schema.users.homeUserId }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .get(); + const homeUserId = user?.homeUserId || userId; + + sendCallRelay(fedCall.federatedCallHost, [{ + eventType: 'dm_call_end', + messageId: generateSnowflake(), + encryptionVersion: 0, + timestamp: Date.now(), + federatedId: fedCall.federatedId, + call: { + endedBy: { homeUserId, homeInstance: getOurOrigin() }, + }, + }]).catch(err => console.error('[federation] Failed to send dm_call_end:', err)); + + return; + } + return; // No active call, silently ignore + } // Clear voice ws binding for the caller (may not be in participants if still ringing) const meta = room.metadata as DmRoomMeta; @@ -1553,6 +1656,10 @@ function handleDmCallEnd(event: Record, userId: string): void { type: 'dm_call_ended', dmChannelId, }); + + // Federation: notify remote instances the call ended + sendFederatedCallEnd(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallEnd error:', err)); } /** @@ -1671,6 +1778,100 @@ async function sendFederatedCallStart( } } +async function sendFederatedCallAccept(dmChannelId: string, acceptorUserId: string): Promise { + const db = getDb(); + const channel = db.select({ federatedId: schema.dmChannels.federatedId }) + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)) + .get(); + if (!channel?.federatedId) return; + + const members = db.select({ homeInstance: schema.users.homeInstance }) + .from(schema.dmMembers) + .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + const ourOrigin = getOurOrigin(); + const targets = new Set(); + for (const m of members) { + if (m.homeInstance) { + const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`; + if (normalized !== ourOrigin) targets.add(normalized); + } + } + if (targets.size === 0) return; + + const user = db.select({ homeUserId: schema.users.homeUserId }) + .from(schema.users) + .where(eq(schema.users.id, acceptorUserId)) + .get(); + const homeUserId = user?.homeUserId || acceptorUserId; + + const event = { + eventType: 'dm_call_accept' as const, + messageId: generateSnowflake(), + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId: channel.federatedId, + call: { + acceptor: { homeUserId, homeInstance: ourOrigin }, + }, + }; + + await Promise.all( + Array.from(targets).map(origin => + sendCallRelay(origin, [event]).catch(err => + console.error(`[federation] Failed to send dm_call_accept to ${origin}:`, err) + ) + ) + ); +} + +async function sendFederatedCallEnd(dmChannelId: string, endedByUserId: string): Promise { + const db = getDb(); + const channel = db.select({ federatedId: schema.dmChannels.federatedId }) + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)) + .get(); + if (!channel?.federatedId) return; + + const members = db.select({ homeInstance: schema.users.homeInstance }) + .from(schema.dmMembers) + .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + const ourOrigin = getOurOrigin(); + const targets = new Set(); + for (const m of members) { + if (m.homeInstance) { + const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`; + if (normalized !== ourOrigin) targets.add(normalized); + } + } + if (targets.size === 0) return; + + const event = { + eventType: 'dm_call_end' as const, + messageId: generateSnowflake(), + encryptionVersion: 0 as const, + timestamp: Date.now(), + federatedId: channel.federatedId, + call: { + endedBy: { homeUserId: endedByUserId, homeInstance: ourOrigin }, + }, + }; + + await Promise.all( + Array.from(targets).map(origin => + sendCallRelay(origin, [event]).catch(err => + console.error(`[federation] Failed to send dm_call_end to ${origin}:`, err) + ) + ) + ); +} + // ─── Voice Moderation Handlers ────────────────────────────────────────────── function handleVoiceSpaceMute(event: Record, userId: string): void {