From f2affb0f1dbb9cbd29c058cbe39762995df14544 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:27:42 +0200 Subject: [PATCH] feat(federation): handleDmCallStart sends S2S to remote instances (FED-009) --- packages/server/src/ws/events.ts | 125 ++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 768dda4e..5e665069 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -11,8 +11,11 @@ import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; -import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins } from '../utils/federationOutbox.js'; +import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId } from '../utils/federationOutbox.js'; import { getOurOrigin } from '../utils/federationAuth.js'; +import { generateFederatedCallToken } from '../routes/livekit.js'; +import { config } from '../config.js'; +import crypto from 'node:crypto'; /** * Re-evaluate SPEAK permission for all participants in voice channels @@ -1409,6 +1412,10 @@ function handleDmCallStart(event: Record, userId: string, usern callerId: userId, callerName: username, }, userId); + + // Federation: notify remote instances (fire-and-forget) + sendFederatedCallStart(dmChannelId, userId, username) + .catch(err => console.error('[federation] sendFederatedCallStart error:', err)); } function handleDmCallAccept(event: Record, userId: string, ws: WebSocket): void { @@ -1548,6 +1555,122 @@ function handleDmCallEnd(event: Record, userId: string): void { }); } +/** + * Send S2S dm_call_start to all remote instances with DM members. + * Fire-and-forget — if delivery fails, the call still works locally. + */ +async function sendFederatedCallStart( + dmChannelId: string, + callerId: string, + callerName: string, +): Promise { + const db = getDb(); + + // Look up DM channel for federatedId + const channel = db.select({ + federatedId: schema.dmChannels.federatedId, + ownerId: schema.dmChannels.ownerId, + }) + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, dmChannelId)) + .get(); + + if (!channel) return; + + // Get all DM members with their user records + const members = db.select({ + userId: schema.dmMembers.userId, + homeUserId: schema.users.homeUserId, + homeInstance: schema.users.homeInstance, + username: schema.users.username, + displayName: schema.users.displayName, + }) + .from(schema.dmMembers) + .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + const ourOrigin = getOurOrigin(); + + // Filter to remote members + const remoteMembers = members.filter(m => { + if (!m.homeInstance) return false; + const normalized = m.homeInstance.startsWith('http') ? m.homeInstance : `https://${m.homeInstance}`; + return normalized !== ourOrigin; + }); + + if (remoteMembers.length === 0) return; + + // Compute or reuse federatedId + let federatedId = channel.federatedId; + if (!federatedId) { + if (!channel.ownerId) { + // 1-on-1: compute from homeUserId pair + const callerMember = members.find(m => m.userId === callerId); + const otherMember = members.find(m => m.userId !== callerId); + if (!callerMember || !otherMember) return; + federatedId = computeFederatedId( + callerMember.homeUserId || callerMember.userId, + otherMember.homeUserId || otherMember.userId, + ); + } else { + federatedId = crypto.randomUUID(); + } + // Persist for future use + db.update(schema.dmChannels) + .set({ federatedId }) + .where(eq(schema.dmChannels.id, dmChannelId)) + .run(); + } + + // Check LiveKit configuration + if (!config.livekit.apiKey || !config.livekit.apiSecret) { + console.warn('[federation] Cannot start federated call: LiveKit not configured'); + return; + } + + // Group remote members by home instance + const peerGroups = new Map(); + for (const m of remoteMembers) { + const origin = m.homeInstance!.startsWith('http') ? m.homeInstance! : `https://${m.homeInstance!}`; + if (!peerGroups.has(origin)) peerGroups.set(origin, []); + peerGroups.get(origin)!.push(m); + } + + const livekitUrl = `https://${config.domain}/livekit`; + + for (const [peerOrigin, peerMembers] of peerGroups) { + // Generate per-user tokens for this peer's members + const tokens: Record = {}; + for (const m of peerMembers) { + const homeUserId = m.homeUserId || m.userId; + const name = m.displayName || m.username; + tokens[homeUserId] = await generateFederatedCallToken(federatedId, homeUserId, name); + } + + const result = await sendCallRelay(peerOrigin, [{ + eventType: 'dm_call_start', + messageId: generateSnowflake(), + encryptionVersion: 0, + timestamp: Date.now(), + federatedId, + call: { + livekitUrl, + tokens, + caller: { + homeUserId: callerId, + homeInstance: ourOrigin, + displayName: callerName, + }, + }, + }]); + + if (!result.ok) { + console.error(`[federation] Failed to send dm_call_start to ${peerOrigin}: ${result.error}`); + } + } +} + // ─── Voice Moderation Handlers ────────────────────────────────────────────── function handleVoiceSpaceMute(event: Record, userId: string): void {