From 07edb25d1243b1f38fdabcda342c88e07735c7b0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:18:25 +0200 Subject: [PATCH] feat: update DM call handlers and processors for federatedCallId lookup --- packages/server/src/routes/federation.ts | 95 +++--- packages/server/src/ws/events.ts | 378 ++++++++++------------- 2 files changed, 210 insertions(+), 263 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 3fe84edd..20f66aa1 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -13,7 +13,7 @@ import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { tombstoneUser, collectDeletionBroadcastTargets, collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; import { computeFederatedId, getDmParticipants, sendCallRelay } from '../utils/federationOutbox.js'; import { getDmMessageWithUser } from './dm.js'; -import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload } from '@backspace/shared'; +import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent } from '@backspace/shared'; /** Fields safe to expose to admin callers (everything except hmacSecret). */ interface SanitizedPeer { @@ -3568,56 +3568,51 @@ function processDmCallAcceptEvent( .from(schema.dmChannels) .where(eq(schema.dmChannels.federatedId, event.federatedId)) .get(); - - if (!channel) { - rejected.push({ messageId: event.messageId, reason: 'channel_not_found' }); - return; - } - - const dmChannelId = channel.id; + const dmChannelId = channel?.id; // Check if we're the HOST (have a VoiceRoom) - const room = connectionManager.getRoom(dmChannelId); + const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined; if (room && room.roomType === 'dm') { const meta = room.metadata as DmRoomMeta; if (meta.state === 'ringing') { - connectionManager.activateDmRoom(dmChannelId); + connectionManager.activateDmRoom(dmChannelId!); // Join caller to room connectionManager.leaveCurrentRoom(meta.callerId); - connectionManager.joinRoom(dmChannelId, meta.callerId); + connectionManager.joinRoom(dmChannelId!, meta.callerId); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToDmMembers(dmChannelId!, { type: 'voice_state_update', - channelId: dmChannelId, + channelId: dmChannelId!, userId: meta.callerId, action: 'join', }); } // Broadcast accepted locally - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToDmMembers(dmChannelId!, { type: 'dm_call_accepted', - dmChannelId, + dmChannelId: dmChannelId!, }); // Fan out to ALL other remote instances (exclude the one that sent the accept) const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`; - fanOutCallEvent(dmChannelId, event.federatedId, 'dm_call_accept', { + fanOutCallEvent(dmChannelId!, event.federatedId, 'dm_call_accept', { call: { acceptor: event.call.acceptor }, }, normalizedSource, db).catch(err => console.error('[federation] Fan-out dm_call_accept failed:', err) ); } else { // We're a REMOTE instance receiving fan-out — transition local state - const fedCall = connectionManager.getFederatedCall(dmChannelId); + const fedCall = connectionManager.getFederatedCall(event.federatedId); if (fedCall) { - connectionManager.activateFederatedCall(dmChannelId); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.activateFederatedCall(event.federatedId); + connectionManager.sendToFederatedCallUsers(event.federatedId, { type: 'dm_call_accepted', - dmChannelId, - }); + dmChannelId: fedCall.dmChannelId, + federatedCallId: event.federatedId, + } as ServerEvent); } } @@ -3645,39 +3640,34 @@ function processDmCallRejectEvent( .from(schema.dmChannels) .where(eq(schema.dmChannels.federatedId, event.federatedId)) .get(); + const dmChannelId = channel?.id; - if (!channel) { - rejected.push({ messageId: event.messageId, reason: 'channel_not_found' }); - return; - } - - const dmChannelId = channel.id; - - const room = connectionManager.getRoom(dmChannelId); + const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined; if (room && room.roomType === 'dm') { const meta = room.metadata as DmRoomMeta; connectionManager.clearVoiceWs(meta.callerId); - connectionManager.destroyRoom(dmChannelId); + connectionManager.destroyRoom(dmChannelId!); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToDmMembers(dmChannelId!, { type: 'dm_call_rejected', - dmChannelId, + dmChannelId: dmChannelId!, }); const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`; - fanOutCallEvent(dmChannelId, event.federatedId, 'dm_call_end', { + fanOutCallEvent(dmChannelId!, event.federatedId, 'dm_call_end', { call: { endedBy: event.call.rejector }, }, normalizedSource, db).catch(err => console.error('[federation] Fan-out dm_call_end (reject) failed:', err) ); } else { - const fedCall = connectionManager.getFederatedCall(dmChannelId); + const fedCall = connectionManager.getFederatedCall(event.federatedId); if (fedCall) { - connectionManager.clearFederatedCall(dmChannelId); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToFederatedCallUsers(event.federatedId, { type: 'dm_call_rejected', - dmChannelId, - }); + dmChannelId: fedCall.dmChannelId, + federatedCallId: event.federatedId, + } as ServerEvent); + connectionManager.clearFederatedCall(event.federatedId); } } @@ -3705,15 +3695,9 @@ function processDmCallEndEvent( .from(schema.dmChannels) .where(eq(schema.dmChannels.federatedId, event.federatedId)) .get(); + const dmChannelId = channel?.id; - if (!channel) { - rejected.push({ messageId: event.messageId, reason: 'channel_not_found' }); - return; - } - - const dmChannelId = channel.id; - - const room = connectionManager.getRoom(dmChannelId); + const room = dmChannelId ? connectionManager.getRoom(dmChannelId) : undefined; if (room && room.roomType === 'dm') { const meta = room.metadata as DmRoomMeta; connectionManager.clearVoiceWs(meta.callerId); @@ -3721,27 +3705,28 @@ function processDmCallEndEvent( connectionManager.clearVoiceUserStatus(pid); connectionManager.clearVoiceWs(pid); } - connectionManager.destroyRoom(dmChannelId); + connectionManager.destroyRoom(dmChannelId!); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToDmMembers(dmChannelId!, { type: 'dm_call_ended', - dmChannelId, + dmChannelId: dmChannelId!, }); const normalizedSource = sourceInstance.startsWith('http') ? sourceInstance : `https://${sourceInstance}`; - fanOutCallEvent(dmChannelId, event.federatedId, 'dm_call_end', { + fanOutCallEvent(dmChannelId!, event.federatedId, 'dm_call_end', { call: { endedBy: event.call.endedBy }, }, normalizedSource, db).catch(err => console.error('[federation] Fan-out dm_call_end failed:', err) ); } else { - const fedCall = connectionManager.getFederatedCall(dmChannelId); + const fedCall = connectionManager.getFederatedCall(event.federatedId); if (fedCall) { - connectionManager.clearFederatedCall(dmChannelId); - connectionManager.sendToDmMembers(dmChannelId, { + connectionManager.sendToFederatedCallUsers(event.federatedId, { type: 'dm_call_ended', - dmChannelId, - }); + dmChannelId: fedCall.dmChannelId, + federatedCallId: event.federatedId, + } as ServerEvent); + connectionManager.clearFederatedCall(event.federatedId); } } diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index a8bac2d1..8748e84d 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -6,7 +6,7 @@ import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js'; import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; -import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser, type Embed, type Activity, type ActivityType, type ActivityTimestamps, type ActivityAssets } from '@backspace/shared'; +import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser, type Embed, type Activity, type ActivityType, type ActivityTimestamps, type ActivityAssets, type ServerEvent } from '@backspace/shared'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; @@ -1442,247 +1442,209 @@ function handleDmCallStart(event: Record, userId: string, usern } function handleDmCallAccept(event: Record, userId: string, ws: WebSocket): void { - const dmChannelId = event.dmChannelId as string; - if (!dmChannelId || typeof dmChannelId !== 'string') { - connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + const dmChannelId = (event.dmChannelId as string) || null; + const federatedCallId = (event.federatedCallId as string) || null; + + if (!dmChannelId && !federatedCallId) { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId or federatedCallId is required' }); return; } - if (!isDmMember(dmChannelId, userId)) { - connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); - return; - } - - 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)); - + // Path 1: Local room (we're the host) — only possible with dmChannelId + if (dmChannelId) { + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); return; } - connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); + const room = connectionManager.getRoom(dmChannelId); + if (room && room.roomType === 'dm') { + const meta = room.metadata as DmRoomMeta; + + if (meta.state === 'ringing') { + connectionManager.activateDmRoom(dmChannelId); + const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); + if (callerLeft) broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); + connectionManager.joinRoom(dmChannelId, meta.callerId); + connectionManager.sendToDmMembers(dmChannelId, { + type: 'voice_state_update', + channelId: dmChannelId, + userId: meta.callerId, + action: 'join', + }); + } + + const acceptorLeft = connectionManager.leaveCurrentRoom(userId); + if (acceptorLeft) broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); + connectionManager.joinRoom(dmChannelId, userId); + connectionManager.setVoiceWs(userId, ws); + connectionManager.sendToDmMembers(dmChannelId, { type: 'dm_call_accepted', dmChannelId }); + connectionManager.sendToDmMembers(dmChannelId, { + type: 'voice_state_update', + channelId: dmChannelId, + userId, + action: 'join', + }); + sendFederatedCallAccept(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallAccept error:', err)); + return; + } + } + + // Path 2: Federated call (we're a remote instance) + const fedCall = federatedCallId + ? connectionManager.getFederatedCall(federatedCallId) + : dmChannelId + ? connectionManager.getFederatedCallByDmChannel(dmChannelId) + : undefined; + + if (fedCall) { + connectionManager.activateFederatedCall(fedCall.federatedId); + connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { + type: 'dm_call_accepted', + dmChannelId: fedCall.dmChannelId, + federatedCallId: fedCall.federatedId, + } as ServerEvent); + + 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; } - const meta = room.metadata as DmRoomMeta; - - if (meta.state === 'ringing') { - // First accept — transition ringing→active and join the caller - connectionManager.activateDmRoom(dmChannelId); - - // Leave caller's current room if in one - const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); - if (callerLeft) { - broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); - } - - // Join caller into the DM room - connectionManager.joinRoom(dmChannelId, meta.callerId); - - // Broadcast voice_state_update join for caller - connectionManager.sendToDmMembers(dmChannelId, { - type: 'voice_state_update', - channelId: dmChannelId, - userId: meta.callerId, - action: 'join', - }); - } - // If already active, this is a late-join (e.g. 3rd member joining group call). - // Skip the ringing→active transition and caller join — just join the acceptor below. - - // Leave acceptor's current room if in one - const acceptorLeft = connectionManager.leaveCurrentRoom(userId); - if (acceptorLeft) { - broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); - } - - // Join acceptor into the DM room - connectionManager.joinRoom(dmChannelId, userId); - - // Bind voice session to this socket for the acceptor - connectionManager.setVoiceWs(userId, ws); - - // Notify all DM members that the call was accepted - connectionManager.sendToDmMembers(dmChannelId, { - type: 'dm_call_accepted', - dmChannelId, - }); - - // Broadcast voice_state_update join for acceptor - connectionManager.sendToDmMembers(dmChannelId, { - type: 'voice_state_update', - channelId: dmChannelId, - userId, - action: 'join', - }); - - // Federation: notify remote instances that the call was accepted - sendFederatedCallAccept(dmChannelId, userId) - .catch(err => console.error('[federation] sendFederatedCallAccept error:', err)); + connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); } function handleDmCallReject(event: Record, userId: string): void { - const dmChannelId = event.dmChannelId as string; - if (!dmChannelId || typeof dmChannelId !== 'string') { - connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); - return; - } + const dmChannelId = (event.dmChannelId as string) || null; + const federatedCallId = (event.federatedCallId as string) || null; - if (!isDmMember(dmChannelId, userId)) { - connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); - return; - } + if (!dmChannelId && !federatedCallId) return; - const room = connectionManager.getRoom(dmChannelId); - 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)); + // Path 1: Local room (we're the host) + if (dmChannelId) { + if (!isDmMember(dmChannelId, userId)) return; + const room = connectionManager.getRoom(dmChannelId); + if (room) { + const meta = room.metadata as DmRoomMeta; + connectionManager.clearVoiceWs(meta.callerId); + connectionManager.destroyRoom(dmChannelId); + connectionManager.sendToDmMembers(dmChannelId, { type: 'dm_call_rejected', dmChannelId }); + sendFederatedCallEnd(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallEnd error:', err)); return; } - return; // No active call, silently ignore } - // Extract caller ID before destroying the room - const meta = room.metadata as DmRoomMeta; + // Path 2: Federated call + const fedCall = federatedCallId + ? connectionManager.getFederatedCall(federatedCallId) + : dmChannelId + ? connectionManager.getFederatedCallByDmChannel(dmChannelId) + : undefined; - // Clear caller's voice ws binding (set during handleDmCallStart) - connectionManager.clearVoiceWs(meta.callerId); + if (fedCall) { + connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { + type: 'dm_call_rejected', + dmChannelId: fedCall.dmChannelId, + federatedCallId: fedCall.federatedId, + } as ServerEvent); + connectionManager.clearFederatedCall(fedCall.federatedId); - // Destroy room - connectionManager.destroyRoom(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; - // Notify all DM members that the call was rejected - connectionManager.sendToDmMembers(dmChannelId, { - 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)); + 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)); + } } function handleDmCallEnd(event: Record, userId: string): void { - const dmChannelId = event.dmChannelId as string; - if (!dmChannelId || typeof dmChannelId !== 'string') { - connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); - return; - } + const dmChannelId = (event.dmChannelId as string) || null; + const federatedCallId = (event.federatedCallId as string) || null; - if (!isDmMember(dmChannelId, userId)) { - connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); - return; - } + if (!dmChannelId && !federatedCallId) return; - const room = connectionManager.getRoom(dmChannelId); - 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)); + // Path 1: Local room (we're the host) + if (dmChannelId) { + if (!isDmMember(dmChannelId, userId)) return; + const room = connectionManager.getRoom(dmChannelId); + if (room) { + const meta = room.metadata as DmRoomMeta; + connectionManager.clearVoiceWs(meta.callerId); + for (const participantId of room.participants) { + connectionManager.clearVoiceUserStatus(participantId); + connectionManager.clearVoiceWs(participantId); + } + connectionManager.destroyRoom(dmChannelId); + connectionManager.sendToDmMembers(dmChannelId, { type: 'dm_call_ended', dmChannelId }); + sendFederatedCallEnd(dmChannelId, userId) + .catch(err => console.error('[federation] sendFederatedCallEnd error:', 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; - connectionManager.clearVoiceWs(meta.callerId); + // Path 2: Federated call + const fedCall = federatedCallId + ? connectionManager.getFederatedCall(federatedCallId) + : dmChannelId + ? connectionManager.getFederatedCallByDmChannel(dmChannelId) + : undefined; - // Clear voice user states and voice ws for all participants - for (const participantId of room.participants) { - connectionManager.clearVoiceUserStatus(participantId); - connectionManager.clearVoiceWs(participantId); + if (fedCall) { + connectionManager.sendToFederatedCallUsers(fedCall.federatedId, { + type: 'dm_call_ended', + dmChannelId: fedCall.dmChannelId, + federatedCallId: fedCall.federatedId, + } as ServerEvent); + connectionManager.clearFederatedCall(fedCall.federatedId); + + 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)); } - - // Destroy room (removes all participants from userToRoom) - connectionManager.destroyRoom(dmChannelId); - - // Notify all DM members that the call ended - connectionManager.sendToDmMembers(dmChannelId, { - type: 'dm_call_ended', - dmChannelId, - }); - - // Federation: notify remote instances the call ended - sendFederatedCallEnd(dmChannelId, userId) - .catch(err => console.error('[federation] sendFederatedCallEnd error:', err)); } /**