From 9a74aba45983cdd8471d64f37253f7a53ccbe660 Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 11 Jul 2025 16:58:13 -0300 Subject: [PATCH 01/72] Partial implementation; media signaling and media calls collection --- packages/core-typings/src/index.ts | 1 + .../core-typings/src/mediaCalls/IMediaCall.ts | 68 +++++ packages/core-typings/src/mediaCalls/index.ts | 1 + packages/media-signaling/.eslintrc.json | 4 + packages/media-signaling/package.json | 29 ++ .../src/definition/IWebRTCProcessor.ts | 18 ++ .../src/definition/MediaSignal.ts | 7 + .../src/definition/MediaSignalDeliver.ts | 36 +++ .../src/definition/MediaSignalHeader.ts | 15 + .../src/definition/MediaSignalNotify.ts | 91 ++++++ .../src/definition/MediaSignalRequest.ts | 30 ++ packages/media-signaling/src/index.ts | 1 + packages/media-signaling/src/lib/Session.ts | 286 ++++++++++++++++++ .../src/lib/utils/createRandomToken.ts | 8 + .../src/lib/webrtc/WebRTCMediaCall.ts | 77 +++++ packages/media-signaling/tsconfig.json | 8 + packages/model-typings/src/index.ts | 1 + .../src/models/IMediaCallsModel.ts | 7 + packages/models/src/index.ts | 2 + packages/models/src/modelClasses.ts | 1 + packages/models/src/models/MediaCalls.ts | 34 +++ 21 files changed, 725 insertions(+) create mode 100644 packages/core-typings/src/mediaCalls/IMediaCall.ts create mode 100644 packages/core-typings/src/mediaCalls/index.ts create mode 100644 packages/media-signaling/.eslintrc.json create mode 100644 packages/media-signaling/package.json create mode 100644 packages/media-signaling/src/definition/IWebRTCProcessor.ts create mode 100644 packages/media-signaling/src/definition/MediaSignal.ts create mode 100644 packages/media-signaling/src/definition/MediaSignalDeliver.ts create mode 100644 packages/media-signaling/src/definition/MediaSignalHeader.ts create mode 100644 packages/media-signaling/src/definition/MediaSignalNotify.ts create mode 100644 packages/media-signaling/src/definition/MediaSignalRequest.ts create mode 100644 packages/media-signaling/src/index.ts create mode 100644 packages/media-signaling/src/lib/Session.ts create mode 100644 packages/media-signaling/src/lib/utils/createRandomToken.ts create mode 100644 packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts create mode 100644 packages/media-signaling/tsconfig.json create mode 100644 packages/model-typings/src/models/IMediaCallsModel.ts create mode 100644 packages/models/src/models/MediaCalls.ts diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 69cd0bbce6c3f..4157b3702a281 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -146,5 +146,6 @@ export * from './RoomRouteData'; export * as Cloud from './cloud'; export * from './themes'; +export * from './mediaCalls'; export { schemas } from './Ajv'; diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts new file mode 100644 index 0000000000000..0ea0033b33413 --- /dev/null +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -0,0 +1,68 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { IUser } from '../IUser'; + +export interface IMediaCallUserSession { + // +} + +export interface IMediaCallUser { + uid?: IUser['_id']; + + // Might be a Rocket.Chat username, or an extension number + username?: string; + name?: string; + + sessions: IMediaCallUserSession[]; +} + +export type MediaCallParticipant = { + type: 'user'; + user: IMediaCallUser; +}; + +export interface IMediaCall extends IRocketChatRecord { + service: 'webrtc'; + kind: 'direct'; + + rid?: string; + state: 'NONE' | 'DIALING' | 'ACCEPTED' | 'ACTIVE' | 'HANGUP'; + + createdBy: MediaCallParticipant; + createdAt: Date; + + endedBy?: MediaCallParticipant; + endedAt?: Date; + + channels: IMediaCallChannel[]; + + // providerName: string; + // providerData?: Record; +} + +export interface IMediaCallChannel { + user?: IMediaCallUser; + + // Originator means this channel was the one who started the call + originator?: boolean; + + state: 'NONE' | 'JOINING' | 'ACTIVE' | 'LEFT'; + + // The moment when the user accepted the call or clicked on the join button + joinedAt?: Date; + // The moment when the user successfully joined the call or got bridged + activeAt?: Date; + // The moment when the user left the call or hanged up + leftAt?: Date; +} + +export interface IWebRTCMediaCall extends IMediaCall { + service: 'webrtc'; + + offer?: { + sdp: string; + }; + + answer?: { + sdp: string; + }; +} diff --git a/packages/core-typings/src/mediaCalls/index.ts b/packages/core-typings/src/mediaCalls/index.ts new file mode 100644 index 0000000000000..158d2409d5ce5 --- /dev/null +++ b/packages/core-typings/src/mediaCalls/index.ts @@ -0,0 +1 @@ +export * from './IMediaCall'; diff --git a/packages/media-signaling/.eslintrc.json b/packages/media-signaling/.eslintrc.json new file mode 100644 index 0000000000000..a83aeda48e66d --- /dev/null +++ b/packages/media-signaling/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json new file mode 100644 index 0000000000000..1e0e766324752 --- /dev/null +++ b/packages/media-signaling/package.json @@ -0,0 +1,29 @@ +{ + "name": "@rocket.chat/media-signaling", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~29.5.12", + "eslint": "~8.45.0", + "jest": "~30.0.0", + "typescript": "~5.8.3" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "build-preview": "mkdir -p ../../.preview && cp -r ./dist ../../.preview/media-signaling" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "dependencies": { + "@rocket.chat/emitter": "^0.31.25" + } +} diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts new file mode 100644 index 0000000000000..5abffa5730336 --- /dev/null +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -0,0 +1,18 @@ +import type { DeliverParams } from './MediaSignalDeliver'; +import type { RequestParams } from './MediaSignalRequest'; + +export interface IWebRTCProcessor { + // Called when the Session manager anticipates a webrtc call may be initiated + initializePeerConnection(configuration?: RTCConfiguration): Promise; + // Function used to create a webrtc offer using the pre-initialized peer connection + createOffer(params: RequestParams<'offer'>): Promise>; + // Function used to create a webrtc answer using the pre-initialized peer connection + createAnswer(params: RequestParams<'answer'>): Promise>; + // Function used to retrieve the local sdp from the peer connection + collectLocalDescription(params: RequestParams<'sdp'>): Promise>; + + // Function used to add a callback to be executed when an ice candidate is gathered + onIceCandidate(cb: unknown): void; + // Function used to add a callback to be executed when ICE needs to be restarted + onNegotiationNeeded(cb: unknown): void; +} diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/MediaSignal.ts new file mode 100644 index 0000000000000..8bb2fa9e8125b --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignal.ts @@ -0,0 +1,7 @@ +import type { MediaSignalDeliver } from './MediaSignalDeliver'; +import type { MediaSignalNotify } from './MediaSignalNotify'; +import type { MediaSignalRequest } from './MediaSignalRequest'; + +export type MediaSignal = MediaSignalRequest | MediaSignalDeliver | MediaSignalNotify; + +export type ClientMediaSignal = Omit; diff --git a/packages/media-signaling/src/definition/MediaSignalDeliver.ts b/packages/media-signaling/src/definition/MediaSignalDeliver.ts new file mode 100644 index 0000000000000..7590afd48c20a --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignalDeliver.ts @@ -0,0 +1,36 @@ +import type { MediaSignalHeader } from './MediaSignalHeader'; + +export type DeliverSdpBody = { + deliver: 'sdp'; + sdp: RTCSessionDescriptionInit; + endOfCandidates: boolean; +}; + +export type DeliverIceCandidatesBody = { + deliver: 'ice-candidates'; + candidates: RTCIceCandidateInit[]; + endOfCandidates: boolean; +}; + +export type DeliverDTMFBody = { + deliver: 'dtmf'; + tone: string; + duration?: number; +}; + +export type DeliverBodyMap = { + 'sdp': DeliverSdpBody; + 'ice-candidates': DeliverIceCandidatesBody; + 'dtmf': DeliverDTMFBody; +}; + +export type DeliverType = keyof DeliverBodyMap; + +export type DeliverBody = DeliverBodyMap[T]; + +export type MediaSignalDeliver = MediaSignalHeader & { + type: 'deliver'; + body: DeliverBody; +}; + +export type DeliverParams = Omit, 'deliver'>; diff --git a/packages/media-signaling/src/definition/MediaSignalHeader.ts b/packages/media-signaling/src/definition/MediaSignalHeader.ts new file mode 100644 index 0000000000000..eac0bd9ef4c8e --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignalHeader.ts @@ -0,0 +1,15 @@ +export type MediaSignalHeaderParams = { + callId: string; + sessionId: string; + version: number; + sequence: number; + + role: 'caller' | 'callee'; + + expectACK?: boolean; +}; + +export type MediaSignalHeader = MediaSignalHeaderParams & { + type: 'request' | 'deliver' | 'notify'; + body: Record; +}; diff --git a/packages/media-signaling/src/definition/MediaSignalNotify.ts b/packages/media-signaling/src/definition/MediaSignalNotify.ts new file mode 100644 index 0000000000000..f7ca2fa64e53f --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignalNotify.ts @@ -0,0 +1,91 @@ +import type { MediaSignalHeader } from './MediaSignalHeader'; + +export type NotifyAckBody = { + notify: 'ack'; +}; + +export type NotifyInvalidBody = { + notify: 'invalid'; +}; + +export type NotifyUnableBody = { + notify: 'unable'; +}; + +export type NotifyEmptyBody = { + notify: 'empty'; +}; + +export type NotifyErrorBody = { + notify: 'error'; + errorCode: string; + errorText?: string; +}; + +export type NotifyNewBody = { + notify: 'new'; + service: 'webrtc'; + kind: 'direct'; +}; + +export type NotifyStateBody = { + notify: 'state'; + callState?: string; + serviceState?: string; + mediaState?: string; +}; + +export type NotifyUnavailableBody = { + notify: 'unavailable'; +}; + +export type NotifyAcceptBody = { + notify: 'accept'; +}; + +export type NotifyRejectBody = { + notify: 'reject'; +}; + +export type NotifyHangupBody = { + notify: 'hangup'; + reasonCode: string; + reasonText?: string; +}; + +export type NotifyNegotiationNeededBody = { + notify: 'negotiation-needed'; + reason?: string; +}; + +export type NotifyMultiBody = { + notify: 'multi'; + notifications: NotifyBody>[]; +}; + +export type NotifyBodyMap = { + 'new': NotifyNewBody; + 'ack': NotifyAckBody; + 'invalid': NotifyInvalidBody; + 'unable': NotifyUnableBody; + 'empty': NotifyEmptyBody; + 'error': NotifyErrorBody; + 'accept': NotifyAcceptBody; + 'reject': NotifyRejectBody; + 'hangup': NotifyHangupBody; + 'negotiation-needed': NotifyNegotiationNeededBody; + 'unavailable': NotifyUnavailableBody; + 'state': NotifyStateBody; + 'multi': NotifyMultiBody; +}; + +export type NotifyType = keyof NotifyBodyMap; + +export type NotifyBody = NotifyBodyMap[T]; + +export type MediaSignalNotify = MediaSignalHeader & { + type: 'notify'; + body: NotifyBody; +}; + +export type NotifyParams = Omit, 'notify'>; diff --git a/packages/media-signaling/src/definition/MediaSignalRequest.ts b/packages/media-signaling/src/definition/MediaSignalRequest.ts new file mode 100644 index 0000000000000..a5811723f15a6 --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignalRequest.ts @@ -0,0 +1,30 @@ +import type { MediaSignalHeader } from './MediaSignalHeader'; + +export type RequestOfferBody = { + request: 'offer'; + iceRestart?: boolean; +}; + +export type RequestAnswerBody = { + request: 'answer'; + offer: RTCSessionDescriptionInit; +}; + +export type RequestSdpBody = { + request: 'sdp'; +}; + +export type RequestBodyMap = { + offer: RequestOfferBody; + answer: RequestAnswerBody; + sdp: RequestSdpBody; +}; + +export type RequestBody = RequestBodyMap[T]; + +export type MediaSignalRequest = MediaSignalHeader & { + type: 'request'; + body: RequestBody; +}; + +export type RequestParams = Omit, 'request'>; diff --git a/packages/media-signaling/src/index.ts b/packages/media-signaling/src/index.ts new file mode 100644 index 0000000000000..729d0b8c30dc5 --- /dev/null +++ b/packages/media-signaling/src/index.ts @@ -0,0 +1 @@ +export default () => "Media-signaling"; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts new file mode 100644 index 0000000000000..c5b224e6917dd --- /dev/null +++ b/packages/media-signaling/src/lib/Session.ts @@ -0,0 +1,286 @@ +import { Emitter } from '@rocket.chat/emitter'; + +import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; +import type { ClientMediaSignal, MediaSignal } from '../definition/MediaSignal'; +import type { DeliverBody, DeliverParams, DeliverType, MediaSignalDeliver } from '../definition/MediaSignalDeliver'; +import type { MediaSignalNotify, NotifyBody, NotifyParams, NotifyType } from '../definition/MediaSignalNotify'; +import type { MediaSignalRequest } from '../definition/MediaSignalRequest'; +import { createRandomToken } from './utils/createRandomToken'; +import type { MediaSignalHeaderParams } from '../definition/MediaSignalHeader'; + +type MediaSignalingEvents = { + 'state/changed': void; +}; + +export class MediaSignalingSession extends Emitter { + private _sessionId: string; + + private _userId: string; + + private version = 1.0; + + private tricklingSignals = new Set(); + + // #ToDo: handle processor + private processor!: IWebRTCProcessor; + + public get agentId(): string { + return `${this._userId}-${this._sessionId}`; + } + + public get sessionId(): string { + return this._sessionId; + } + + public get userId(): string { + return this._userId; + } + + constructor(userId: string) { + super(); + this._userId = userId; + this._sessionId = createRandomToken(8); + } + + private isSignalForUs(signal: MediaSignal): boolean { + if (signal.sessionId) { + return signal.sessionId === this._sessionId; + } + + return true; + } + + // private changeState() { + // this.emit('state/changed'); + // } + + private async processRequest(signal: MediaSignalRequest) { + if (!signal.sessionId) { + throw new Error('Request signals MUST target a specific session.'); + } + + switch (signal.body.request) { + case 'offer': + return this.processOfferRequest(signal as MediaSignalRequest<'offer'>); + case 'answer': + return this.processAnswerRequest(signal as MediaSignalRequest<'answer'>); + case 'sdp': + return this.processSdpRequest(signal as MediaSignalRequest<'sdp'>); + } + } + + private async processOfferRequest(signal: MediaSignalRequest<'offer'>) { + // #ToDo: processor + let offer: DeliverParams<'sdp'> | null = null; + try { + offer = await this.processor.createOffer(signal.body); + } catch (e) { + this.sendError(signal, 'failed-to-create-offer'); + throw e; + } + + if (!offer) { + this.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, offer); + } + + private async processAnswerRequest(signal: MediaSignalRequest<'answer'>) { + let answer: DeliverParams<'sdp'> | null = null; + try { + answer = await this.processor.createAnswer(signal.body); + } catch (e) { + this.sendError(signal, 'failed-to-create-answer'); + throw e; + } + + if (!answer) { + this.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, answer); + } + + private async processSdpRequest(signal: MediaSignalRequest<'sdp'>) { + let sdp: DeliverParams<'sdp'> | null = null; + try { + sdp = await this.processor.collectLocalDescription(signal.body); + } catch (e) { + this.sendError(signal, 'failed-to-collect-description'); + throw e; + } + + if (!sdp) { + this.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, sdp); + } + + private async deliverSdp(requestSignal: MediaSignal, sdp: DeliverParams<'sdp'>) { + // If we're trickling ICE, keep the signal reference for upcoming candidates + if (!sdp.endOfCandidates) { + this.tricklingSignals.add(requestSignal); + } + + return this.deliverToServer(requestSignal, 'sdp', sdp); + } + + private async processDeliver(_signal: MediaSignalDeliver) { + // + } + + private async processNotify(signal: MediaSignalNotify) { + switch (signal.body.notify) { + case 'new': + return this.processNewCall(signal, true); + } + } + + private isCallKnown(_callId: string): boolean { + return false; + } + + private isSignalExpected(signal: MediaSignal): boolean { + if (this.isCallKnown(signal.callId)) { + return true; + } + + if (signal.type === 'notify') { + return signal.body.notify === 'new'; + } + + if (signal.type === 'request') { + return ['offer', 'answer'].includes(signal.body.request); + } + + return false; + } + + private async initializePeerConnection() { + // call processor.initializePeerConnection + } + + private async processNewCall(signal: MediaSignal, isNewNotify = false) { + // If we already know about this call, we don't need to process anything + if (this.isCallKnown(signal.callId)) { + return; + } + + // #ToDo: Load call data from the server to get the current state, in case the call was already going for a while + + // If we are busy with another call, we can't take this one - though the server is supposed to know our state + if (this.isBusy()) { + if (signal.expectACK || isNewNotify) { + return this.notifyServer(signal, 'unavailable'); + } + return; + } + + try { + await this.initializePeerConnection(); + } catch (e) { + this.sendError(signal, 'failed-to-initialize-peer'); + throw e; + } + + // If we're free, ACK the new call + if (signal.expectACK || isNewNotify) { + this.sendACK(signal); + } + + // If isNewNotify and role = 'callee', fire event for incoming call + } + + private deliverToServer(requestSignal: MediaSignal, type: T, params: DeliverParams) { + return this.sendSignalToServer( + { + type: 'deliver', + body: { + deliver: type, + ...params, + } as DeliverBody, + }, + requestSignal, + ); + } + + private notifyServer(requestSignal: MediaSignal, type: T, params?: NotifyParams) { + return this.sendSignalToServer( + { + type: 'notify', + body: { + notify: type, + ...params, + } as NotifyBody, + }, + requestSignal, + ); + } + + private sendError(requestSignal: MediaSignal, errorCode: string, errorText?: string) { + this.notifyServer(requestSignal, 'error', { + errorCode, + ...(errorText && { errorText }), + }); + } + + private sendACK(requestSignal: MediaSignal) { + return this.notifyServer(requestSignal, 'ack'); + } + + private sendSignalToServer(signal: ClientMediaSignal, requestSignal: MediaSignal) { + const header: MediaSignalHeaderParams = { + callId: requestSignal.callId, + sequence: requestSignal.sequence, + role: requestSignal.role, + version: this.version, + sessionId: this._sessionId, + }; + + const newSignal = { + ...signal, + ...header, + } as T; + console.log(newSignal); + } + + public isBusy(): boolean { + return false; + } + + public async processSignal(signal: MediaSignal) { + if (!this.isSignalForUs(signal)) { + return; + } + + if (!this.isSignalExpected(signal)) { + console.error('Unexpected Signal', signal); + throw new Error('Unexpected Signal received.'); + } + + if (!this.isCallKnown(signal.callId)) { + const isNewNotify = signal.type === 'notify' && signal.body.notify === 'new'; + + await this.processNewCall(signal, isNewNotify); + + if (isNewNotify) { + return; + } + } + + console.log(signal); + switch (signal.type) { + case 'request': + await this.processRequest(signal); + break; + case 'deliver': + await this.processDeliver(signal); + break; + case 'notify': + await this.processNotify(signal); + break; + } + } +} diff --git a/packages/media-signaling/src/lib/utils/createRandomToken.ts b/packages/media-signaling/src/lib/utils/createRandomToken.ts new file mode 100644 index 0000000000000..1961a06a936d3 --- /dev/null +++ b/packages/media-signaling/src/lib/utils/createRandomToken.ts @@ -0,0 +1,8 @@ +export function createRandomToken(size: number, base = 32): string { + let token = ''; + for (let i = 0; i < size; i++) { + const r: number = Math.floor(Math.random() * base); + token += r.toString(base); + } + return token; +} diff --git a/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts b/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts new file mode 100644 index 0000000000000..57e47a80b8de4 --- /dev/null +++ b/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts @@ -0,0 +1,77 @@ +import type { IWebRTCProcessor } from '../../definition/IWebRTCProcessor'; +import type { DeliverParams } from '../../definition/MediaSignalDeliver'; +import type { RequestParams } from '../../definition/MediaSignalRequest'; + +export class WebRTCMediaCall implements IWebRTCProcessor { + // #ToDo peer instance state + private peer: RTCPeerConnection; + + private iceGatheringFinished = false; + + private restartIce() { + this.iceGatheringFinished = false; + + this.peer.restartIce(); + } + + constructor() { + // + } + + public async initializePeerConnection(configuration?: RTCConfiguration): Promise { + this.peer = new RTCPeerConnection(configuration); + } + + // private onTrack(event) { + // // + // } + + public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { + if (iceRestart) { + this.restartIce(); + } + + const offer = await this.peer.createOffer(); + await this.peer.setLocalDescription(offer); + + return { + sdp: offer, + endOfCandidates: this.iceGatheringFinished, + }; + } + + public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { + if (this.peer.remoteDescription?.sdp !== offer.sdp) { + this.peer.setRemoteDescription(offer); + } + + const answer = await this.peer.createAnswer(); + await this.peer.setLocalDescription(answer); + + return { + sdp: answer, + endOfCandidates: this.iceGatheringFinished, + }; + } + + public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { + const sdp = this.peer.localDescription; + + if (!sdp) { + throw new Error('no-local-sdp'); + } + + return { + sdp, + endOfCandidates: this.iceGatheringFinished, + }; + } + + public onIceCandidate(cb: unknown) { + // + } + + public onNegotiationNeeded(cb: unknown) { + // + } +} diff --git a/packages/media-signaling/tsconfig.json b/packages/media-signaling/tsconfig.json new file mode 100644 index 0000000000000..47288e86819dd --- /dev/null +++ b/packages/media-signaling/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@rocket.chat/tsconfig/client.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"], +} diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 43f8b7de30cb4..137d525dfc2f3 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -83,5 +83,6 @@ export * from './models/IAuditLogModel'; export * from './models/ICronHistoryModel'; export * from './models/IMigrationsModel'; export * from './models/IModerationReportsModel'; +export * from './models/IMediaCallsModel'; export * from './updater'; export * from './models/IWorkspaceCredentialsModel'; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts new file mode 100644 index 0000000000000..fd33cc8128fba --- /dev/null +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -0,0 +1,7 @@ +import type { IMediaCall, MediaCallParticipant } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export interface IMediaCallsModel extends IBaseModel { + setEndedById(callId: string, data?: { endedBy?: MediaCallParticipant; endedAt?: Date }): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 727d969588837..85cf07cec226a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -90,6 +90,7 @@ import type { IModerationReportsModel, IWorkspaceCredentialsModel, IFreeSwitchChannelEventDeltaModel, + IMediaCallsModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -180,6 +181,7 @@ export const LivechatUnit = proxify('ILivechatUnitModel'); export const LivechatUnitMonitors = proxify('ILivechatUnitMonitorsModel'); export const LoginServiceConfiguration = proxify('ILoginServiceConfigurationModel'); export const Messages = proxify('IMessagesModel'); +export const MediaCalls = proxify('IMediaCallsModel'); export const NotificationQueue = proxify('INotificationQueueModel'); export const Nps = proxify('INpsModel'); export const NpsVote = proxify('INpsVoteModel'); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 429be82fd64a7..3412c5f144989 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -75,5 +75,6 @@ export * from './models/MessageReads'; export * from './models/CronHistoryModel'; export * from './models/Migrations'; export * from './models/ModerationReports'; +export * from './models/MediaCalls'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts new file mode 100644 index 0000000000000..2a73fc4951d4c --- /dev/null +++ b/packages/models/src/models/MediaCalls.ts @@ -0,0 +1,34 @@ +import type { IMediaCall, MediaCallParticipant, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IMediaCallsModel } from '@rocket.chat/model-typings'; +import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class MediaCallsRaw extends BaseRaw implements IMediaCallsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'media_call', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [{ key: { rid: 1, createdAt: 1 }, unique: false }]; + } + + public updateOneById( + _id: string, + update: UpdateFilter | Partial, + options?: UpdateOptions, + ): Promise { + return this.updateOne({ _id }, update, options); + } + + public async setEndedById(callId: string, data?: { endedBy?: MediaCallParticipant; endedAt?: Date }): Promise { + const { endedBy, endedAt } = { endedAt: new Date(), ...data }; + + await this.updateOneById(callId, { + $set: { + ...(endedBy && { endedBy }), + endedAt, + }, + }); + } +} From f1436bf903700b1d0290c42b0571ac1ad47d11da Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 16 Jul 2025 19:25:14 -0300 Subject: [PATCH 02/72] started work on media-signaling-server --- .../core-typings/src/mediaCalls/IMediaCall.ts | 95 ++++--- .../media-signaling-server/.eslintrc.json | 4 + packages/media-signaling-server/package.json | 31 +++ .../src/MediaSignalServer.ts | 240 ++++++++++++++++++ packages/media-signaling-server/src/index.ts | 1 + packages/media-signaling-server/tsconfig.json | 8 + packages/media-signaling/package.json | 6 +- .../src/definition/IWebRTCProcessor.ts | 2 + .../media-signaling/src/definition/index.ts | 6 + packages/media-signaling/src/index.ts | 3 +- packages/media-signaling/src/lib/Session.ts | 44 +++- packages/media-signaling/src/lib/index.ts | 1 + .../src/lib/webrtc/WebRTCMediaCall.ts | 42 ++- .../src/models/IMediaCallsModel.ts | 3 +- packages/models/src/models/MediaCalls.ts | 18 +- yarn.lock | 30 +++ 16 files changed, 482 insertions(+), 52 deletions(-) create mode 100644 packages/media-signaling-server/.eslintrc.json create mode 100644 packages/media-signaling-server/package.json create mode 100644 packages/media-signaling-server/src/MediaSignalServer.ts create mode 100644 packages/media-signaling-server/src/index.ts create mode 100644 packages/media-signaling-server/tsconfig.json create mode 100644 packages/media-signaling/src/definition/index.ts create mode 100644 packages/media-signaling/src/lib/index.ts diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 0ea0033b33413..f06ea3c57ca44 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -1,51 +1,61 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; import type { IUser } from '../IUser'; -export interface IMediaCallUserSession { - // -} - -export interface IMediaCallUser { - uid?: IUser['_id']; - - // Might be a Rocket.Chat username, or an extension number - username?: string; - name?: string; - - sessions: IMediaCallUserSession[]; -} - -export type MediaCallParticipant = { - type: 'user'; - user: IMediaCallUser; -}; +export type MediaCallActor = + | { + type: 'user'; + uid: IUser['_id']; + sessionId?: string; + } + | { + type: 'sip'; + username: string; + } + | { type: 'server' }; export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; kind: 'direct'; rid?: string; - state: 'NONE' | 'DIALING' | 'ACCEPTED' | 'ACTIVE' | 'HANGUP'; + state: 'none' | 'dialing' | 'accepted' | 'active' | 'hangup'; - createdBy: MediaCallParticipant; + createdBy: MediaCallActor; createdAt: Date; - endedBy?: MediaCallParticipant; + caller: MediaCallActor; + callee: MediaCallActor; + + endedBy?: MediaCallActor; endedAt?: Date; - channels: IMediaCallChannel[]; + userChannels: MediaCallUserChannel[]; // providerName: string; // providerData?: Record; } -export interface IMediaCallChannel { - user?: IMediaCallUser; +export type MediaCallChannelUserRC = { + type: 'user'; + uid: IUser['_id']; + username: string; + displayName?: string; + + sessionId: string; +}; - // Originator means this channel was the one who started the call - originator?: boolean; +export type MediaCallChannelUserSIP = { + type: 'sip'; + // The SIP username usually matches the extension number + username: string; +}; - state: 'NONE' | 'JOINING' | 'ACTIVE' | 'LEFT'; +export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; + +export type MediaCallChannel = { + role: 'caller' | 'callee' | 'none'; + + state: 'none' | 'ringing' | 'joining' | 'active' | 'left'; // The moment when the user accepted the call or clicked on the join button joinedAt?: Date; @@ -53,16 +63,27 @@ export interface IMediaCallChannel { activeAt?: Date; // The moment when the user left the call or hanged up leftAt?: Date; -} -export interface IWebRTCMediaCall extends IMediaCall { - service: 'webrtc'; - - offer?: { - sdp: string; + webrtc?: { + local: { + description: RTCSessionDescriptionInit | null; + iceCandidates: RTCIceCandidateInit[]; + iceGatheringComplete: boolean; + }; + remote: { + description: RTCSessionDescriptionInit | null; + iceCandidates: RTCIceCandidateInit[]; + iceGatheringComplete: boolean; + }; }; +}; - answer?: { - sdp: string; - }; -} +export type MediaCallUserChannel = MediaCallChannel & { + participant: MediaCallChannelUserRC; +}; + +export type MediaCallSipChannel = MediaCallChannel & { + participant: MediaCallChannelUserSIP; +}; + +export type MediaCallExternalChannel = MediaCallSipChannel; diff --git a/packages/media-signaling-server/.eslintrc.json b/packages/media-signaling-server/.eslintrc.json new file mode 100644 index 0000000000000..a83aeda48e66d --- /dev/null +++ b/packages/media-signaling-server/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/packages/media-signaling-server/package.json b/packages/media-signaling-server/package.json new file mode 100644 index 0000000000000..fbe9c17a70a74 --- /dev/null +++ b/packages/media-signaling-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@rocket.chat/media-signaling-server", + "version": "0.0.1", + "private": true, + "devDependencies": { + "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "eslint": "~8.45.0", + "jest": "~30.0.5", + "typescript": "~5.8.3" + }, + "scripts": { + "lint": "eslint --ext .js,.jsx,.ts,.tsx .", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", + "test": "jest", + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "build-preview": "mkdir -p ../../.preview && cp -r ./dist ../../.preview/media-signaling-server" + }, + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "dependencies": { + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/media-signaling": "workspace:^", + "@rocket.chat/models": "workspace:^" + } +} diff --git a/packages/media-signaling-server/src/MediaSignalServer.ts b/packages/media-signaling-server/src/MediaSignalServer.ts new file mode 100644 index 0000000000000..19a96a2cb594b --- /dev/null +++ b/packages/media-signaling-server/src/MediaSignalServer.ts @@ -0,0 +1,240 @@ +import type { + IMediaCall, + MediaCallUserChannel, + MediaCallChannel, + IUser, + MediaCallActor, + MediaCallChannelUserRC, + MediaCallParticipant, +} from '@rocket.chat/core-typings'; +import type { MediaSignal, MediaSignalDeliver, MediaSignalNotify, MediaSignalRequest } from '@rocket.chat/media-signaling'; +import { Users, MediaCalls } from '@rocket.chat/models'; + +export class MediaSignalServer { + private async processRequest(_signal: MediaSignalRequest): Promise { + // + } + + private async processDeliver(_signal: MediaSignalDeliver): Promise { + // + } + + private async processNotify(signal: MediaSignalNotify): Promise { + switch (signal.body.notify) { + case 'ack': + return this.processACK(signal as MediaSignalNotify<'ack'>); + } + } + + private async processACK(_signal: MediaSignalNotify<'ack'>): Promise { + // + } + + private async preProcessSignal(signal: MediaSignal, uid: IUser['_id']): Promise { + // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + throw new Error('invalid-call'); + } + + const actor: MediaCallActor = { type: 'user', uid, sessionId: signal.sessionId }; + + const existingChannel = call.userChannels.find((channel) => this.compareParticipantAndActor(channel.participant, actor)); + if (!existingChannel) { + // There's no channel for this actor yet, so let's add one + + const user = await Users.findOneById(uid, { projection: { username: 1, name: 1 } }); + if (!user?.username) { + throw new Error('invalid-user'); + } + + const role = this.getRoleForActor(call, actor); + + const newChannel: MediaCallUserChannel = { + participant: { + type: 'user', + uid, + username: user.username, + displayName: user.name, + sessionId: signal.sessionId, + }, + role, + state: 'none', + }; + + // #ToDo: Perhaps a separate collection would be better? + await MediaCalls.addUserChannel(call._id, newChannel); + } + + return call; + } + + private getRoleForActor(call: IMediaCall, actor: MediaCallActor): MediaCallChannel['role'] { + if (this.compareActorsIgnoringSession(call.caller, actor)) { + return 'caller'; + } + + if (this.compareActorsIgnoringSession(call.callee, actor)) { + return 'callee'; + } + + return 'none'; + } + + private compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { + if (actor1.type === 'sip' && actor2.type === 'sip') { + return actor1.username === actor2.username; + } + + if (actor1.type === 'user' && actor2.type === 'user') { + return actor1.uid === actor2.uid; + } + + if (actor1.type === 'server' && actor2.type === 'server') { + return true; + } + + return false; + } + + private compareParticipantAndActor(participant: MediaCallParticipant, actor: MediaCallActor): boolean { + if (participant.type !== actor.type) { + return false; + } + + if (actor.type === 'sip') { + return actor.username === participant.username; + } + + if (actor.type === 'user') { + const user = participant as MediaCallChannelUserRC; + + if (actor.uid !== user.uid) { + return false; + } + + return !actor.sessionId || actor.sessionId === user.sessionId; + } + + return false; + } + + private async getCallChannelForActor( + call: IMediaCall, + actor: MediaCallActor, + { autoAdd }: { autoAdd: boolean } = { autoAdd: false }, + ): Promise { + if (actor.type === 'server') { + return undefined; + } + + const existingChannel = call.userChannels.find((channel) => this.compareParticipantAndActor(channel.participant, actor)); + if (existingChannel) { + return existingChannel; + } + + if (!autoAdd) { + return undefined; + } + } + + private async getSignalUserRole(_signal: MediaSignal, uid: IUser['_id'], call: IMediaCall): Promise { + if (call.caller.type === 'user' && call.caller.uid === uid) { + return 'caller'; + } + + if (call.callee.type === 'user' && call.callee.uid === uid) { + return 'callee'; + } + + // #ToDo: go through channels + return 'none'; + } + + public async processSignal(signal: MediaSignal, uid: IUser['_id']) { + console.log(signal); + await this.preProcessSignal(signal, uid); + + switch (signal.type) { + case 'request': + await this.processRequest(signal); + break; + case 'deliver': + await this.processDeliver(signal); + break; + case 'notify': + await this.processNotify(signal); + break; + } + } + + public async createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { + if (caller.type !== 'user' || callee.type !== 'user') { + throw new Error('not-implemented'); + } + + const callerUser = await Users.findOneById>(caller.uid, { + projection: { username: 1, name: 1 }, + }); + + if (!callerUser?.username) { + throw new Error('invalid-caller'); + } + + const calleeUser = await Users.findOneById>(callee.uid, { + projection: { username: 1, name: 1 }, + }); + if (!calleeUser?.username) { + throw new Error('invalid-callee'); + } + + // Rocket.Chat users will have a separate channel entry for each session involved in the call + // So if we don't have the sessionId of an user yet, do not initialize the channel + const callerChannel: MediaCallUserChannel | null = + (caller.sessionId && { + participant: { + type: 'user', + uid: caller.uid, + username: callerUser.username, + displayName: callerUser.name, + sessionId: caller.sessionId, + }, + role: 'caller', + state: 'none', + joinedAt: new Date(), + }) || + null; + const calleeChannel: MediaCallUserChannel | null = + (callee.sessionId && { + participant: { + type: 'user', + uid: callee.uid, + username: calleeUser.username, + displayName: calleeUser.name, + sessionId: callee.sessionId, + }, + role: 'callee', + state: 'none', + joinedAt: new Date(), + }) || + null; + + const call: Omit = { + service: 'webrtc', + kind: 'direct', + state: 'none', + + createdBy: caller, + createdAt: new Date(), + + caller, + callee, + + userChannels: [...(callerChannel ? [callerChannel] : []), ...(calleeChannel ? [calleeChannel] : [])], + }; + + const insertResult = await MediaCalls.insertOne(call); + + console.log(insertResult); + } +} diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts new file mode 100644 index 0000000000000..2824c53d6647d --- /dev/null +++ b/packages/media-signaling-server/src/index.ts @@ -0,0 +1 @@ +export default () => "Media-signaling-server"; diff --git a/packages/media-signaling-server/tsconfig.json b/packages/media-signaling-server/tsconfig.json new file mode 100644 index 0000000000000..07f38f2e097a3 --- /dev/null +++ b/packages/media-signaling-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"], +} diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 1e0e766324752..3d074f437b1b1 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -5,9 +5,9 @@ "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/tsconfig": "workspace:*", - "@types/jest": "~29.5.12", + "@types/jest": "~30.0.0", "eslint": "~8.45.0", - "jest": "~30.0.0", + "jest": "~30.0.5", "typescript": "~5.8.3" }, "scripts": { @@ -24,6 +24,6 @@ "/dist" ], "dependencies": { - "@rocket.chat/emitter": "^0.31.25" + "@rocket.chat/emitter": "~0.31.25" } } diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index 5abffa5730336..9af4f7adcac03 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -10,6 +10,8 @@ export interface IWebRTCProcessor { createAnswer(params: RequestParams<'answer'>): Promise>; // Function used to retrieve the local sdp from the peer connection collectLocalDescription(params: RequestParams<'sdp'>): Promise>; + setRemoteDescription(params: DeliverParams<'sdp'>): Promise; + addIceCandidates(params: DeliverParams<'ice-candidates'>): Promise; // Function used to add a callback to be executed when an ice candidate is gathered onIceCandidate(cb: unknown): void; diff --git a/packages/media-signaling/src/definition/index.ts b/packages/media-signaling/src/definition/index.ts new file mode 100644 index 0000000000000..15934e69af627 --- /dev/null +++ b/packages/media-signaling/src/definition/index.ts @@ -0,0 +1,6 @@ +export * from './IWebRTCProcessor'; +export * from './MediaSignal'; +export * from './MediaSignalDeliver'; +export * from './MediaSignalHeader'; +export * from './MediaSignalNotify'; +export * from './MediaSignalRequest'; diff --git a/packages/media-signaling/src/index.ts b/packages/media-signaling/src/index.ts index 729d0b8c30dc5..f0c07a3e4a25e 100644 --- a/packages/media-signaling/src/index.ts +++ b/packages/media-signaling/src/index.ts @@ -1 +1,2 @@ -export default () => "Media-signaling"; +export * from './definition'; +export * from './lib'; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index c5b224e6917dd..984cc07374f3e 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -127,14 +127,52 @@ export class MediaSignalingSession extends Emitter { return this.deliverToServer(requestSignal, 'sdp', sdp); } - private async processDeliver(_signal: MediaSignalDeliver) { - // + private async processDeliver(signal: MediaSignalDeliver) { + if (!signal.sessionId) { + throw new Error('Delivery signals MUST target a specific session.'); + } + + switch (signal.body.deliver) { + case 'sdp': + await this.processor.setRemoteDescription(signal.body); + break; + case 'ice-candidates': + await this.processor.addIceCandidates(signal.body); + break; + case 'dtmf': + // #ToDo + break; + } } private async processNotify(signal: MediaSignalNotify) { switch (signal.body.notify) { case 'new': return this.processNewCall(signal, true); + case 'error': + break; + case 'ack': + break; + case 'invalid': + break; + case 'unable': + break; + case 'empty': + break; + case 'state': + break; + case 'unavailable': + break; + case 'accept': + break; + case 'reject': + break; + case 'hangup': + break; + case 'negotiation-needed': + break; + case 'multi': + break; } } @@ -159,7 +197,7 @@ export class MediaSignalingSession extends Emitter { } private async initializePeerConnection() { - // call processor.initializePeerConnection + await this.processor.initializePeerConnection(); } private async processNewCall(signal: MediaSignal, isNewNotify = false) { diff --git a/packages/media-signaling/src/lib/index.ts b/packages/media-signaling/src/lib/index.ts new file mode 100644 index 0000000000000..d398127b4a2c1 --- /dev/null +++ b/packages/media-signaling/src/lib/index.ts @@ -0,0 +1 @@ +export * from './Session'; diff --git a/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts b/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts index 57e47a80b8de4..5078a35ea2514 100644 --- a/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts +++ b/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts @@ -4,14 +4,14 @@ import type { RequestParams } from '../../definition/MediaSignalRequest'; export class WebRTCMediaCall implements IWebRTCProcessor { // #ToDo peer instance state - private peer: RTCPeerConnection; + private peer: RTCPeerConnection | undefined; private iceGatheringFinished = false; private restartIce() { this.iceGatheringFinished = false; - this.peer.restartIce(); + this.peer?.restartIce(); } constructor() { @@ -27,6 +27,10 @@ export class WebRTCMediaCall implements IWebRTCProcessor { // } public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { + if (!this.peer) { + throw new Error('peer-not-initialized'); + } + if (iceRestart) { this.restartIce(); } @@ -41,6 +45,10 @@ export class WebRTCMediaCall implements IWebRTCProcessor { } public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { + if (!this.peer) { + throw new Error('peer-not-initialized'); + } + if (this.peer.remoteDescription?.sdp !== offer.sdp) { this.peer.setRemoteDescription(offer); } @@ -55,6 +63,10 @@ export class WebRTCMediaCall implements IWebRTCProcessor { } public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { + if (!this.peer) { + throw new Error('peer-not-initialized'); + } + const sdp = this.peer.localDescription; if (!sdp) { @@ -67,11 +79,33 @@ export class WebRTCMediaCall implements IWebRTCProcessor { }; } - public onIceCandidate(cb: unknown) { + public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { + if (!this.peer) { + throw new Error('peer-not-initialized'); + } + + this.peer.setRemoteDescription(sdp); + } + + public async addIceCandidates({ candidates }: DeliverParams<'ice-candidates'>): Promise { + if (!this.peer) { + throw new Error('peer-not-initialized'); + } + + const results = await Promise.allSettled(candidates.map((candidate) => this.peer?.addIceCandidate(candidate))); + + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason; + } + } + } + + public onIceCandidate(_cb: unknown) { // } - public onNegotiationNeeded(cb: unknown) { + public onNegotiationNeeded(_cb: unknown) { // } } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index fd33cc8128fba..d80704e82ee32 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,7 +1,8 @@ -import type { IMediaCall, MediaCallParticipant } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallParticipant, MediaCallUserChannel } from '@rocket.chat/core-typings'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { setEndedById(callId: string, data?: { endedBy?: MediaCallParticipant; endedAt?: Date }): Promise; + addUserChannel(callId: string, userChannel: MediaCallUserChannel): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 2a73fc4951d4c..3ed9c64e6810c 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallParticipant, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallParticipant, MediaCallUserChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; @@ -6,11 +6,15 @@ import { BaseRaw } from './BaseRaw'; export class MediaCallsRaw extends BaseRaw implements IMediaCallsModel { constructor(db: Db, trash?: Collection>) { - super(db, 'media_call', trash); + super(db, 'media_calls', trash); } protected modelIndexes(): IndexDescription[] { - return [{ key: { rid: 1, createdAt: 1 }, unique: false }]; + return [ + { key: { rid: 1, createdAt: 1 }, unique: false }, + // unique key to prevent the same sessionId from getting two channels due to race conditions + { key: { '_id': 1, 'userChannels.uid': 1, 'userChannels.sessionId': 1 }, unique: true }, + ]; } public updateOneById( @@ -31,4 +35,12 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, }); } + + public async addUserChannel(callId: string, userChannel: MediaCallUserChannel): Promise { + await this.updateOneById(callId, { + $addToSet: { + userChannels: userChannel, + }, + }); + } } diff --git a/yarn.lock b/yarn.lock index 0343e0a55fa01..2fd726edbb1c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7807,6 +7807,36 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/media-signaling-server@workspace:packages/media-signaling-server": + version: 0.0.0-use.local + resolution: "@rocket.chat/media-signaling-server@workspace:packages/media-signaling-server" + dependencies: + "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/media-signaling": "workspace:^" + "@rocket.chat/models": "workspace:^" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + eslint: "npm:~8.45.0" + jest: "npm:~30.0.5" + typescript: "npm:~5.8.3" + languageName: unknown + linkType: soft + +"@rocket.chat/media-signaling@workspace:^, @rocket.chat/media-signaling@workspace:packages/media-signaling": + version: 0.0.0-use.local + resolution: "@rocket.chat/media-signaling@workspace:packages/media-signaling" + dependencies: + "@rocket.chat/emitter": "npm:~0.31.25" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + eslint: "npm:~8.45.0" + jest: "npm:~30.0.5" + typescript: "npm:~5.8.3" + languageName: unknown + linkType: soft + "@rocket.chat/memo@npm:^0.31.25, @rocket.chat/memo@npm:~0.31.25": version: 0.31.25 resolution: "@rocket.chat/memo@npm:0.31.25" From 8b44642fe9ade6b080d27e7dbc67b923fa6d361f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 17 Jul 2025 18:48:06 -0300 Subject: [PATCH 03/72] split channels into a new model, send offer request to caller --- apps/meteor/server/models.ts | 4 + .../core-typings/src/mediaCalls/IMediaCall.ts | 66 +---- .../src/mediaCalls/IMediaCallChannel.ts | 58 ++++ packages/core-typings/src/mediaCalls/index.ts | 1 + .../src/MediaSignalServer.ts | 268 +++++++++--------- packages/model-typings/src/index.ts | 1 + .../src/models/IMediaCallChannelsModel.ts | 9 + .../src/models/IMediaCallsModel.ts | 8 +- packages/models/src/index.ts | 2 + packages/models/src/modelClasses.ts | 1 + .../models/src/models/MediaCallChannels.ts | 45 +++ packages/models/src/models/MediaCalls.ts | 28 +- 12 files changed, 279 insertions(+), 212 deletions(-) create mode 100644 packages/core-typings/src/mediaCalls/IMediaCallChannel.ts create mode 100644 packages/model-typings/src/models/IMediaCallChannelsModel.ts create mode 100644 packages/models/src/models/MediaCallChannels.ts diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index abe9f5d5bb273..81c453385dbf9 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -43,6 +43,8 @@ import { LoginServiceConfigurationRaw, MatrixBridgedRoomRaw, MatrixBridgedUserRaw, + MediaCallsRaw, + MediaCallChannelsRaw, MessageReadsRaw, MessagesRaw, MigrationsRaw, @@ -134,6 +136,8 @@ registerModel('ILivechatVisitorsModel', new LivechatVisitorsRaw(db)); registerModel('ILoginServiceConfigurationModel', new LoginServiceConfigurationRaw(db)); registerModel('IMatrixBridgedRoomModel', new MatrixBridgedRoomRaw(db)); registerModel('IMatrixBridgedUserModel', new MatrixBridgedUserRaw(db)); +registerModel('IMediaCallsModel', new MediaCallsRaw(db)); +registerModel('IMediaCallChannelsModel', new MediaCallChannelsRaw(db)); registerModel('IMessageReadsModel', new MessageReadsRaw(db)); registerModel('IMessagesModel', new MessagesRaw(db, trashCollection)); registerModel('IMigrationsModel', new MigrationsRaw(db)); diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index f06ea3c57ca44..2c483080f9523 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -4,21 +4,24 @@ import type { IUser } from '../IUser'; export type MediaCallActor = | { type: 'user'; - uid: IUser['_id']; + id: IUser['_id']; sessionId?: string; } | { type: 'sip'; - username: string; + id: string; } - | { type: 'server' }; + | { + type: 'server'; + id: 'server'; + }; export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; kind: 'direct'; rid?: string; - state: 'none' | 'dialing' | 'accepted' | 'active' | 'hangup'; + state: 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; createdBy: MediaCallActor; createdAt: Date; @@ -29,61 +32,6 @@ export interface IMediaCall extends IRocketChatRecord { endedBy?: MediaCallActor; endedAt?: Date; - userChannels: MediaCallUserChannel[]; - // providerName: string; // providerData?: Record; } - -export type MediaCallChannelUserRC = { - type: 'user'; - uid: IUser['_id']; - username: string; - displayName?: string; - - sessionId: string; -}; - -export type MediaCallChannelUserSIP = { - type: 'sip'; - // The SIP username usually matches the extension number - username: string; -}; - -export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; - -export type MediaCallChannel = { - role: 'caller' | 'callee' | 'none'; - - state: 'none' | 'ringing' | 'joining' | 'active' | 'left'; - - // The moment when the user accepted the call or clicked on the join button - joinedAt?: Date; - // The moment when the user successfully joined the call or got bridged - activeAt?: Date; - // The moment when the user left the call or hanged up - leftAt?: Date; - - webrtc?: { - local: { - description: RTCSessionDescriptionInit | null; - iceCandidates: RTCIceCandidateInit[]; - iceGatheringComplete: boolean; - }; - remote: { - description: RTCSessionDescriptionInit | null; - iceCandidates: RTCIceCandidateInit[]; - iceGatheringComplete: boolean; - }; - }; -}; - -export type MediaCallUserChannel = MediaCallChannel & { - participant: MediaCallChannelUserRC; -}; - -export type MediaCallSipChannel = MediaCallChannel & { - participant: MediaCallChannelUserSIP; -}; - -export type MediaCallExternalChannel = MediaCallSipChannel; diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts new file mode 100644 index 0000000000000..9e42cc7c4f7d5 --- /dev/null +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -0,0 +1,58 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { IUser } from '../IUser'; + +export type MediaCallParticipantIdentification = { + type: 'user' | 'sip'; + id: string; + sessionId?: string; +}; + +export type MediaCallChannelUserRC = MediaCallParticipantIdentification & { + type: 'user'; + id: IUser['_id']; + username: string; + displayName?: string; + + sessionId: string; +}; + +export type MediaCallChannelUserSIP = MediaCallParticipantIdentification & { + type: 'sip'; + id: string; + sessionId?: never; + + // The SIP username usually matches the extension number + username: string; +}; + +export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; + +export interface IMediaCallChannel extends IRocketChatRecord { + callId: string; + + participant: MediaCallParticipant; + + role: 'caller' | 'callee' | 'none'; + + state: 'none' | 'ringing' | 'joining' | 'active' | 'left'; + + // The moment when the user accepted the call or clicked on the join button + joinedAt?: Date; + // The moment when the user successfully joined the call or got bridged + activeAt?: Date; + // The moment when the user left the call or hanged up + leftAt?: Date; + + webrtc?: { + local: { + description: RTCSessionDescriptionInit | null; + iceCandidates: RTCIceCandidateInit[]; + iceGatheringComplete: boolean; + }; + remote: { + description: RTCSessionDescriptionInit | null; + iceCandidates: RTCIceCandidateInit[]; + iceGatheringComplete: boolean; + }; + }; +} diff --git a/packages/core-typings/src/mediaCalls/index.ts b/packages/core-typings/src/mediaCalls/index.ts index 158d2409d5ce5..5a855f34938b2 100644 --- a/packages/core-typings/src/mediaCalls/index.ts +++ b/packages/core-typings/src/mediaCalls/index.ts @@ -1 +1,2 @@ export * from './IMediaCall'; +export * from './IMediaCallChannel'; diff --git a/packages/media-signaling-server/src/MediaSignalServer.ts b/packages/media-signaling-server/src/MediaSignalServer.ts index 19a96a2cb594b..05966ea4da77f 100644 --- a/packages/media-signaling-server/src/MediaSignalServer.ts +++ b/packages/media-signaling-server/src/MediaSignalServer.ts @@ -1,14 +1,17 @@ import type { IMediaCall, - MediaCallUserChannel, - MediaCallChannel, + IMediaCallChannel, IUser, MediaCallActor, - MediaCallChannelUserRC, - MediaCallParticipant, + // MediaCallChannelUserRC, + // MediaCallParticipant, + AtLeast, } from '@rocket.chat/core-typings'; -import type { MediaSignal, MediaSignalDeliver, MediaSignalNotify, MediaSignalRequest } from '@rocket.chat/media-signaling'; -import { Users, MediaCalls } from '@rocket.chat/models'; +import type { MediaSignal, MediaSignalDeliver, MediaSignalNotify, MediaSignalRequest, RequestParams } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { Users, MediaCalls, MediaCallChannels } from '@rocket.chat/models'; + +type ValidUser = AtLeast, '_id' | 'username' | 'name'>; export class MediaSignalServer { private async processRequest(_signal: MediaSignalRequest): Promise { @@ -19,141 +22,122 @@ export class MediaSignalServer { // } - private async processNotify(signal: MediaSignalNotify): Promise { + private async processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: IMediaCallChannel): Promise { switch (signal.body.notify) { case 'ack': - return this.processACK(signal as MediaSignalNotify<'ack'>); + return this.processACK(signal as MediaSignalNotify<'ack'>, call, channel); } } - private async processACK(_signal: MediaSignalNotify<'ack'>): Promise { - // - } - - private async preProcessSignal(signal: MediaSignal, uid: IUser['_id']): Promise { - // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list - const call = await MediaCalls.findOneById(signal.callId); - if (!call) { - throw new Error('invalid-call'); - } - - const actor: MediaCallActor = { type: 'user', uid, sessionId: signal.sessionId }; + private async processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: IMediaCallChannel): Promise { + // If the call is still on an empty state and the signal reached a callee, change the state to 'ringing' - const existingChannel = call.userChannels.find((channel) => this.compareParticipantAndActor(channel.participant, actor)); - if (!existingChannel) { - // There's no channel for this actor yet, so let's add one + if (channel.role === 'callee' && call.state === 'none') { + const result = await MediaCalls.startRingingById(call._id); - const user = await Users.findOneById(uid, { projection: { username: 1, name: 1 } }); - if (!user?.username) { - throw new Error('invalid-user'); + // If the state was changed, request an offer from the caller + if (result.modifiedCount) { + await this.requestOffer(call); } - - const role = this.getRoleForActor(call, actor); - - const newChannel: MediaCallUserChannel = { - participant: { - type: 'user', - uid, - username: user.username, - displayName: user.name, - sessionId: signal.sessionId, - }, - role, - state: 'none', - }; - - // #ToDo: Perhaps a separate collection would be better? - await MediaCalls.addUserChannel(call._id, newChannel); } - - return call; } - private getRoleForActor(call: IMediaCall, actor: MediaCallActor): MediaCallChannel['role'] { - if (this.compareActorsIgnoringSession(call.caller, actor)) { - return 'caller'; - } - - if (this.compareActorsIgnoringSession(call.callee, actor)) { - return 'callee'; + private async requestOffer(call: IMediaCall, params?: RequestParams<'offer'>): Promise { + if (call.caller.type !== 'user' || !call.caller.sessionId) { + throw new Error('not-implemented'); } - return 'none'; + await this.sendSignal(call, call.caller.id, { + role: 'caller', + sessionId: call.caller.sessionId, + type: 'request', + body: { + request: 'offer', + ...params, + }, + }); } - private compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { - if (actor1.type === 'sip' && actor2.type === 'sip') { - return actor1.username === actor2.username; - } - - if (actor1.type === 'user' && actor2.type === 'user') { - return actor1.uid === actor2.uid; - } - - if (actor1.type === 'server' && actor2.type === 'server') { - return true; - } - - return false; + private async sendSignal( + call: IMediaCall, + uid: IUser['_id'], + signal: Omit, + ): Promise { + console.log('sendSignal', call, uid, { + ...signal, + version: 1, + sequence: 1, + callId: call._id, + }); } - private compareParticipantAndActor(participant: MediaCallParticipant, actor: MediaCallActor): boolean { - if (participant.type !== actor.type) { - return false; - } - - if (actor.type === 'sip') { - return actor.username === participant.username; - } - - if (actor.type === 'user') { - const user = participant as MediaCallChannelUserRC; + private async getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { + const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; - if (actor.uid !== user.uid) { - return false; - } + // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list + const existingChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); - return !actor.sessionId || actor.sessionId === user.sessionId; + if (existingChannel) { + return existingChannel; } - return false; - } + const role = this.getRoleForActor(call, actor); - private async getCallChannelForActor( - call: IMediaCall, - actor: MediaCallActor, - { autoAdd }: { autoAdd: boolean } = { autoAdd: false }, - ): Promise { - if (actor.type === 'server') { - return undefined; - } + const newChannel: InsertionModel = { + callId: call._id, + participant: { + ...actor, + username: user.username, + displayName: user.name, + }, + role, + state: 'none', + }; - const existingChannel = call.userChannels.find((channel) => this.compareParticipantAndActor(channel.participant, actor)); - if (existingChannel) { - return existingChannel; + try { + await MediaCallChannels.insertOne(newChannel); + } catch (e) { + // #ToDo: Check if it was really a race condition? } - if (!autoAdd) { - return undefined; + const insertedChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); + if (!insertedChannel) { + throw new Error('failed-to-insert-channel'); } + + return insertedChannel; } - private async getSignalUserRole(_signal: MediaSignal, uid: IUser['_id'], call: IMediaCall): Promise { - if (call.caller.type === 'user' && call.caller.uid === uid) { + private getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMediaCallChannel['role'] { + if (this.compareActorsIgnoringSession(call.caller, actor)) { return 'caller'; } - if (call.callee.type === 'user' && call.callee.uid === uid) { + if (this.compareActorsIgnoringSession(call.callee, actor)) { return 'callee'; } - // #ToDo: go through channels return 'none'; } + private compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { + return actor1.type === actor2.type && actor1.id === actor2.id; + } + public async processSignal(signal: MediaSignal, uid: IUser['_id']) { console.log(signal); - await this.preProcessSignal(signal, uid); + + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + throw new Error('invalid-call'); + } + + const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); + if (!user?.username || !user?.name) { + throw new Error('invalid-user'); + } + + const channel = await this.getChannelForSignal(signal, call, user); switch (signal.type) { case 'request': @@ -163,7 +147,7 @@ export class MediaSignalServer { await this.processDeliver(signal); break; case 'notify': - await this.processNotify(signal); + await this.processNotify(signal, call, channel); break; } } @@ -172,8 +156,11 @@ export class MediaSignalServer { if (caller.type !== 'user' || callee.type !== 'user') { throw new Error('not-implemented'); } + if (!caller.sessionId) { + throw new Error('not-implemented'); + } - const callerUser = await Users.findOneById>(caller.uid, { + const callerUser = await Users.findOneById>(caller.id, { projection: { username: 1, name: 1 }, }); @@ -181,44 +168,13 @@ export class MediaSignalServer { throw new Error('invalid-caller'); } - const calleeUser = await Users.findOneById>(callee.uid, { + const calleeUser = await Users.findOneById>(callee.id, { projection: { username: 1, name: 1 }, }); if (!calleeUser?.username) { throw new Error('invalid-callee'); } - // Rocket.Chat users will have a separate channel entry for each session involved in the call - // So if we don't have the sessionId of an user yet, do not initialize the channel - const callerChannel: MediaCallUserChannel | null = - (caller.sessionId && { - participant: { - type: 'user', - uid: caller.uid, - username: callerUser.username, - displayName: callerUser.name, - sessionId: caller.sessionId, - }, - role: 'caller', - state: 'none', - joinedAt: new Date(), - }) || - null; - const calleeChannel: MediaCallUserChannel | null = - (callee.sessionId && { - participant: { - type: 'user', - uid: callee.uid, - username: calleeUser.username, - displayName: calleeUser.name, - sessionId: callee.sessionId, - }, - role: 'callee', - state: 'none', - joinedAt: new Date(), - }) || - null; - const call: Omit = { service: 'webrtc', kind: 'direct', @@ -229,12 +185,48 @@ export class MediaSignalServer { caller, callee, - - userChannels: [...(callerChannel ? [callerChannel] : []), ...(calleeChannel ? [calleeChannel] : [])], }; const insertResult = await MediaCalls.insertOne(call); + if (insertResult.insertedId) { + await Promise.allSettled([ + this.createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), + this.createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), + ]); + } console.log(insertResult); } + + private async createInitialChannel( + callId: string, + actor: MediaCallActor, + channelData: Partial>, + userData?: Pick, + ): Promise { + // Rocket.Chat users will have a separate channel entry for each session involved in the call + // So if we don't have the sessionId of an user yet, do not initialize the channel + if (actor.type !== 'user' || !actor.sessionId) { + return; + } + + if (!userData?.username || !userData.name) { + return; + } + + await MediaCallChannels.insertOne({ + role: 'none', + ...channelData, + callId, + participant: { + type: 'user', + id: userData._id, + username: userData.username, + displayName: userData.name, + sessionId: actor.sessionId, + }, + state: 'none', + joinedAt: new Date(), + }); + } } diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 137d525dfc2f3..641243604e729 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -84,5 +84,6 @@ export * from './models/ICronHistoryModel'; export * from './models/IMigrationsModel'; export * from './models/IModerationReportsModel'; export * from './models/IMediaCallsModel'; +export * from './models/IMediaCallChannelsModel'; export * from './updater'; export * from './models/IWorkspaceCredentialsModel'; diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts new file mode 100644 index 0000000000000..16b12e6946785 --- /dev/null +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -0,0 +1,9 @@ +import type { IMediaCallChannel, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { UpdateResult } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface IMediaCallChannelsModel extends IBaseModel { + findOneByCallIdAndParticipant(callId: string, participant: MediaCallParticipantIdentification): Promise; + setState(_id: string, state: IMediaCallChannel['state']): Promise; +} diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index d80704e82ee32..faae63d83cbf4 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,8 +1,10 @@ -import type { IMediaCall, MediaCallParticipant, MediaCallUserChannel } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { - setEndedById(callId: string, data?: { endedBy?: MediaCallParticipant; endedAt?: Date }): Promise; - addUserChannel(callId: string, userChannel: MediaCallUserChannel): Promise; + setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise; + setStateById(callId: string, state: IMediaCall['state']): Promise; + startRingingById(callId: string): Promise; } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 85cf07cec226a..ade19e99f73d3 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -91,6 +91,7 @@ import type { IWorkspaceCredentialsModel, IFreeSwitchChannelEventDeltaModel, IMediaCallsModel, + IMediaCallChannelsModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -182,6 +183,7 @@ export const LivechatUnitMonitors = proxify('ILivech export const LoginServiceConfiguration = proxify('ILoginServiceConfigurationModel'); export const Messages = proxify('IMessagesModel'); export const MediaCalls = proxify('IMediaCallsModel'); +export const MediaCallChannels = proxify('IMediaCallChannelsModel'); export const NotificationQueue = proxify('INotificationQueueModel'); export const Nps = proxify('INpsModel'); export const NpsVote = proxify('INpsVoteModel'); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index 3412c5f144989..b8db18e0299c6 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -76,5 +76,6 @@ export * from './models/CronHistoryModel'; export * from './models/Migrations'; export * from './models/ModerationReports'; export * from './models/MediaCalls'; +export * from './models/MediaCallChannels'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts new file mode 100644 index 0000000000000..269863a49240d --- /dev/null +++ b/packages/models/src/models/MediaCallChannels.ts @@ -0,0 +1,45 @@ +import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { IMediaCallChannelsModel } from '@rocket.chat/model-typings'; +import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class MediaCallChannelsRaw extends BaseRaw implements IMediaCallChannelsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'media_call_channels', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [ + { + key: { 'callId': 1, 'participant.type': 1, 'participant.id': 1, 'participant.sessionId': 1 }, + unique: true, + sparse: true, + }, + ]; + } + + public async findOneByCallIdAndParticipant( + callId: string, + participant: MediaCallParticipantIdentification, + ): Promise { + return this.findOne({ + callId, + 'participant.type': participant.type, + 'participant.id': participant.id, + ...(participant.type === 'user' && { 'participant.sessionId': participant.sessionId }), + }); + } + + public updateOneById( + _id: string, + update: UpdateFilter | Partial, + options?: UpdateOptions, + ): Promise { + return this.updateOne({ _id }, update, options); + } + + public async setState(_id: string, state: IMediaCallChannel['state']): Promise { + return this.updateOneById(_id, { $set: { state } }); + } +} diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 3ed9c64e6810c..24f8ed87a40b9 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallParticipant, MediaCallUserChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { IMediaCall, RocketChatRecordDeleted, MediaCallActor } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; @@ -10,11 +10,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod } protected modelIndexes(): IndexDescription[] { - return [ - { key: { rid: 1, createdAt: 1 }, unique: false }, - // unique key to prevent the same sessionId from getting two channels due to race conditions - { key: { '_id': 1, 'userChannels.uid': 1, 'userChannels.sessionId': 1 }, unique: true }, - ]; + return [{ key: { rid: 1, createdAt: 1 }, unique: false }]; } public updateOneById( @@ -25,7 +21,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne({ _id }, update, options); } - public async setEndedById(callId: string, data?: { endedBy?: MediaCallParticipant; endedAt?: Date }): Promise { + public async setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise { const { endedBy, endedAt } = { endedAt: new Date(), ...data }; await this.updateOneById(callId, { @@ -36,11 +32,19 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }); } - public async addUserChannel(callId: string, userChannel: MediaCallUserChannel): Promise { - await this.updateOneById(callId, { - $addToSet: { - userChannels: userChannel, - }, + public async setStateById(callId: string, state: IMediaCall['state']): Promise { + return this.updateOneById(callId, { + $set: { state }, }); } + + public async startRingingById(callId: string): Promise { + return this.updateOne( + { + _id: callId, + state: 'none', + }, + { $set: { state: 'ringing' } }, + ); + } } From 5c911f36b7c398f85f21fbfcf053d9c86eb803a6 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 21 Jul 2025 17:55:08 -0300 Subject: [PATCH 04/72] call acceptance --- .../core-typings/src/mediaCalls/IMediaCall.ts | 2 + .../src/mediaCalls/IMediaCallChannel.ts | 24 +- .../src/MediaSignalServer.ts | 240 +++++++++++++++--- .../src/models/IMediaCallChannelsModel.ts | 4 +- .../src/models/IMediaCallsModel.ts | 3 + .../models/src/models/MediaCallChannels.ts | 15 +- packages/models/src/models/MediaCalls.ts | 30 +++ 7 files changed, 274 insertions(+), 44 deletions(-) diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 2c483080f9523..c00143dc21b71 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -32,6 +32,8 @@ export interface IMediaCall extends IRocketChatRecord { endedBy?: MediaCallActor; endedAt?: Date; + sequence: number; + // providerName: string; // providerData?: Record; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index 9e42cc7c4f7d5..95353350f6cb0 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -27,6 +27,13 @@ export type MediaCallChannelUserSIP = MediaCallParticipantIdentification & { export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; +export type MediaCallChannelWebRTCSession = { + description: RTCSessionDescriptionInit | null; + iceCandidates: RTCIceCandidateInit[]; + iceGatheringComplete: boolean; + assignSequence: number; +}; + export interface IMediaCallChannel extends IRocketChatRecord { callId: string; @@ -44,15 +51,12 @@ export interface IMediaCallChannel extends IRocketChatRecord { leftAt?: Date; webrtc?: { - local: { - description: RTCSessionDescriptionInit | null; - iceCandidates: RTCIceCandidateInit[]; - iceGatheringComplete: boolean; - }; - remote: { - description: RTCSessionDescriptionInit | null; - iceCandidates: RTCIceCandidateInit[]; - iceGatheringComplete: boolean; - }; + local?: MediaCallChannelWebRTCSession; + remote?: MediaCallChannelWebRTCSession; }; } + +export type ValidSignalChannel = IMediaCallChannel & { + // role: 'caller' | 'callee'; + participant: MediaCallChannelUserRC; +}; diff --git a/packages/media-signaling-server/src/MediaSignalServer.ts b/packages/media-signaling-server/src/MediaSignalServer.ts index 05966ea4da77f..dafebff888895 100644 --- a/packages/media-signaling-server/src/MediaSignalServer.ts +++ b/packages/media-signaling-server/src/MediaSignalServer.ts @@ -1,13 +1,12 @@ +import type { IMediaCall, IMediaCallChannel, IUser, MediaCallActor, ValidSignalChannel, AtLeast } from '@rocket.chat/core-typings'; import type { - IMediaCall, - IMediaCallChannel, - IUser, - MediaCallActor, - // MediaCallChannelUserRC, - // MediaCallParticipant, - AtLeast, -} from '@rocket.chat/core-typings'; -import type { MediaSignal, MediaSignalDeliver, MediaSignalNotify, MediaSignalRequest, RequestParams } from '@rocket.chat/media-signaling'; + DeliverParams, + MediaSignal, + MediaSignalDeliver, + MediaSignalNotify, + MediaSignalRequest, + RequestParams, +} from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { Users, MediaCalls, MediaCallChannels } from '@rocket.chat/models'; @@ -18,38 +17,133 @@ export class MediaSignalServer { // } - private async processDeliver(_signal: MediaSignalDeliver): Promise { - // + private async processDeliver(signal: MediaSignalDeliver, call: IMediaCall, channel: ValidSignalChannel): Promise { + switch (signal.body.deliver) { + case 'sdp': + return this.processSDP(signal as MediaSignalDeliver<'sdp'>, call, channel); + } } - private async processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: IMediaCallChannel): Promise { + private async processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: ValidSignalChannel): Promise { switch (signal.body.notify) { case 'ack': return this.processACK(signal as MediaSignalNotify<'ack'>, call, channel); + case 'accept': + return this.processAccept(signal as MediaSignalNotify<'accept'>, call, channel); } } - private async processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: IMediaCallChannel): Promise { - // If the call is still on an empty state and the signal reached a callee, change the state to 'ringing' + private async processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + // If the call is still on an empty state and a signal reached a callee, change the state to 'ringing' if (channel.role === 'callee' && call.state === 'none') { const result = await MediaCalls.startRingingById(call._id); // If the state was changed, request an offer from the caller if (result.modifiedCount) { - await this.requestOffer(call); + await this.requestOffer(channel); + } + } + } + + private async processAccept(_signal: MediaSignalNotify<'accept'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + if (channel.role === 'callee') { + if (!this.compareActorsIgnoringSession(call.callee, channel.participant)) { + // Someone else tried to accept the call... should we respond something? + return; + } + const sessionId = (channel.participant.type === 'user' && channel.participant.sessionId) || undefined; + + const result = await MediaCalls.acceptCallById(call._id, sessionId); + if (!result.modifiedCount) { + // # nothing was changed by this acceptance... should we respond something? + return; + } + + const newSequence = await this.getNewCallSequence(channel.callId); + await this.sendSignalToChannel(newSequence.sequence, channel, { + type: 'notify', + body: { + notify: 'state', + callState: 'accepted', + }, + }); + + const otherChannel = await this.getOppositeChannel(call, channel); + if (otherChannel && this.isValidSignalChannel(otherChannel)) { + await this.sendSignalToChannel(newSequence.sequence, otherChannel, { + type: 'notify', + body: { + notify: 'state', + callState: 'accepted', + }, + }); + } + + return; + } + + if (channel.role === 'caller' && call.caller.type === 'user' && !call.caller.sessionId) { + const result = await MediaCalls.setCallerSessionIdById(call._id, channel.participant.sessionId); + if (result.modifiedCount) { + // #Todo: Calls initiated without a caller sessionId } } } - private async requestOffer(call: IMediaCall, params?: RequestParams<'offer'>): Promise { - if (call.caller.type !== 'user' || !call.caller.sessionId) { + private async getActorChannel(callId: IMediaCall['_id'], actor: MediaCallActor): Promise { + if (actor.type !== 'user') { throw new Error('not-implemented'); } - await this.sendSignal(call, call.caller.id, { - role: 'caller', - sessionId: call.caller.sessionId, + // If there is no sessionId yet, we can't determine which channel is going to be used by this actor + if (!actor.sessionId) { + return null; + } + + return MediaCallChannels.findOneByCallIdAndParticipant(callId, actor); + } + + private async getOppositeChannel(call: IMediaCall, channel: IMediaCallChannel): Promise { + switch (channel.role) { + case 'callee': + return this.getActorChannel(call._id, call.callee); + case 'caller': + return this.getActorChannel(call._id, call.caller); + default: + return null; + } + } + + private async processSDP(signal: MediaSignalDeliver<'sdp'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + // Save the SDP for the local session of the channel + await MediaCallChannels.setLocalWebRTCSession(channel._id, { + description: signal.body.sdp, + iceCandidates: [], + iceGatheringComplete: Boolean(signal.body.endOfCandidates), + assignSequence: signal.sequence, + }); + + // Find the opposite channel and save the SDP there as well + const otherChannel = await this.getOppositeChannel(call, channel); + if (otherChannel) { + await this.setRemoteSDP( + otherChannel, + { + sdp: signal.body.sdp, + endOfCandidates: Boolean(signal.body.endOfCandidates), + }, + signal.sequence, + ); + } + } + + private async requestOffer(channel: IMediaCallChannel, params?: RequestParams<'offer'>): Promise { + this.validateChannelForSignals(channel); + + const call = await this.getNewCallSequence(channel.callId); + + await this.sendSignalToChannel(call.sequence, channel, { type: 'request', body: { request: 'offer', @@ -58,27 +152,108 @@ export class MediaSignalServer { }); } - private async sendSignal( - call: IMediaCall, - uid: IUser['_id'], - signal: Omit, + private isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is MediaSignal['role'] { + return ['caller', 'callee'].includes(channelRole); + } + + private isValidSignalChannel(channel: IMediaCallChannel): channel is ValidSignalChannel { + return channel.participant.type === 'user'; + } + + private async getNewCallSequence(callId: IMediaCall['_id']): Promise { + const call = await MediaCalls.getNewSequence(callId); + if (!call) { + throw new Error('failed-to-reserve-sequence'); + } + + return call; + } + + private validateChannelForSignals(channel: IMediaCallChannel): asserts channel is ValidSignalChannel { + if (channel.participant.type !== 'user') { + throw new Error('not-implemented'); + } + if (!channel.participant.sessionId) { + throw new Error('SDP may only be sent to specific user sessions.'); + } + } + + private async setRemoteSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>, sequence: number): Promise { + await MediaCallChannels.setRemoteWebRTCSession(channel._id, { + description: params.sdp, + iceCandidates: [], + iceGatheringComplete: Boolean(params.endOfCandidates), + assignSequence: sequence, + }); + + if (channel.participant.type !== 'user') { + // No need to send any signals if the remote participant is not a rocket.chat user + return; + } + + await this.deliverSDP(channel, { + sdp: params.sdp, + endOfCandidates: params.endOfCandidates, + }); + } + + private async deliverSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>): Promise { + this.validateChannelForSignals(channel); + + const call = await this.getNewCallSequence(channel.callId); + + // If the sdp is an offer, send an answer request, otherwise simply deliver it + if (params.sdp.type === 'offer') { + await this.sendSignalToChannel(call.sequence, channel, { + type: 'request', + body: { + request: 'answer', + offer: params.sdp, + }, + }); + } else { + await this.sendSignalToChannel(call.sequence, channel, { + type: 'deliver', + body: { + deliver: 'sdp', + ...params, + }, + }); + } + } + + private async sendSignalToChannel( + sequence: number, + channel: ValidSignalChannel, + signal: Omit, ): Promise { - console.log('sendSignal', call, uid, { + if (!this.isValidSignalRole(channel.role)) { + // Tried to send a signal to a channel that is neither the caller nor the callee... what to do? + return; + } + + await this.sendSignal({ ...signal, + role: channel.role, + sessionId: channel.participant.sessionId, version: 1, - sequence: 1, - callId: call._id, - }); + sequence, + callId: channel.callId, + } as MediaSignal); + } + + private async sendSignal(signal: MediaSignal): Promise { + console.log('sendSignal', signal); } - private async getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { + private async getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list const existingChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); if (existingChannel) { - return existingChannel; + return existingChannel as ValidSignalChannel; } const role = this.getRoleForActor(call, actor); @@ -105,7 +280,7 @@ export class MediaSignalServer { throw new Error('failed-to-insert-channel'); } - return insertedChannel; + return insertedChannel as ValidSignalChannel; } private getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMediaCallChannel['role'] { @@ -144,7 +319,7 @@ export class MediaSignalServer { await this.processRequest(signal); break; case 'deliver': - await this.processDeliver(signal); + await this.processDeliver(signal, call, channel); break; case 'notify': await this.processNotify(signal, call, channel); @@ -182,6 +357,7 @@ export class MediaSignalServer { createdBy: caller, createdAt: new Date(), + sequence: 0, caller, callee, diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index 16b12e6946785..71377bb671a82 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -1,4 +1,4 @@ -import type { IMediaCallChannel, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, MediaCallParticipantIdentification, MediaCallChannelWebRTCSession } from '@rocket.chat/core-typings'; import type { UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -6,4 +6,6 @@ import type { IBaseModel } from './IBaseModel'; export interface IMediaCallChannelsModel extends IBaseModel { findOneByCallIdAndParticipant(callId: string, participant: MediaCallParticipantIdentification): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; + setLocalWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise; + setRemoteWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index faae63d83cbf4..f885bf06ad8de 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -7,4 +7,7 @@ export interface IMediaCallsModel extends IBaseModel { setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise; setStateById(callId: string, state: IMediaCall['state']): Promise; startRingingById(callId: string): Promise; + getNewSequence(callId: string): Promise; + acceptCallById(callId: string, calleeSessionId?: string): Promise; + setCallerSessionIdById(callId: string, callerSessionId: string): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index 269863a49240d..d0e403f128fa1 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -1,4 +1,9 @@ -import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { + IMediaCallChannel, + RocketChatRecordDeleted, + MediaCallParticipantIdentification, + MediaCallChannelWebRTCSession, +} from '@rocket.chat/core-typings'; import type { IMediaCallChannelsModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; @@ -42,4 +47,12 @@ export class MediaCallChannelsRaw extends BaseRaw implements public async setState(_id: string, state: IMediaCallChannel['state']): Promise { return this.updateOneById(_id, { $set: { state } }); } + + public async setLocalWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise { + return this.updateOneById(_id, { $set: { 'webrtc.local': session } }); + } + + public async setRemoteWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise { + return this.updateOneById(_id, { $set: { 'webrtc.remote': session } }); + } } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 24f8ed87a40b9..e581ca3fdd921 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -47,4 +47,34 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod { $set: { state: 'ringing' } }, ); } + + public async acceptCallById(callId: string, calleeSessionId?: string): Promise { + if (!calleeSessionId) { + return this.updateOne({ _id: callId, state: { $in: ['none', 'ringing'] } }, { $set: { state: 'accepted' } }); + } + + return this.updateOne( + { + _id: callId, + $or: [ + { + state: { $in: ['none', 'ringing'] }, + }, + { + 'state': 'accepted', + 'callee.sessionId': { $exists: false }, + }, + ], + }, + { $set: { 'state': 'accepted', 'callee.sessionId': calleeSessionId } }, + ); + } + + public async setCallerSessionIdById(callId: string, callerSessionId: string): Promise { + return this.updateOne({ '_id': callId, 'caller.sessionId': { $exists: false } }, { $set: { 'caller.sessionId': callerSessionId } }); + } + + public async getNewSequence(callId: string): Promise { + return this.findOneAndUpdate({ _id: callId }, { $inc: { sequence: 1 } }); + } } From c062c57eeae4094a1690a9c15987c727cd4706ac Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 22 Jul 2025 11:48:33 -0300 Subject: [PATCH 05/72] split files --- .../src/MediaSignalServer.ts | 408 ------------------ .../src/calls/createCall.ts | 51 +++ .../src/calls/getNewCallSequence.ts | 11 + .../src/channels/createInitialChannel.ts | 35 ++ .../src/channels/deliverChannelSDP.ts | 31 ++ .../src/channels/getActorChannel.ts | 15 + .../src/channels/getOppositeChannel.ts | 14 + .../src/channels/getRoleForActor.ts | 15 + .../src/channels/requestChannelOffer.ts | 20 + .../src/channels/setRemoteSDP.ts | 24 ++ packages/media-signaling-server/src/index.ts | 3 +- .../src/signals/deliver/processDeliver.ts | 11 + .../src/signals/deliver/sdp.ts | 29 ++ .../src/signals/getChannelForSignal.ts | 45 ++ .../src/signals/isValidSignalChannel.ts | 5 + .../src/signals/isValidSignalRole.ts | 6 + .../src/signals/notify/accept.ts | 54 +++ .../src/signals/notify/ack.ts | 18 + .../src/signals/notify/processNotify.ts | 14 + .../src/signals/processSignal.ts | 35 ++ .../src/signals/sendSignal.ts | 5 + .../src/signals/sendSignalToChannel.ts | 25 ++ .../src/signals/validateChannelForSignals.ts | 10 + .../src/utils/compareActorsIgnoringSession.ts | 5 + 24 files changed, 480 insertions(+), 409 deletions(-) delete mode 100644 packages/media-signaling-server/src/MediaSignalServer.ts create mode 100644 packages/media-signaling-server/src/calls/createCall.ts create mode 100644 packages/media-signaling-server/src/calls/getNewCallSequence.ts create mode 100644 packages/media-signaling-server/src/channels/createInitialChannel.ts create mode 100644 packages/media-signaling-server/src/channels/deliverChannelSDP.ts create mode 100644 packages/media-signaling-server/src/channels/getActorChannel.ts create mode 100644 packages/media-signaling-server/src/channels/getOppositeChannel.ts create mode 100644 packages/media-signaling-server/src/channels/getRoleForActor.ts create mode 100644 packages/media-signaling-server/src/channels/requestChannelOffer.ts create mode 100644 packages/media-signaling-server/src/channels/setRemoteSDP.ts create mode 100644 packages/media-signaling-server/src/signals/deliver/processDeliver.ts create mode 100644 packages/media-signaling-server/src/signals/deliver/sdp.ts create mode 100644 packages/media-signaling-server/src/signals/getChannelForSignal.ts create mode 100644 packages/media-signaling-server/src/signals/isValidSignalChannel.ts create mode 100644 packages/media-signaling-server/src/signals/isValidSignalRole.ts create mode 100644 packages/media-signaling-server/src/signals/notify/accept.ts create mode 100644 packages/media-signaling-server/src/signals/notify/ack.ts create mode 100644 packages/media-signaling-server/src/signals/notify/processNotify.ts create mode 100644 packages/media-signaling-server/src/signals/processSignal.ts create mode 100644 packages/media-signaling-server/src/signals/sendSignal.ts create mode 100644 packages/media-signaling-server/src/signals/sendSignalToChannel.ts create mode 100644 packages/media-signaling-server/src/signals/validateChannelForSignals.ts create mode 100644 packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts diff --git a/packages/media-signaling-server/src/MediaSignalServer.ts b/packages/media-signaling-server/src/MediaSignalServer.ts deleted file mode 100644 index dafebff888895..0000000000000 --- a/packages/media-signaling-server/src/MediaSignalServer.ts +++ /dev/null @@ -1,408 +0,0 @@ -import type { IMediaCall, IMediaCallChannel, IUser, MediaCallActor, ValidSignalChannel, AtLeast } from '@rocket.chat/core-typings'; -import type { - DeliverParams, - MediaSignal, - MediaSignalDeliver, - MediaSignalNotify, - MediaSignalRequest, - RequestParams, -} from '@rocket.chat/media-signaling'; -import type { InsertionModel } from '@rocket.chat/model-typings'; -import { Users, MediaCalls, MediaCallChannels } from '@rocket.chat/models'; - -type ValidUser = AtLeast, '_id' | 'username' | 'name'>; - -export class MediaSignalServer { - private async processRequest(_signal: MediaSignalRequest): Promise { - // - } - - private async processDeliver(signal: MediaSignalDeliver, call: IMediaCall, channel: ValidSignalChannel): Promise { - switch (signal.body.deliver) { - case 'sdp': - return this.processSDP(signal as MediaSignalDeliver<'sdp'>, call, channel); - } - } - - private async processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: ValidSignalChannel): Promise { - switch (signal.body.notify) { - case 'ack': - return this.processACK(signal as MediaSignalNotify<'ack'>, call, channel); - case 'accept': - return this.processAccept(signal as MediaSignalNotify<'accept'>, call, channel); - } - } - - private async processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - // If the call is still on an empty state and a signal reached a callee, change the state to 'ringing' - - if (channel.role === 'callee' && call.state === 'none') { - const result = await MediaCalls.startRingingById(call._id); - - // If the state was changed, request an offer from the caller - if (result.modifiedCount) { - await this.requestOffer(channel); - } - } - } - - private async processAccept(_signal: MediaSignalNotify<'accept'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - if (channel.role === 'callee') { - if (!this.compareActorsIgnoringSession(call.callee, channel.participant)) { - // Someone else tried to accept the call... should we respond something? - return; - } - const sessionId = (channel.participant.type === 'user' && channel.participant.sessionId) || undefined; - - const result = await MediaCalls.acceptCallById(call._id, sessionId); - if (!result.modifiedCount) { - // # nothing was changed by this acceptance... should we respond something? - return; - } - - const newSequence = await this.getNewCallSequence(channel.callId); - await this.sendSignalToChannel(newSequence.sequence, channel, { - type: 'notify', - body: { - notify: 'state', - callState: 'accepted', - }, - }); - - const otherChannel = await this.getOppositeChannel(call, channel); - if (otherChannel && this.isValidSignalChannel(otherChannel)) { - await this.sendSignalToChannel(newSequence.sequence, otherChannel, { - type: 'notify', - body: { - notify: 'state', - callState: 'accepted', - }, - }); - } - - return; - } - - if (channel.role === 'caller' && call.caller.type === 'user' && !call.caller.sessionId) { - const result = await MediaCalls.setCallerSessionIdById(call._id, channel.participant.sessionId); - if (result.modifiedCount) { - // #Todo: Calls initiated without a caller sessionId - } - } - } - - private async getActorChannel(callId: IMediaCall['_id'], actor: MediaCallActor): Promise { - if (actor.type !== 'user') { - throw new Error('not-implemented'); - } - - // If there is no sessionId yet, we can't determine which channel is going to be used by this actor - if (!actor.sessionId) { - return null; - } - - return MediaCallChannels.findOneByCallIdAndParticipant(callId, actor); - } - - private async getOppositeChannel(call: IMediaCall, channel: IMediaCallChannel): Promise { - switch (channel.role) { - case 'callee': - return this.getActorChannel(call._id, call.callee); - case 'caller': - return this.getActorChannel(call._id, call.caller); - default: - return null; - } - } - - private async processSDP(signal: MediaSignalDeliver<'sdp'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - // Save the SDP for the local session of the channel - await MediaCallChannels.setLocalWebRTCSession(channel._id, { - description: signal.body.sdp, - iceCandidates: [], - iceGatheringComplete: Boolean(signal.body.endOfCandidates), - assignSequence: signal.sequence, - }); - - // Find the opposite channel and save the SDP there as well - const otherChannel = await this.getOppositeChannel(call, channel); - if (otherChannel) { - await this.setRemoteSDP( - otherChannel, - { - sdp: signal.body.sdp, - endOfCandidates: Boolean(signal.body.endOfCandidates), - }, - signal.sequence, - ); - } - } - - private async requestOffer(channel: IMediaCallChannel, params?: RequestParams<'offer'>): Promise { - this.validateChannelForSignals(channel); - - const call = await this.getNewCallSequence(channel.callId); - - await this.sendSignalToChannel(call.sequence, channel, { - type: 'request', - body: { - request: 'offer', - ...params, - }, - }); - } - - private isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is MediaSignal['role'] { - return ['caller', 'callee'].includes(channelRole); - } - - private isValidSignalChannel(channel: IMediaCallChannel): channel is ValidSignalChannel { - return channel.participant.type === 'user'; - } - - private async getNewCallSequence(callId: IMediaCall['_id']): Promise { - const call = await MediaCalls.getNewSequence(callId); - if (!call) { - throw new Error('failed-to-reserve-sequence'); - } - - return call; - } - - private validateChannelForSignals(channel: IMediaCallChannel): asserts channel is ValidSignalChannel { - if (channel.participant.type !== 'user') { - throw new Error('not-implemented'); - } - if (!channel.participant.sessionId) { - throw new Error('SDP may only be sent to specific user sessions.'); - } - } - - private async setRemoteSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>, sequence: number): Promise { - await MediaCallChannels.setRemoteWebRTCSession(channel._id, { - description: params.sdp, - iceCandidates: [], - iceGatheringComplete: Boolean(params.endOfCandidates), - assignSequence: sequence, - }); - - if (channel.participant.type !== 'user') { - // No need to send any signals if the remote participant is not a rocket.chat user - return; - } - - await this.deliverSDP(channel, { - sdp: params.sdp, - endOfCandidates: params.endOfCandidates, - }); - } - - private async deliverSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>): Promise { - this.validateChannelForSignals(channel); - - const call = await this.getNewCallSequence(channel.callId); - - // If the sdp is an offer, send an answer request, otherwise simply deliver it - if (params.sdp.type === 'offer') { - await this.sendSignalToChannel(call.sequence, channel, { - type: 'request', - body: { - request: 'answer', - offer: params.sdp, - }, - }); - } else { - await this.sendSignalToChannel(call.sequence, channel, { - type: 'deliver', - body: { - deliver: 'sdp', - ...params, - }, - }); - } - } - - private async sendSignalToChannel( - sequence: number, - channel: ValidSignalChannel, - signal: Omit, - ): Promise { - if (!this.isValidSignalRole(channel.role)) { - // Tried to send a signal to a channel that is neither the caller nor the callee... what to do? - return; - } - - await this.sendSignal({ - ...signal, - role: channel.role, - sessionId: channel.participant.sessionId, - version: 1, - sequence, - callId: channel.callId, - } as MediaSignal); - } - - private async sendSignal(signal: MediaSignal): Promise { - console.log('sendSignal', signal); - } - - private async getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { - const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; - - // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list - const existingChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); - - if (existingChannel) { - return existingChannel as ValidSignalChannel; - } - - const role = this.getRoleForActor(call, actor); - - const newChannel: InsertionModel = { - callId: call._id, - participant: { - ...actor, - username: user.username, - displayName: user.name, - }, - role, - state: 'none', - }; - - try { - await MediaCallChannels.insertOne(newChannel); - } catch (e) { - // #ToDo: Check if it was really a race condition? - } - - const insertedChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); - if (!insertedChannel) { - throw new Error('failed-to-insert-channel'); - } - - return insertedChannel as ValidSignalChannel; - } - - private getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMediaCallChannel['role'] { - if (this.compareActorsIgnoringSession(call.caller, actor)) { - return 'caller'; - } - - if (this.compareActorsIgnoringSession(call.callee, actor)) { - return 'callee'; - } - - return 'none'; - } - - private compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { - return actor1.type === actor2.type && actor1.id === actor2.id; - } - - public async processSignal(signal: MediaSignal, uid: IUser['_id']) { - console.log(signal); - - const call = await MediaCalls.findOneById(signal.callId); - if (!call) { - throw new Error('invalid-call'); - } - - const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); - if (!user?.username || !user?.name) { - throw new Error('invalid-user'); - } - - const channel = await this.getChannelForSignal(signal, call, user); - - switch (signal.type) { - case 'request': - await this.processRequest(signal); - break; - case 'deliver': - await this.processDeliver(signal, call, channel); - break; - case 'notify': - await this.processNotify(signal, call, channel); - break; - } - } - - public async createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { - if (caller.type !== 'user' || callee.type !== 'user') { - throw new Error('not-implemented'); - } - if (!caller.sessionId) { - throw new Error('not-implemented'); - } - - const callerUser = await Users.findOneById>(caller.id, { - projection: { username: 1, name: 1 }, - }); - - if (!callerUser?.username) { - throw new Error('invalid-caller'); - } - - const calleeUser = await Users.findOneById>(callee.id, { - projection: { username: 1, name: 1 }, - }); - if (!calleeUser?.username) { - throw new Error('invalid-callee'); - } - - const call: Omit = { - service: 'webrtc', - kind: 'direct', - state: 'none', - - createdBy: caller, - createdAt: new Date(), - sequence: 0, - - caller, - callee, - }; - - const insertResult = await MediaCalls.insertOne(call); - if (insertResult.insertedId) { - await Promise.allSettled([ - this.createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), - this.createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), - ]); - } - - console.log(insertResult); - } - - private async createInitialChannel( - callId: string, - actor: MediaCallActor, - channelData: Partial>, - userData?: Pick, - ): Promise { - // Rocket.Chat users will have a separate channel entry for each session involved in the call - // So if we don't have the sessionId of an user yet, do not initialize the channel - if (actor.type !== 'user' || !actor.sessionId) { - return; - } - - if (!userData?.username || !userData.name) { - return; - } - - await MediaCallChannels.insertOne({ - role: 'none', - ...channelData, - callId, - participant: { - type: 'user', - id: userData._id, - username: userData.username, - displayName: userData.name, - sessionId: actor.sessionId, - }, - state: 'none', - joinedAt: new Date(), - }); - } -} diff --git a/packages/media-signaling-server/src/calls/createCall.ts b/packages/media-signaling-server/src/calls/createCall.ts new file mode 100644 index 0000000000000..505b211417fd0 --- /dev/null +++ b/packages/media-signaling-server/src/calls/createCall.ts @@ -0,0 +1,51 @@ +import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import { MediaCalls, Users } from '@rocket.chat/models'; + +import { createInitialChannel } from '../channels/createInitialChannel'; + +export async function createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { + if (caller.type !== 'user' || callee.type !== 'user') { + throw new Error('not-implemented'); + } + if (!caller.sessionId) { + throw new Error('not-implemented'); + } + + const callerUser = await Users.findOneById>(caller.id, { + projection: { username: 1, name: 1 }, + }); + + if (!callerUser?.username) { + throw new Error('invalid-caller'); + } + + const calleeUser = await Users.findOneById>(callee.id, { + projection: { username: 1, name: 1 }, + }); + if (!calleeUser?.username) { + throw new Error('invalid-callee'); + } + + const call: Omit = { + service: 'webrtc', + kind: 'direct', + state: 'none', + + createdBy: caller, + createdAt: new Date(), + sequence: 0, + + caller, + callee, + }; + + const insertResult = await MediaCalls.insertOne(call); + if (insertResult.insertedId) { + await Promise.allSettled([ + createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), + createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), + ]); + } + + console.log(insertResult); +} diff --git a/packages/media-signaling-server/src/calls/getNewCallSequence.ts b/packages/media-signaling-server/src/calls/getNewCallSequence.ts new file mode 100644 index 0000000000000..dd596b0390599 --- /dev/null +++ b/packages/media-signaling-server/src/calls/getNewCallSequence.ts @@ -0,0 +1,11 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +export async function getNewCallSequence(callId: IMediaCall['_id']): Promise { + const call = await MediaCalls.getNewSequence(callId); + if (!call) { + throw new Error('failed-to-reserve-sequence'); + } + + return call; +} diff --git a/packages/media-signaling-server/src/channels/createInitialChannel.ts b/packages/media-signaling-server/src/channels/createInitialChannel.ts new file mode 100644 index 0000000000000..ad5f6537a582e --- /dev/null +++ b/packages/media-signaling-server/src/channels/createInitialChannel.ts @@ -0,0 +1,35 @@ +import type { IMediaCallChannel, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { MediaCallChannels } from '@rocket.chat/models'; + +export async function createInitialChannel( + callId: string, + actor: MediaCallActor, + channelData: Partial>, + userData?: Pick, +): Promise { + // Rocket.Chat users will have a separate channel entry for each session involved in the call + // So if we don't have the sessionId of an user yet, do not initialize the channel + if (actor.type !== 'user' || !actor.sessionId) { + return; + } + + if (!userData?.username || !userData.name) { + return; + } + + await MediaCallChannels.insertOne({ + role: 'none', + ...channelData, + callId, + participant: { + type: 'user', + id: userData._id, + username: userData.username, + displayName: userData.name, + sessionId: actor.sessionId, + }, + state: 'none', + joinedAt: new Date(), + }); +} diff --git a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts new file mode 100644 index 0000000000000..dc3e1f1d795ae --- /dev/null +++ b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts @@ -0,0 +1,31 @@ +import type { IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { DeliverParams } from '@rocket.chat/media-signaling'; + +import { getNewCallSequence } from '../calls/getNewCallSequence'; +import { sendSignalToChannel } from '../signals/sendSignalToChannel'; +import { validateChannelForSignals } from '../signals/validateChannelForSignals'; + +export async function deliverChannelSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>): Promise { + validateChannelForSignals(channel); + + const call = await getNewCallSequence(channel.callId); + + // If the sdp is an offer, send an answer request, otherwise simply deliver it + if (params.sdp.type === 'offer') { + await sendSignalToChannel(call.sequence, channel, { + type: 'request', + body: { + request: 'answer', + offer: params.sdp, + }, + }); + } else { + await sendSignalToChannel(call.sequence, channel, { + type: 'deliver', + body: { + deliver: 'sdp', + ...params, + }, + }); + } +} diff --git a/packages/media-signaling-server/src/channels/getActorChannel.ts b/packages/media-signaling-server/src/channels/getActorChannel.ts new file mode 100644 index 0000000000000..1c36fb85360f1 --- /dev/null +++ b/packages/media-signaling-server/src/channels/getActorChannel.ts @@ -0,0 +1,15 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; +import { MediaCallChannels } from '@rocket.chat/models'; + +export async function getActorChannel(callId: IMediaCall['_id'], actor: MediaCallActor): Promise { + if (actor.type !== 'user') { + throw new Error('not-implemented'); + } + + // If there is no sessionId yet, we can't determine which channel is going to be used by this actor + if (!actor.sessionId) { + return null; + } + + return MediaCallChannels.findOneByCallIdAndParticipant(callId, actor); +} diff --git a/packages/media-signaling-server/src/channels/getOppositeChannel.ts b/packages/media-signaling-server/src/channels/getOppositeChannel.ts new file mode 100644 index 0000000000000..688d8a6b3dd94 --- /dev/null +++ b/packages/media-signaling-server/src/channels/getOppositeChannel.ts @@ -0,0 +1,14 @@ +import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; + +import { getActorChannel } from './getActorChannel'; + +export async function getOppositeChannel(call: IMediaCall, channel: IMediaCallChannel): Promise { + switch (channel.role) { + case 'callee': + return getActorChannel(call._id, call.callee); + case 'caller': + return getActorChannel(call._id, call.caller); + default: + return null; + } +} diff --git a/packages/media-signaling-server/src/channels/getRoleForActor.ts b/packages/media-signaling-server/src/channels/getRoleForActor.ts new file mode 100644 index 0000000000000..ffb5772a35d9a --- /dev/null +++ b/packages/media-signaling-server/src/channels/getRoleForActor.ts @@ -0,0 +1,15 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; + +import { compareActorsIgnoringSession } from '../utils/compareActorsIgnoringSession'; + +export function getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMediaCallChannel['role'] { + if (compareActorsIgnoringSession(call.caller, actor)) { + return 'caller'; + } + + if (compareActorsIgnoringSession(call.callee, actor)) { + return 'callee'; + } + + return 'none'; +} diff --git a/packages/media-signaling-server/src/channels/requestChannelOffer.ts b/packages/media-signaling-server/src/channels/requestChannelOffer.ts new file mode 100644 index 0000000000000..d4290bba31d3f --- /dev/null +++ b/packages/media-signaling-server/src/channels/requestChannelOffer.ts @@ -0,0 +1,20 @@ +import type { IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { RequestParams } from '@rocket.chat/media-signaling'; + +import { getNewCallSequence } from '../calls/getNewCallSequence'; +import { sendSignalToChannel } from '../signals/sendSignalToChannel'; +import { validateChannelForSignals } from '../signals/validateChannelForSignals'; + +export async function requestChannelOffer(channel: IMediaCallChannel, params?: RequestParams<'offer'>): Promise { + validateChannelForSignals(channel); + + const call = await getNewCallSequence(channel.callId); + + await sendSignalToChannel(call.sequence, channel, { + type: 'request', + body: { + request: 'offer', + ...params, + }, + }); +} diff --git a/packages/media-signaling-server/src/channels/setRemoteSDP.ts b/packages/media-signaling-server/src/channels/setRemoteSDP.ts new file mode 100644 index 0000000000000..827f344457053 --- /dev/null +++ b/packages/media-signaling-server/src/channels/setRemoteSDP.ts @@ -0,0 +1,24 @@ +import type { IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { DeliverParams } from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import { deliverChannelSDP } from './deliverChannelSDP'; + +export async function setRemoteSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>, sequence: number): Promise { + await MediaCallChannels.setRemoteWebRTCSession(channel._id, { + description: params.sdp, + iceCandidates: [], + iceGatheringComplete: Boolean(params.endOfCandidates), + assignSequence: sequence, + }); + + if (channel.participant.type !== 'user') { + // No need to send any signals if the remote participant is not a rocket.chat user + return; + } + + await deliverChannelSDP(channel, { + sdp: params.sdp, + endOfCandidates: params.endOfCandidates, + }); +} diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index 2824c53d6647d..15cd7a1658fcf 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1 +1,2 @@ -export default () => "Media-signaling-server"; +export { createCall } from './calls/createCall'; +export { processSignal } from './signals/processSignal'; diff --git a/packages/media-signaling-server/src/signals/deliver/processDeliver.ts b/packages/media-signaling-server/src/signals/deliver/processDeliver.ts new file mode 100644 index 0000000000000..2d9e03cf8c929 --- /dev/null +++ b/packages/media-signaling-server/src/signals/deliver/processDeliver.ts @@ -0,0 +1,11 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalDeliver } from '@rocket.chat/media-signaling'; + +import { processSDP } from './sdp'; + +export async function processDeliver(signal: MediaSignalDeliver, call: IMediaCall, channel: ValidSignalChannel): Promise { + switch (signal.body.deliver) { + case 'sdp': + return processSDP(signal as MediaSignalDeliver<'sdp'>, call, channel); + } +} diff --git a/packages/media-signaling-server/src/signals/deliver/sdp.ts b/packages/media-signaling-server/src/signals/deliver/sdp.ts new file mode 100644 index 0000000000000..c589722b90bad --- /dev/null +++ b/packages/media-signaling-server/src/signals/deliver/sdp.ts @@ -0,0 +1,29 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalDeliver } from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import { getOppositeChannel } from '../../channels/getOppositeChannel'; +import { setRemoteSDP } from '../../channels/setRemoteSDP'; + +export async function processSDP(signal: MediaSignalDeliver<'sdp'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + // Save the SDP for the local session of the channel + await MediaCallChannels.setLocalWebRTCSession(channel._id, { + description: signal.body.sdp, + iceCandidates: [], + iceGatheringComplete: Boolean(signal.body.endOfCandidates), + assignSequence: signal.sequence, + }); + + // Find the opposite channel and save the SDP there as well + const otherChannel = await getOppositeChannel(call, channel); + if (otherChannel) { + await setRemoteSDP( + otherChannel, + { + sdp: signal.body.sdp, + endOfCandidates: Boolean(signal.body.endOfCandidates), + }, + signal.sequence, + ); + } +} diff --git a/packages/media-signaling-server/src/signals/getChannelForSignal.ts b/packages/media-signaling-server/src/signals/getChannelForSignal.ts new file mode 100644 index 0000000000000..0a50cc0372530 --- /dev/null +++ b/packages/media-signaling-server/src/signals/getChannelForSignal.ts @@ -0,0 +1,45 @@ +import type { AtLeast, IMediaCall, IMediaCallChannel, IUser, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import { getRoleForActor } from '../channels/getRoleForActor'; + +type ValidUser = AtLeast, '_id' | 'username' | 'name'>; + +export async function getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { + const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; + + // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list + const existingChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); + + if (existingChannel) { + return existingChannel as ValidSignalChannel; + } + + const role = getRoleForActor(call, actor); + + const newChannel: InsertionModel = { + callId: call._id, + participant: { + ...actor, + username: user.username, + displayName: user.name, + }, + role, + state: 'none', + }; + + try { + await MediaCallChannels.insertOne(newChannel); + } catch (e) { + // #ToDo: Check if it was really a race condition? + } + + const insertedChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); + if (!insertedChannel) { + throw new Error('failed-to-insert-channel'); + } + + return insertedChannel as ValidSignalChannel; +} diff --git a/packages/media-signaling-server/src/signals/isValidSignalChannel.ts b/packages/media-signaling-server/src/signals/isValidSignalChannel.ts new file mode 100644 index 0000000000000..fa31cb7e0a6c4 --- /dev/null +++ b/packages/media-signaling-server/src/signals/isValidSignalChannel.ts @@ -0,0 +1,5 @@ +import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; + +export function isValidSignalChannel(channel: IMediaCallChannel): channel is ValidSignalChannel { + return channel.participant.type === 'user'; +} diff --git a/packages/media-signaling-server/src/signals/isValidSignalRole.ts b/packages/media-signaling-server/src/signals/isValidSignalRole.ts new file mode 100644 index 0000000000000..7f30e53840d59 --- /dev/null +++ b/packages/media-signaling-server/src/signals/isValidSignalRole.ts @@ -0,0 +1,6 @@ +import type { IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +export function isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is MediaSignal['role'] { + return ['caller', 'callee'].includes(channelRole); +} diff --git a/packages/media-signaling-server/src/signals/notify/accept.ts b/packages/media-signaling-server/src/signals/notify/accept.ts new file mode 100644 index 0000000000000..294604db332ca --- /dev/null +++ b/packages/media-signaling-server/src/signals/notify/accept.ts @@ -0,0 +1,54 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { getNewCallSequence } from '../../calls/getNewCallSequence'; +import { getOppositeChannel } from '../../channels/getOppositeChannel'; +import { compareActorsIgnoringSession } from '../../utils/compareActorsIgnoringSession'; +import { isValidSignalChannel } from '../isValidSignalChannel'; +import { sendSignalToChannel } from '../sendSignalToChannel'; + +export async function processAccept(_signal: MediaSignalNotify<'accept'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + if (channel.role === 'callee') { + if (!compareActorsIgnoringSession(call.callee, channel.participant)) { + // Someone else tried to accept the call... should we respond something? + return; + } + const sessionId = (channel.participant.type === 'user' && channel.participant.sessionId) || undefined; + + const result = await MediaCalls.acceptCallById(call._id, sessionId); + if (!result.modifiedCount) { + // # nothing was changed by this acceptance... should we respond something? + return; + } + + const newSequence = await getNewCallSequence(channel.callId); + await sendSignalToChannel(newSequence.sequence, channel, { + type: 'notify', + body: { + notify: 'state', + callState: 'accepted', + }, + }); + + const otherChannel = await getOppositeChannel(call, channel); + if (otherChannel && isValidSignalChannel(otherChannel)) { + await sendSignalToChannel(newSequence.sequence, otherChannel, { + type: 'notify', + body: { + notify: 'state', + callState: 'accepted', + }, + }); + } + + return; + } + + if (channel.role === 'caller' && call.caller.type === 'user' && !call.caller.sessionId) { + const result = await MediaCalls.setCallerSessionIdById(call._id, channel.participant.sessionId); + if (result.modifiedCount) { + // #Todo: Calls initiated without a caller sessionId + } + } +} diff --git a/packages/media-signaling-server/src/signals/notify/ack.ts b/packages/media-signaling-server/src/signals/notify/ack.ts new file mode 100644 index 0000000000000..ff61a4bde4cee --- /dev/null +++ b/packages/media-signaling-server/src/signals/notify/ack.ts @@ -0,0 +1,18 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { requestChannelOffer } from '../../channels/requestChannelOffer'; + +export async function processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + // If the call is still on an empty state and a signal reached a callee, change the state to 'ringing' + + if (channel.role === 'callee' && call.state === 'none') { + const result = await MediaCalls.startRingingById(call._id); + + // If the state was changed, request an offer from the caller + if (result.modifiedCount) { + await requestChannelOffer(channel); + } + } +} diff --git a/packages/media-signaling-server/src/signals/notify/processNotify.ts b/packages/media-signaling-server/src/signals/notify/processNotify.ts new file mode 100644 index 0000000000000..7e886c3cd39b7 --- /dev/null +++ b/packages/media-signaling-server/src/signals/notify/processNotify.ts @@ -0,0 +1,14 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; + +import { processAccept } from './accept'; +import { processACK } from './ack'; + +export async function processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: ValidSignalChannel): Promise { + switch (signal.body.notify) { + case 'ack': + return processACK(signal as MediaSignalNotify<'ack'>, call, channel); + case 'accept': + return processAccept(signal as MediaSignalNotify<'accept'>, call, channel); + } +} diff --git a/packages/media-signaling-server/src/signals/processSignal.ts b/packages/media-signaling-server/src/signals/processSignal.ts new file mode 100644 index 0000000000000..c83d872dbd604 --- /dev/null +++ b/packages/media-signaling-server/src/signals/processSignal.ts @@ -0,0 +1,35 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; +import { MediaCalls, Users } from '@rocket.chat/models'; + +import { processDeliver } from './deliver/processDeliver'; +import { getChannelForSignal } from './getChannelForSignal'; +import { processNotify } from './notify/processNotify'; + +export async function processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { + console.log(signal); + + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + throw new Error('invalid-call'); + } + + const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); + if (!user?.username || !user?.name) { + throw new Error('invalid-user'); + } + + const channel = await getChannelForSignal(signal, call, user); + + switch (signal.type) { + // case 'request': + // await processRequest(signal); + // break; + case 'deliver': + await processDeliver(signal, call, channel); + break; + case 'notify': + await processNotify(signal, call, channel); + break; + } +} diff --git a/packages/media-signaling-server/src/signals/sendSignal.ts b/packages/media-signaling-server/src/signals/sendSignal.ts new file mode 100644 index 0000000000000..7ace6cef60449 --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendSignal.ts @@ -0,0 +1,5 @@ +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +export async function sendSignal(signal: MediaSignal): Promise { + console.log('sendSignal', signal); +} diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts new file mode 100644 index 0000000000000..ca2f6bab11641 --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts @@ -0,0 +1,25 @@ +import type { ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +import { isValidSignalRole } from './isValidSignalRole'; +import { sendSignal } from './sendSignal'; + +export async function sendSignalToChannel( + sequence: number, + channel: ValidSignalChannel, + signal: Omit, +): Promise { + if (!isValidSignalRole(channel.role)) { + // Tried to send a signal to a channel that is neither the caller nor the callee... what to do? + return; + } + + await sendSignal({ + ...signal, + role: channel.role, + sessionId: channel.participant.sessionId, + version: 1, + sequence, + callId: channel.callId, + } as MediaSignal); +} diff --git a/packages/media-signaling-server/src/signals/validateChannelForSignals.ts b/packages/media-signaling-server/src/signals/validateChannelForSignals.ts new file mode 100644 index 0000000000000..5da1c1183537c --- /dev/null +++ b/packages/media-signaling-server/src/signals/validateChannelForSignals.ts @@ -0,0 +1,10 @@ +import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; + +export function validateChannelForSignals(channel: IMediaCallChannel): asserts channel is ValidSignalChannel { + if (channel.participant.type !== 'user') { + throw new Error('not-implemented'); + } + if (!channel.participant.sessionId) { + throw new Error('SDP may only be sent to specific user sessions.'); + } +} diff --git a/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts b/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts new file mode 100644 index 0000000000000..425a203e6fd1e --- /dev/null +++ b/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts @@ -0,0 +1,5 @@ +import type { MediaCallActor } from '@rocket.chat/core-typings'; + +export function compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { + return actor1.type === actor2.type && actor1.id === actor2.id; +} From 8ed06352aac916d0d01262a1d05e858a6b064fd2 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 22 Jul 2025 17:50:15 -0300 Subject: [PATCH 06/72] service, endpoints and streams --- apps/meteor/app/api/server/v1/mediacalls.ts | 57 +++++ apps/meteor/package.json | 2 + .../modules/listeners/listeners.module.ts | 5 + .../notifications/notifications.module.ts | 5 + .../server/services/media-call/service.ts | 46 ++++ apps/meteor/server/services/startup.ts | 2 + packages/core-services/package.json | 2 + packages/core-services/src/events/Events.ts | 2 + packages/core-services/src/index.ts | 3 + .../src/types/IMediaCallService.ts | 8 + packages/ddp-client/src/types/streams.ts | 2 + packages/media-signaling-server/package.json | 1 + .../src/calls/createCall.ts | 22 +- .../src/channels/deliverChannelSDP.ts | 6 +- .../src/channels/requestChannelOffer.ts | 3 +- packages/media-signaling-server/src/index.ts | 2 + .../src/signals/buildSignal.ts | 11 + .../src/signals/notify/accept.ts | 6 +- .../src/signals/sendSignal.ts | 5 - .../src/signals/sendSignalToChannel.ts | 35 +-- .../src/signals/signalHandler.ts | 20 ++ .../src/utils/logger.ts | 6 + packages/media-signaling-server/tsconfig.json | 3 +- packages/media-signaling/package.json | 3 + .../src/definition/MediaSignal.ts | 24 +- .../src/definition/MediaSignalDeliver.ts | 88 ++++++- .../src/definition/MediaSignalHeader.ts | 57 +++++ .../src/definition/MediaSignalNotify.ts | 233 +++++++++++++++++- .../src/definition/MediaSignalRequest.ts | 72 +++++- .../media-signaling/src/definition/WebRTC.ts | 47 ++++ packages/media-signaling/src/lib/Session.ts | 12 +- packages/rest-typings/package.json | 1 + packages/rest-typings/src/index.ts | 3 + .../v1/media-calls/MediaCallsSignalProps.ts | 18 ++ .../v1/media-calls/MediaCallsStartProps.ts | 24 ++ .../rest-typings/src/v1/media-calls/index.ts | 16 ++ yarn.lock | 15 +- 37 files changed, 803 insertions(+), 64 deletions(-) create mode 100644 apps/meteor/app/api/server/v1/mediacalls.ts create mode 100644 apps/meteor/server/services/media-call/service.ts create mode 100644 packages/core-services/src/types/IMediaCallService.ts create mode 100644 packages/media-signaling-server/src/signals/buildSignal.ts delete mode 100644 packages/media-signaling-server/src/signals/sendSignal.ts create mode 100644 packages/media-signaling-server/src/signals/signalHandler.ts create mode 100644 packages/media-signaling-server/src/utils/logger.ts create mode 100644 packages/media-signaling/src/definition/WebRTC.ts create mode 100644 packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts create mode 100644 packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts create mode 100644 packages/rest-typings/src/v1/media-calls/index.ts diff --git a/apps/meteor/app/api/server/v1/mediacalls.ts b/apps/meteor/app/api/server/v1/mediacalls.ts new file mode 100644 index 0000000000000..8769db49590f5 --- /dev/null +++ b/apps/meteor/app/api/server/v1/mediacalls.ts @@ -0,0 +1,57 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import { isMediaCallsStartProps, isMediaCallsSignalProps } from '@rocket.chat/rest-typings'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { API } from '../api'; + +API.v1.addRoute( + 'media-calls.start', + { authRequired: true, validateParams: isMediaCallsStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, + { + async post() { + const { roomId, sessionId } = this.bodyParams; + const { userId } = this; + if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + return API.v1.failure('invalid-params'); + } + + if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { + return API.v1.forbidden(); + } + + try { + const call = await MediaCall.callRoom({ uid: userId, sessionId }, roomId); + + return API.v1.success({ + call, + }); + } catch (e) { + return API.v1.failure('error'); + } + }, + }, +); + +API.v1.addRoute( + 'media-calls.signal', + { authRequired: true, validateParams: isMediaCallsSignalProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, + { + async post() { + const { signal } = this.bodyParams; + const { userId } = this; + + // if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { + // return API.v1.forbidden(); + // } + + try { + await MediaCall.processSignal(signal, userId); + + return API.v1.success(); + } catch (e) { + return API.v1.failure('error'); + } + }, + }, +); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 92273a2b96a91..ac09800e5e457 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -270,6 +270,8 @@ "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/logo": "^0.32.2", + "@rocket.chat/media-signaling": "workspace:^", + "@rocket.chat/media-signaling-server": "workspace:^", "@rocket.chat/memo": "~0.31.25", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/model-typings": "workspace:^", diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index d0894913cec15..23749eec09463 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -5,6 +5,7 @@ import { EnterpriseSettings } from '@rocket.chat/core-services'; import { isSettingColor, isSettingEnterprise, UserStatus } from '@rocket.chat/core-typings'; import type { IUser, IRoom, IRole, VideoConference, ISetting, IOmnichannelRoom, IMessage, IOTRMessage } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; import { parse } from '@rocket.chat/message-parser'; import { settings } from '../../../app/settings/server/cached'; @@ -144,6 +145,10 @@ export class ListenersModule { }, ); + service.onEvent('user.media-signal', ({ userId, signal }: { userId: string; signal: MediaSignal }) => { + notifications.notifyUserInThisInstance(userId, 'media-signal', signal); + }); + service.onEvent('room.video-conference', ({ rid, callId }) => { /* deprecated */ (notifications.notifyRoom as any)(rid, callId); diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index ad6bacba354b2..22074d6a47d7f 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -308,6 +308,11 @@ export class NotificationsModule { }); } + if (e === 'media-signal') { + console.log('allowWrite', data); + return true; + } + return Boolean(this.userId); }); this.streamUser.allowRead(async function (eventName) { diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts new file mode 100644 index 0000000000000..f2558b938c048 --- /dev/null +++ b/apps/meteor/server/services/media-call/service.ts @@ -0,0 +1,46 @@ +import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; +import type { IUser, IMediaCall, IRoom } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; +import { processSignal, createCall, setSignalHandler } from '@rocket.chat/media-signaling-server'; +import { Rooms } from '@rocket.chat/models'; + +export class MediaCallService extends ServiceClassInternal implements IMediaCallService { + protected name = 'media-call'; + + constructor() { + super(); + setSignalHandler(this.sendSignal.bind(this)); + } + + public async processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { + return processSignal(signal, uid); + } + + public async createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise { + return createCall( + { + type: 'user', + id: caller.uid, + sessionId: caller.sessionId, + }, + { type: 'user', id: callee.uid }, + ); + } + + public async callRoom(caller: { uid: IUser['_id']; sessionId: string }, roomId: IRoom['_id']): Promise { + const room = await Rooms.findOneById>(roomId, { projection: { t: 1, uids: 1 } }); + if (room?.t !== 'd') { + throw new Error('invalid-room'); + } + const calleeId = room.uids?.filter((uid) => uid !== caller.uid).pop(); + if (!calleeId) { + throw new Error('invalid-room'); + } + + return this.createInternalCall(caller, { uid: calleeId }); + } + + private async sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { + void api.broadcast('user.media-signal', { userId: toUid, signal }); + } +} diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 8707cd409c38d..2a334932d9530 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -13,6 +13,7 @@ import { DeviceManagementService } from './device-management/service'; import { MediaService } from './image/service'; import { ImportService } from './import/service'; import { LDAPService } from './ldap/service'; +import { MediaCallService } from './media-call/service'; import { MessageService } from './messages/service'; import { MeteorService } from './meteor/service'; import { NPSService } from './nps/service'; @@ -61,6 +62,7 @@ export const registerServices = async (): Promise => { api.registerService(new ImportService()); api.registerService(new OmnichannelAnalyticsService()); api.registerService(new UserService()); + api.registerService(new MediaCallService()); // if the process is running in micro services mode we don't need to register services that will run separately if (!isRunningMs()) { diff --git a/packages/core-services/package.json b/packages/core-services/package.json index a1441b185c9d8..03a6de5d668d2 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -36,6 +36,8 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/icons": "^0.43.0", + "@rocket.chat/media-signaling": "workspace:^", + "@rocket.chat/media-signaling-server": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index faa8d230199cb..cd6d449c6ee96 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -39,6 +39,7 @@ import type { import type * as UiKit from '@rocket.chat/ui-kit'; import type { AutoUpdateRecord } from '../types/IMeteor'; +import { MediaSignal } from '@rocket.chat/media-signaling'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -140,6 +141,7 @@ export type EventSignatures = { ): void; 'user.deleteCustomStatus'(userStatus: Omit): void; 'user.forceLogout': (uid: string) => void; + 'user.media-signal'(data: { userId: IUser['_id']; signal: MediaSignal }): void; 'user.nameChanged'(user: Pick): void; 'user.realNameChanged'(user: Partial): void; 'user.roleUpdate'(update: { diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index de2b5f4291e74..e6d0d00998830 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -14,6 +14,7 @@ import type { IFederationService, IFederationServiceEE } from './types/IFederati import type { IImportService } from './types/IImportService'; import type { ILDAPService } from './types/ILDAPService'; import type { ILicense } from './types/ILicense'; +import type { IMediaCallService } from './types/IMediaCallService'; import type { IMediaService, ResizeResult } from './types/IMediaService'; import type { IMessageReadsService } from './types/IMessageReadsService'; import type { IMessageService } from './types/IMessageService'; @@ -104,6 +105,7 @@ export { IOmnichannelVoipService, IPresence, IPushService, + IMediaCallService, IMessageReadsService, IRoomService, ISAUMonitorService, @@ -161,6 +163,7 @@ export const Team = proxify('team'); export const MessageReads = proxify('message-reads'); export const Room = proxify('room'); export const Media = proxify('media'); +export const MediaCall = proxify('media-call'); export const VoipAsterisk = proxify('voip-asterisk'); export const VoipFreeSwitch = proxify('voip-freeswitch'); export const LivechatVoip = proxify('omnichannel-voip'); diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts new file mode 100644 index 0000000000000..b4ceaf6a1c40e --- /dev/null +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -0,0 +1,8 @@ +import type { IMediaCall, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +export interface IMediaCallService { + processSignal(signal: MediaSignal, fromUid: IUser['_id']): Promise; + createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise; + callRoom(caller: { uid: IUser['_id']; sessionId: string }, roomId: IRoom['_id']): Promise; +} diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 7d8a2ac96784f..4ca02105e893e 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -26,6 +26,7 @@ import type { IWebdavAccount, MessageAttachment, } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; import type * as UiKit from '@rocket.chat/ui-kit'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -194,6 +195,7 @@ export interface StreamerEvents { key: `${string}/video-conference`; args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }]; }, + { key: `${string}/media-signal`; args: [MediaSignal] }, { key: `${string}/userData`; args: [IUserDataEvent] }, { key: `${string}/updateInvites`; args: [unknown] }, { key: `${string}/departmentAgentData`; args: [unknown] }, diff --git a/packages/media-signaling-server/package.json b/packages/media-signaling-server/package.json index fbe9c17a70a74..b8a2f433cdc40 100644 --- a/packages/media-signaling-server/package.json +++ b/packages/media-signaling-server/package.json @@ -25,6 +25,7 @@ ], "dependencies": { "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/logger": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/models": "workspace:^" } diff --git a/packages/media-signaling-server/src/calls/createCall.ts b/packages/media-signaling-server/src/calls/createCall.ts index 505b211417fd0..ad640400cc6bb 100644 --- a/packages/media-signaling-server/src/calls/createCall.ts +++ b/packages/media-signaling-server/src/calls/createCall.ts @@ -3,7 +3,7 @@ import { MediaCalls, Users } from '@rocket.chat/models'; import { createInitialChannel } from '../channels/createInitialChannel'; -export async function createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { +export async function createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { if (caller.type !== 'user' || callee.type !== 'user') { throw new Error('not-implemented'); } @@ -40,12 +40,20 @@ export async function createCall(caller: MediaCallActor, callee: MediaCallActor) }; const insertResult = await MediaCalls.insertOne(call); - if (insertResult.insertedId) { - await Promise.allSettled([ - createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), - createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), - ]); + if (!insertResult.insertedId) { + throw new Error('failed-to-create-call'); } - console.log(insertResult); + await Promise.allSettled([ + createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), + createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), + ]); + + const insertedCall = await MediaCalls.findOneById(insertResult.insertedId); + + if (!insertedCall) { + throw new Error('failed-to-create-call'); + } + + return insertedCall; } diff --git a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts index dc3e1f1d795ae..a8244f25f09f0 100644 --- a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts +++ b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts @@ -12,7 +12,8 @@ export async function deliverChannelSDP(channel: IMediaCallChannel, params: Deli // If the sdp is an offer, send an answer request, otherwise simply deliver it if (params.sdp.type === 'offer') { - await sendSignalToChannel(call.sequence, channel, { + await sendSignalToChannel(channel, { + sequence: call.sequence, type: 'request', body: { request: 'answer', @@ -20,7 +21,8 @@ export async function deliverChannelSDP(channel: IMediaCallChannel, params: Deli }, }); } else { - await sendSignalToChannel(call.sequence, channel, { + await sendSignalToChannel(channel, { + sequence: call.sequence, type: 'deliver', body: { deliver: 'sdp', diff --git a/packages/media-signaling-server/src/channels/requestChannelOffer.ts b/packages/media-signaling-server/src/channels/requestChannelOffer.ts index d4290bba31d3f..a4c18cae7187e 100644 --- a/packages/media-signaling-server/src/channels/requestChannelOffer.ts +++ b/packages/media-signaling-server/src/channels/requestChannelOffer.ts @@ -10,7 +10,8 @@ export async function requestChannelOffer(channel: IMediaCallChannel, params?: R const call = await getNewCallSequence(channel.callId); - await sendSignalToChannel(call.sequence, channel, { + await sendSignalToChannel(channel, { + sequence: call.sequence, type: 'request', body: { request: 'offer', diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index 15cd7a1658fcf..7ac6c799b805e 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1,2 +1,4 @@ export { createCall } from './calls/createCall'; export { processSignal } from './signals/processSignal'; +export { setSignalHandler } from './signals/signalHandler'; +export { logger } from './utils/logger'; diff --git a/packages/media-signaling-server/src/signals/buildSignal.ts b/packages/media-signaling-server/src/signals/buildSignal.ts new file mode 100644 index 0000000000000..b66a30bcc66bc --- /dev/null +++ b/packages/media-signaling-server/src/signals/buildSignal.ts @@ -0,0 +1,11 @@ +import type { MediaSignal, MediaSignalBody, MediaSignalHeaderParams, MediaSignalType } from '@rocket.chat/media-signaling'; + +// This function's only purpose is to make the typecast safer; +// If all the params can be infered to the right types, then the signal can be typecasted. +export function buildSignal(header: MediaSignalHeaderParams, type: T, body: MediaSignalBody): MediaSignal { + return { + ...header, + type, + body, + } as MediaSignal; +} diff --git a/packages/media-signaling-server/src/signals/notify/accept.ts b/packages/media-signaling-server/src/signals/notify/accept.ts index 294604db332ca..52eaea83bb514 100644 --- a/packages/media-signaling-server/src/signals/notify/accept.ts +++ b/packages/media-signaling-server/src/signals/notify/accept.ts @@ -23,7 +23,8 @@ export async function processAccept(_signal: MediaSignalNotify<'accept'>, call: } const newSequence = await getNewCallSequence(channel.callId); - await sendSignalToChannel(newSequence.sequence, channel, { + await sendSignalToChannel(channel, { + sequence: newSequence.sequence, type: 'notify', body: { notify: 'state', @@ -33,7 +34,8 @@ export async function processAccept(_signal: MediaSignalNotify<'accept'>, call: const otherChannel = await getOppositeChannel(call, channel); if (otherChannel && isValidSignalChannel(otherChannel)) { - await sendSignalToChannel(newSequence.sequence, otherChannel, { + await sendSignalToChannel(otherChannel, { + sequence: newSequence.sequence, type: 'notify', body: { notify: 'state', diff --git a/packages/media-signaling-server/src/signals/sendSignal.ts b/packages/media-signaling-server/src/signals/sendSignal.ts deleted file mode 100644 index 7ace6cef60449..0000000000000 --- a/packages/media-signaling-server/src/signals/sendSignal.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MediaSignal } from '@rocket.chat/media-signaling'; - -export async function sendSignal(signal: MediaSignal): Promise { - console.log('sendSignal', signal); -} diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts index ca2f6bab11641..fb2b44905eda1 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts @@ -1,25 +1,32 @@ import type { ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; +import { buildSignal } from './buildSignal'; import { isValidSignalRole } from './isValidSignalRole'; -import { sendSignal } from './sendSignal'; +import { sendSignal } from './signalHandler'; +import { logger } from '../utils/logger'; -export async function sendSignalToChannel( - sequence: number, +export async function sendSignalToChannel( channel: ValidSignalChannel, - signal: Omit, + signal: { sequence: number; type: T; body: MediaSignalBody }, ): Promise { if (!isValidSignalRole(channel.role)) { - // Tried to send a signal to a channel that is neither the caller nor the callee... what to do? + logger.info({ msg: 'tried to send a signal to a channel with invalid role', channel, signal }); return; } - await sendSignal({ - ...signal, - role: channel.role, - sessionId: channel.participant.sessionId, - version: 1, - sequence, - callId: channel.callId, - } as MediaSignal); + const fullSignal = buildSignal( + { + role: channel.role, + sessionId: channel.participant.sessionId, + version: 1, + sequence: signal.sequence, + callId: channel.callId, + }, + signal.type, + signal.body, + ); + + logger.debug({ msg: 'sending signal to channel', channel, signal }); + await sendSignal(channel.participant.id, fullSignal); } diff --git a/packages/media-signaling-server/src/signals/signalHandler.ts b/packages/media-signaling-server/src/signals/signalHandler.ts new file mode 100644 index 0000000000000..9010f422814fd --- /dev/null +++ b/packages/media-signaling-server/src/signals/signalHandler.ts @@ -0,0 +1,20 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +export type SignalHandler = (uid: IUser['_id'], signal: MediaSignal) => void; + +let handler: SignalHandler | null = null; + +export function setSignalHandler(handlerFn: SignalHandler): void { + handler = handlerFn; +} + +export async function sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { + console.log('sendSignal', toUid, signal); + + if (!handler) { + throw new Error('media-signaling-server-not-configured'); + } + + return handler(toUid, signal); +} diff --git a/packages/media-signaling-server/src/utils/logger.ts b/packages/media-signaling-server/src/utils/logger.ts new file mode 100644 index 0000000000000..8cdcd64a7d2df --- /dev/null +++ b/packages/media-signaling-server/src/utils/logger.ts @@ -0,0 +1,6 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('MediaSignalingServer'); + +// #ToDo: Remove +logger.level('debug'); diff --git a/packages/media-signaling-server/tsconfig.json b/packages/media-signaling-server/tsconfig.json index 07f38f2e097a3..552c12b4d9522 100644 --- a/packages/media-signaling-server/tsconfig.json +++ b/packages/media-signaling-server/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@rocket.chat/tsconfig/server.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "declaration": true, }, "include": ["./src/**/*"], } diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 3d074f437b1b1..f014df8c57040 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -25,5 +25,8 @@ ], "dependencies": { "@rocket.chat/emitter": "~0.31.25" + }, + "devDependencies": { + "ajv": "^8.17.1" } } diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/MediaSignal.ts index 8bb2fa9e8125b..c106d9d3cf16e 100644 --- a/packages/media-signaling/src/definition/MediaSignal.ts +++ b/packages/media-signaling/src/definition/MediaSignal.ts @@ -1,7 +1,27 @@ +import type { JSONSchemaType } from 'ajv'; + +import { mediaSignalDeliverSchema } from './MediaSignalDeliver'; import type { MediaSignalDeliver } from './MediaSignalDeliver'; +import { mediaSignalNotifySchema } from './MediaSignalNotify'; import type { MediaSignalNotify } from './MediaSignalNotify'; +import { mediaSignalRequestSchema } from './MediaSignalRequest'; import type { MediaSignalRequest } from './MediaSignalRequest'; -export type MediaSignal = MediaSignalRequest | MediaSignalDeliver | MediaSignalNotify; +export type MediaSignalMap = { + request: MediaSignalRequest; + deliver: MediaSignalDeliver; + notify: MediaSignalNotify; +}; + +export type MediaSignalType = keyof MediaSignalMap; + +export type MediaSignal = MediaSignalMap[T]; + +export type ClientMediaSignal = Omit; + +export const mediaSignalSchema: JSONSchemaType = { + type: 'object', + oneOf: [mediaSignalRequestSchema, mediaSignalDeliverSchema, mediaSignalNotifySchema], +}; -export type ClientMediaSignal = Omit; +export type MediaSignalBody = MediaSignal['body']; diff --git a/packages/media-signaling/src/definition/MediaSignalDeliver.ts b/packages/media-signaling/src/definition/MediaSignalDeliver.ts index 7590afd48c20a..a3c96e167d9eb 100644 --- a/packages/media-signaling/src/definition/MediaSignalDeliver.ts +++ b/packages/media-signaling/src/definition/MediaSignalDeliver.ts @@ -1,4 +1,7 @@ -import type { MediaSignalHeader } from './MediaSignalHeader'; +import type { JSONSchemaType } from 'ajv'; + +import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; +import { iceCandidateSchema, sdpSchema } from './WebRTC'; export type DeliverSdpBody = { deliver: 'sdp'; @@ -6,18 +9,75 @@ export type DeliverSdpBody = { endOfCandidates: boolean; }; +export const deliverSdpBodySchema: JSONSchemaType = { + type: 'object', + properties: { + deliver: { + type: 'string', + pattern: 'sdp', + }, + sdp: sdpSchema, + endOfCandidates: { + type: 'boolean', + nullable: false, + }, + }, + required: ['deliver', 'sdp', 'endOfCandidates'], + additionalProperties: false, +}; + export type DeliverIceCandidatesBody = { deliver: 'ice-candidates'; candidates: RTCIceCandidateInit[]; endOfCandidates: boolean; }; +export const deliverIceCandidatesBodySchema: JSONSchemaType = { + type: 'object', + properties: { + deliver: { + type: 'string', + pattern: 'ice-candidates', + }, + candidates: { + type: 'array', + items: iceCandidateSchema, + }, + endOfCandidates: { + type: 'boolean', + nullable: false, + }, + }, + required: ['deliver', 'candidates', 'endOfCandidates'], + additionalProperties: false, +}; + export type DeliverDTMFBody = { deliver: 'dtmf'; tone: string; duration?: number; }; +export const deliverDTMFBodySchema: JSONSchemaType = { + type: 'object', + properties: { + deliver: { + type: 'string', + pattern: 'dtmf', + }, + tone: { + type: 'string', + nullable: false, + }, + duration: { + type: 'number', + nullable: true, + }, + }, + required: ['deliver', 'tone'], + additionalProperties: false, +}; + export type DeliverBodyMap = { 'sdp': DeliverSdpBody; 'ice-candidates': DeliverIceCandidatesBody; @@ -26,7 +86,7 @@ export type DeliverBodyMap = { export type DeliverType = keyof DeliverBodyMap; -export type DeliverBody = DeliverBodyMap[T]; +export type DeliverBody = DeliverBodyMap[T]; export type MediaSignalDeliver = MediaSignalHeader & { type: 'deliver'; @@ -34,3 +94,27 @@ export type MediaSignalDeliver = MediaSigna }; export type DeliverParams = Omit, 'deliver'>; + +export const mediaSignalDeliverSchema: JSONSchemaType = { + type: 'object', + allOf: [ + mediaSignalHeaderParamsSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + pattern: 'deliver', + }, + body: { + type: 'object', + oneOf: [deliverSdpBodySchema, deliverIceCandidatesBodySchema, deliverDTMFBodySchema], + additionalProperties: false, + }, + }, + required: ['type', 'body'], + additionalProperties: false, + }, + ], + required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], +}; diff --git a/packages/media-signaling/src/definition/MediaSignalHeader.ts b/packages/media-signaling/src/definition/MediaSignalHeader.ts index eac0bd9ef4c8e..95264ff3fd3ab 100644 --- a/packages/media-signaling/src/definition/MediaSignalHeader.ts +++ b/packages/media-signaling/src/definition/MediaSignalHeader.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + export type MediaSignalHeaderParams = { callId: string; sessionId: string; @@ -9,7 +11,62 @@ export type MediaSignalHeaderParams = { expectACK?: boolean; }; +export const mediaSignalHeaderParamsSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + sessionId: { + type: 'string', + nullable: false, + }, + version: { + type: 'number', + nullable: false, + }, + sequence: { + type: 'number', + nullable: false, + }, + role: { + type: 'string', + nullable: false, + }, + expectACK: { + type: 'boolean', + nullable: true, + }, + }, + required: ['callId', 'sessionId', 'version', 'sequence', 'role'], + additionalProperties: true, +}; + export type MediaSignalHeader = MediaSignalHeaderParams & { type: 'request' | 'deliver' | 'notify'; body: Record; }; + +export const mediaSignalHeaderSchema: JSONSchemaType = { + type: 'object', + allOf: [ + mediaSignalHeaderParamsSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + oneOf: ['request', 'deliver', 'notify'], + }, + body: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['type', 'body'], + additionalProperties: false, + }, + ], + required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], +}; diff --git a/packages/media-signaling/src/definition/MediaSignalNotify.ts b/packages/media-signaling/src/definition/MediaSignalNotify.ts index f7ca2fa64e53f..d38a74b4c01d5 100644 --- a/packages/media-signaling/src/definition/MediaSignalNotify.ts +++ b/packages/media-signaling/src/definition/MediaSignalNotify.ts @@ -1,20 +1,34 @@ -import type { MediaSignalHeader } from './MediaSignalHeader'; +import type { JSONSchemaType } from 'ajv'; + +import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; export type NotifyAckBody = { notify: 'ack'; }; -export type NotifyInvalidBody = { - notify: 'invalid'; +export const notifyAckBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'ack', + }, + }, + required: ['notify'], + additionalProperties: false, }; -export type NotifyUnableBody = { - notify: 'unable'; -}; +// export type NotifyInvalidBody = { +// notify: 'invalid'; +// }; -export type NotifyEmptyBody = { - notify: 'empty'; -}; +// export type NotifyUnableBody = { +// notify: 'unable'; +// }; + +// export type NotifyEmptyBody = { +// notify: 'empty'; +// }; export type NotifyErrorBody = { notify: 'error'; @@ -22,12 +36,52 @@ export type NotifyErrorBody = { errorText?: string; }; +export const notifyErrorBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'error', + }, + errorCode: { + type: 'string', + nullable: false, + }, + errorText: { + type: 'string', + nullable: true, + }, + }, + required: ['notify', 'errorCode'], + additionalProperties: false, +}; + export type NotifyNewBody = { notify: 'new'; service: 'webrtc'; kind: 'direct'; }; +export const notifyNewBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'new', + }, + service: { + type: 'string', + pattern: 'webrtc', + }, + kind: { + type: 'string', + pattern: 'direct', + }, + }, + required: ['notify', 'service', 'kind'], + additionalProperties: false, +}; + export type NotifyStateBody = { notify: 'state'; callState?: string; @@ -35,40 +89,169 @@ export type NotifyStateBody = { mediaState?: string; }; +export const notifyStateBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'state', + }, + callState: { + type: 'string', + nullable: true, + }, + serviceState: { + type: 'string', + nullable: true, + }, + mediaState: { + type: 'string', + nullable: true, + }, + }, + required: ['notify'], + additionalProperties: false, +}; + export type NotifyUnavailableBody = { notify: 'unavailable'; }; +export const notifyUnavailableBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'unavailable', + }, + }, + required: ['notify'], + additionalProperties: false, +}; + export type NotifyAcceptBody = { notify: 'accept'; }; +export const notifyAcceptBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'accept', + }, + }, + required: ['notify'], + additionalProperties: false, +}; + export type NotifyRejectBody = { notify: 'reject'; }; +export const notifyRejectBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'reject', + }, + }, + required: ['notify'], + additionalProperties: false, +}; + export type NotifyHangupBody = { notify: 'hangup'; reasonCode: string; reasonText?: string; }; +export const notifyHangupBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'hangup', + }, + reasonCode: { + type: 'string', + nullable: false, + }, + reasonText: { + type: 'string', + nullable: true, + }, + }, + required: ['notify', 'reasonCode'], + additionalProperties: false, +}; + export type NotifyNegotiationNeededBody = { notify: 'negotiation-needed'; reason?: string; }; +export const notifyNegotiationNeededBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'negotiation-needed', + }, + reason: { + type: 'string', + nullable: true, + }, + }, + required: ['notify'], + additionalProperties: false, +}; + export type NotifyMultiBody = { notify: 'multi'; notifications: NotifyBody>[]; }; +const notifySingleBodySchemas = [ + notifyAckBodySchema, + notifyNewBodySchema, + notifyErrorBodySchema, + notifyAcceptBodySchema, + notifyRejectBodySchema, + notifyHangupBodySchema, + notifyNegotiationNeededBodySchema, + notifyUnavailableBodySchema, + notifyStateBodySchema, +]; + +export const notifyMultiBodySchema: JSONSchemaType = { + type: 'object', + properties: { + notify: { + type: 'string', + pattern: 'multi', + }, + notifications: { + type: 'array', + items: { + type: 'object', + anyOf: notifySingleBodySchemas, + }, + }, + }, + required: ['notify'], + additionalProperties: false, +}; + +const notifyAnyBodySchema = [...notifySingleBodySchemas, notifyMultiBodySchema]; + export type NotifyBodyMap = { 'new': NotifyNewBody; 'ack': NotifyAckBody; - 'invalid': NotifyInvalidBody; - 'unable': NotifyUnableBody; - 'empty': NotifyEmptyBody; + // 'invalid': NotifyInvalidBody; + // 'unable': NotifyUnableBody; + // 'empty': NotifyEmptyBody; 'error': NotifyErrorBody; 'accept': NotifyAcceptBody; 'reject': NotifyRejectBody; @@ -81,7 +264,7 @@ export type NotifyBodyMap = { export type NotifyType = keyof NotifyBodyMap; -export type NotifyBody = NotifyBodyMap[T]; +export type NotifyBody = NotifyBodyMap[T]; export type MediaSignalNotify = MediaSignalHeader & { type: 'notify'; @@ -89,3 +272,27 @@ export type MediaSignalNotify = MediaSignalHe }; export type NotifyParams = Omit, 'notify'>; + +export const mediaSignalNotifySchema: JSONSchemaType = { + type: 'object', + allOf: [ + mediaSignalHeaderParamsSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + pattern: 'notify', + }, + body: { + type: 'object', + oneOf: notifyAnyBodySchema, + additionalProperties: false, + }, + }, + required: ['type', 'body'], + additionalProperties: false, + }, + ], + required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], +}; diff --git a/packages/media-signaling/src/definition/MediaSignalRequest.ts b/packages/media-signaling/src/definition/MediaSignalRequest.ts index a5811723f15a6..fda8cced7ebb5 100644 --- a/packages/media-signaling/src/definition/MediaSignalRequest.ts +++ b/packages/media-signaling/src/definition/MediaSignalRequest.ts @@ -1,26 +1,70 @@ -import type { MediaSignalHeader } from './MediaSignalHeader'; +import type { JSONSchemaType } from 'ajv'; + +import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; +import { sdpSchema } from './WebRTC'; export type RequestOfferBody = { request: 'offer'; iceRestart?: boolean; }; +export const requestOfferBodySchema: JSONSchemaType = { + type: 'object', + properties: { + request: { + type: 'string', + pattern: 'offer', + }, + iceRestart: { + type: 'boolean', + nullable: true, + }, + }, + required: ['request'], + additionalProperties: false, +}; + export type RequestAnswerBody = { request: 'answer'; offer: RTCSessionDescriptionInit; }; +export const requestAnswerBodySchema: JSONSchemaType = { + type: 'object', + properties: { + request: { + type: 'string', + pattern: 'answer', + }, + offer: sdpSchema, + }, + required: ['request'], + additionalProperties: false, +}; + export type RequestSdpBody = { request: 'sdp'; }; +export const requestSdpBodySchema: JSONSchemaType = { + type: 'object', + properties: { + request: { + type: 'string', + pattern: 'sdp', + }, + }, + required: ['request'], + additionalProperties: false, +}; + export type RequestBodyMap = { offer: RequestOfferBody; answer: RequestAnswerBody; sdp: RequestSdpBody; }; -export type RequestBody = RequestBodyMap[T]; +export type RequestBody = RequestBodyMap[T]; export type MediaSignalRequest = MediaSignalHeader & { type: 'request'; @@ -28,3 +72,27 @@ export type MediaSignalRequest = Omit, 'request'>; + +export const mediaSignalRequestSchema: JSONSchemaType = { + type: 'object', + allOf: [ + mediaSignalHeaderParamsSchema, + { + type: 'object', + properties: { + type: { + type: 'string', + pattern: 'request', + }, + body: { + type: 'object', + oneOf: [requestOfferBodySchema, requestAnswerBodySchema, requestSdpBodySchema], + additionalProperties: false, + }, + }, + required: ['type', 'body'], + additionalProperties: false, + }, + ], + required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], +}; diff --git a/packages/media-signaling/src/definition/WebRTC.ts b/packages/media-signaling/src/definition/WebRTC.ts new file mode 100644 index 0000000000000..6ed629d559480 --- /dev/null +++ b/packages/media-signaling/src/definition/WebRTC.ts @@ -0,0 +1,47 @@ +import type { JSONSchemaType } from 'ajv'; + +export type SDP = RTCSessionDescriptionInit; + +export const sdpSchema: JSONSchemaType = { + type: 'object', + properties: { + sdp: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + nullable: false, + required: ['type'], +}; + +export type IceCandidate = RTCIceCandidateInit; + +export const iceCandidateSchema: JSONSchemaType = { + type: 'object', + properties: { + candidate: { + type: 'string', + nullable: true, + }, + sdpMLineIndex: { + type: 'number', + nullable: true, + }, + sdpMid: { + type: 'string', + nullable: true, + }, + usernameFragment: { + type: 'string', + nullable: true, + }, + }, + additionalProperties: false, + nullable: false, + required: [], +}; diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 984cc07374f3e..b29a634b18a94 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -153,12 +153,12 @@ export class MediaSignalingSession extends Emitter { break; case 'ack': break; - case 'invalid': - break; - case 'unable': - break; - case 'empty': - break; + // case 'invalid': + // break; + // case 'unable': + // break; + // case 'empty': + // break; case 'state': break; case 'unavailable': diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 021bdfef61a92..baee81b3343cd 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -25,6 +25,7 @@ ], "dependencies": { "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/ui-kit": "workspace:~", "ajv": "^8.17.1", diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 2b1e2e8faaba0..a9614e579100e 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -30,6 +30,7 @@ import type { InvitesEndpoints } from './v1/invites'; import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MailerEndpoints } from './v1/mailer'; +import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MeEndpoints } from './v1/me'; import type { MiscEndpoints } from './v1/misc'; import type { ModerationEndpoints } from './v1/moderation'; @@ -101,6 +102,7 @@ export interface Endpoints ImportEndpoints, VoipFreeSwitchEndpoints, ServerEventsEndpoints, + MediaCallsEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -220,6 +222,7 @@ export * from './v1/roles'; export * from './v1/settings'; export * from './v1/teams'; export * from './v1/videoConference'; +export * from './v1/media-calls'; export * from './v1/assets'; export * from './v1/channels'; export * from './v1/customUserStatus'; diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts new file mode 100644 index 0000000000000..35fbd63982c71 --- /dev/null +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -0,0 +1,18 @@ +import { mediaSignalSchema, type MediaSignal } from '@rocket.chat/media-signaling'; +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type MediaCallsSignalProps = { signal: MediaSignal }; + +const mediaCallsSignalPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + signal: mediaSignalSchema, + }, + required: ['signal'], + additionalProperties: true, +}; + +export const isMediaCallsSignalProps = ajv.compile(mediaCallsSignalPropsSchema); diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts new file mode 100644 index 0000000000000..ecb2520d439d2 --- /dev/null +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts @@ -0,0 +1,24 @@ +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv(); + +export type MediaCallsStartProps = { roomId: string; sessionId: string }; + +const mediaCallsStartPropsSchema: JSONSchemaType = { + type: 'object', + properties: { + roomId: { + type: 'string', + nullable: false, + }, + sessionId: { + type: 'string', + nullable: false, + }, + }, + required: ['roomId', 'sessionId'], + additionalProperties: true, +}; + +export const isMediaCallsStartProps = ajv.compile(mediaCallsStartPropsSchema); diff --git a/packages/rest-typings/src/v1/media-calls/index.ts b/packages/rest-typings/src/v1/media-calls/index.ts new file mode 100644 index 0000000000000..cc1f92e322efb --- /dev/null +++ b/packages/rest-typings/src/v1/media-calls/index.ts @@ -0,0 +1,16 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +import type { MediaCallsSignalProps } from './MediaCallsSignalProps'; +import type { MediaCallsStartProps } from './MediaCallsStartProps'; + +export * from './MediaCallsSignalProps'; +export * from './MediaCallsStartProps'; + +export type MediaCallsEndpoints = { + '/v1/media-calls.start': { + POST: (params: MediaCallsStartProps) => { call: IMediaCall }; + }; + '/v1/media-calls.signal': { + POST: (params: MediaCallsSignalProps) => void; + }; +}; diff --git a/yarn.lock b/yarn.lock index 2fd726edbb1c5..cdee6b27572ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7107,6 +7107,8 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/media-signaling": "workspace:^" + "@rocket.chat/media-signaling-server": "workspace:^" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" @@ -7807,12 +7809,13 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/media-signaling-server@workspace:packages/media-signaling-server": +"@rocket.chat/media-signaling-server@workspace:^, @rocket.chat/media-signaling-server@workspace:packages/media-signaling-server": version: 0.0.0-use.local resolution: "@rocket.chat/media-signaling-server@workspace:packages/media-signaling-server" dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/logger": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" @@ -7828,12 +7831,7 @@ __metadata: resolution: "@rocket.chat/media-signaling@workspace:packages/media-signaling" dependencies: "@rocket.chat/emitter": "npm:~0.31.25" - "@rocket.chat/jest-presets": "workspace:~" - "@rocket.chat/tsconfig": "workspace:*" - "@types/jest": "npm:~30.0.0" - eslint: "npm:~8.45.0" - jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + ajv: "npm:^8.17.1" languageName: unknown linkType: soft @@ -7939,6 +7937,8 @@ __metadata: "@rocket.chat/log-format": "workspace:^" "@rocket.chat/logger": "workspace:^" "@rocket.chat/logo": "npm:^0.32.2" + "@rocket.chat/media-signaling": "workspace:^" + "@rocket.chat/media-signaling-server": "workspace:^" "@rocket.chat/memo": "npm:~0.31.25" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/mock-providers": "workspace:^" @@ -8766,6 +8766,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:~" + "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/ui-kit": "workspace:~" "@types/jest": "npm:~30.0.0" From 866d7d28a8c51f74a3ee6a2007762c3e314177a4 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 22 Jul 2025 19:04:51 -0300 Subject: [PATCH 07/72] dependencies --- packages/media-signaling/package.json | 4 +--- packages/media-signaling/src/lib/Session.ts | 11 ++--------- packages/ui-voip/package.json | 1 + yarn.lock | 7 +++++++ 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index f014df8c57040..307733fea96d4 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -6,6 +6,7 @@ "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", + "ajv": "^8.17.1", "eslint": "~8.45.0", "jest": "~30.0.5", "typescript": "~5.8.3" @@ -25,8 +26,5 @@ ], "dependencies": { "@rocket.chat/emitter": "~0.31.25" - }, - "devDependencies": { - "ajv": "^8.17.1" } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index b29a634b18a94..62ac514964ee4 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -1,3 +1,4 @@ +import type { DefaultEventMap } from '@rocket.chat/emitter'; import { Emitter } from '@rocket.chat/emitter'; import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; @@ -8,11 +9,7 @@ import type { MediaSignalRequest } from '../definition/MediaSignalRequest'; import { createRandomToken } from './utils/createRandomToken'; import type { MediaSignalHeaderParams } from '../definition/MediaSignalHeader'; -type MediaSignalingEvents = { - 'state/changed': void; -}; - -export class MediaSignalingSession extends Emitter { +export class MediaSignalingSession extends Emitter { private _sessionId: string; private _userId: string; @@ -50,10 +47,6 @@ export class MediaSignalingSession extends Emitter { return true; } - // private changeState() { - // this.emit('state/changed'); - // } - private async processRequest(signal: MediaSignalRequest) { if (!signal.sessionId) { throw new Error('Request signals MUST target a specific session.'); diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index 0083962895cf7..dde6865e16c3e 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@rocket.chat/emitter": "~0.31.25", + "@rocket.chat/media-signaling": "workspace:^", "@tanstack/react-query": "~5.65.1", "react-i18next": "~13.2.2", "sip.js": "^0.21.2" diff --git a/yarn.lock b/yarn.lock index cdee6b27572ea..552a0e5244fe2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7831,7 +7831,13 @@ __metadata: resolution: "@rocket.chat/media-signaling@workspace:packages/media-signaling" dependencies: "@rocket.chat/emitter": "npm:~0.31.25" + "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" ajv: "npm:^8.17.1" + eslint: "npm:~8.45.0" + jest: "npm:~30.0.5" + typescript: "npm:~5.8.3" languageName: unknown linkType: soft @@ -9253,6 +9259,7 @@ __metadata: "@rocket.chat/fuselage-tokens": "npm:~0.33.2" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/mock-providers": "workspace:~" "@rocket.chat/styled": "npm:~0.32.0" "@rocket.chat/tsconfig": "workspace:*" From e6fb9d949bf8227635ec04022654fe4d00dffcdb Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 22 Jul 2025 20:02:52 -0300 Subject: [PATCH 08/72] client call state --- packages/media-signaling/src/lib/Session.ts | 141 ++++++++++++++------ 1 file changed, 99 insertions(+), 42 deletions(-) diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 62ac514964ee4..0b3dd7dda0e1e 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -9,6 +9,14 @@ import type { MediaSignalRequest } from '../definition/MediaSignalRequest'; import { createRandomToken } from './utils/createRandomToken'; import type { MediaSignalHeaderParams } from '../definition/MediaSignalHeader'; +interface IClientMediaCall { + callId: string; + role: 'caller' | 'callee'; + state: 'none' | 'ringing' | 'accepted' | 'active' | 'ignored'; + + timeoutHandler?: ReturnType; +} + export class MediaSignalingSession extends Emitter { private _sessionId: string; @@ -21,6 +29,10 @@ export class MediaSignalingSession; + + private ignoredCalls: Set; + public get agentId(): string { return `${this._userId}-${this._sessionId}`; } @@ -35,10 +47,63 @@ export class MediaSignalingSession(); + this.ignoredCalls = new Set(); this._userId = userId; this._sessionId = createRandomToken(8); } + public isBusy(): boolean { + for (const call of this.knownCalls.values()) { + if (['ringing', 'accepted', 'active'].includes(call.state)) { + return true; + } + } + return false; + } + + public async processSignal(signal: MediaSignal) { + if (!this.isSignalForUs(signal)) { + return; + } + + if (this.isCallIgnored(signal.callId)) { + if (signal.sessionId) { + console.error('Received targeted signal for an ignored call.', signal); + } + + return; + } + + if (!this.isSignalExpected(signal)) { + console.error('Unexpected Signal', signal); + throw new Error('Unexpected Signal received.'); + } + + // if (!this.isCallKnown(signal.callId)) { + // const isNewNotify = signal.type === 'notify' && signal.body.notify === 'new'; + + // await this.processNewCall(signal, isNewNotify); + + // if (isNewNotify) { + // return; + // } + // } + + console.log(signal); + switch (signal.type) { + case 'request': + await this.processRequest(signal); + break; + case 'deliver': + await this.processDeliver(signal); + break; + case 'notify': + await this.processNotify(signal); + break; + } + } + private isSignalForUs(signal: MediaSignal): boolean { if (signal.sessionId) { return signal.sessionId === this._sessionId; @@ -49,7 +114,13 @@ export class MediaSignalingSession(requestSignal: MediaSignal, type: T, params: DeliverParams) { @@ -276,42 +371,4 @@ export class MediaSignalingSession Date: Wed, 23 Jul 2025 19:50:34 -0300 Subject: [PATCH 09/72] integrating --- .../client/providers/MeteorProvider.tsx | 6 +- packages/media-signaling/src/lib/Session.ts | 45 +- .../ui-voip/src/hooks/useMediaCallsClient.tsx | 49 ++ packages/ui-voip/src/index.ts | 1 + packages/ui-voip/src/lib/MediaCallsClient.ts | 447 ++++++++++++++++++ .../src/providers/MediaCallsProvider.tsx | 188 ++++++++ 6 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 packages/ui-voip/src/hooks/useMediaCallsClient.tsx create mode 100644 packages/ui-voip/src/lib/MediaCallsClient.ts create mode 100644 packages/ui-voip/src/providers/MediaCallsProvider.tsx diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index a8faabf0ff90f..961f5cb1c5437 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -1,5 +1,5 @@ import { ModalProvider } from '@rocket.chat/ui-client'; -import { VoipProvider } from '@rocket.chat/ui-voip'; +import { MediaCallsProvider } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import ActionManagerProvider from './ActionManagerProvider'; @@ -51,11 +51,11 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( - + {children} - + diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 0b3dd7dda0e1e..25014f3deda09 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -9,14 +9,23 @@ import type { MediaSignalRequest } from '../definition/MediaSignalRequest'; import { createRandomToken } from './utils/createRandomToken'; import type { MediaSignalHeaderParams } from '../definition/MediaSignalHeader'; -interface IClientMediaCall { +export interface IClientMediaCall { callId: string; role: 'caller' | 'callee'; - state: 'none' | 'ringing' | 'accepted' | 'active' | 'ignored'; + state: 'none' | 'ringing' | 'accepted' | 'active' | 'error'; + ignored?: boolean; timeoutHandler?: ReturnType; } +const stateWeights: Record = { + none: 0, + ringing: 1, + accepted: 2, + error: 3, + active: 4, +} as const; + export class MediaSignalingSession extends Emitter { private _sessionId: string; @@ -54,14 +63,42 @@ export class MediaSignalingSession state) + .toArray(); + } + + public hasAnyCallState(states: IClientMediaCall['state'][]): boolean { + const knownStates = this.getAllCallStates(); + for (const state of knownStates) { + if (states.includes(state)) { return true; } } return false; } + public getSortedCalls(): IClientMediaCall[] { + return this.knownCalls + .values() + .toArray() + .sort((call1, call2) => { + const call1Weight = stateWeights[call1.state] || 0; + const call2Weight = stateWeights[call2.state] || 0; + + return call2Weight - call1Weight; + }); + } + + public getMainCall(): IClientMediaCall | null { + return this.getSortedCalls().pop() || null; + } + public async processSignal(signal: MediaSignal) { if (!this.isSignalForUs(signal)) { return; diff --git a/packages/ui-voip/src/hooks/useMediaCallsClient.tsx b/packages/ui-voip/src/hooks/useMediaCallsClient.tsx new file mode 100644 index 0000000000000..3f1e05160bf13 --- /dev/null +++ b/packages/ui-voip/src/hooks/useMediaCallsClient.tsx @@ -0,0 +1,49 @@ +import { useUser } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; + +// import { useIceServers } from './useIceServers'; +import MediaCallsClient from '../lib/MediaCallsClient'; + +type MediaCallsClientParams = { + enabled?: boolean; +}; + +type MediaCallsClientResult = { + mediaCallsClient: MediaCallsClient | null; + error: Error | null; +}; + +export const useMediaCallsClient = ({ enabled = true }: MediaCallsClientParams = {}): MediaCallsClientResult => { + const { _id: userId } = useUser() || {}; + const mediaCallsClientRef = useRef(null); + + // const iceServers = useIceServers(); + + const { data: mediaCallsClient, error } = useQuery({ + queryKey: ['media-calls-client', enabled, userId], + queryFn: async () => { + if (mediaCallsClientRef.current) { + mediaCallsClientRef.current.clear(); + } + + if (!userId) { + throw Error('error-user-not-found'); + } + + const mediaCallsClient = await MediaCallsClient.create(userId); + + return mediaCallsClient; + }, + initialData: null, + enabled, + }); + + useEffect(() => { + mediaCallsClientRef.current = mediaCallsClient; + + return () => mediaCallsClientRef.current?.clear(); + }, [mediaCallsClient]); + + return { mediaCallsClient, error }; +}; diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index ebf30176a7256..e3818972df05e 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -1,4 +1,5 @@ export { default as VoipProvider } from './providers/VoipProvider'; +export { default as MediaCallsProvider } from './providers/MediaCallsProvider'; export * from './definitions/VoipSession'; export * from './hooks'; export * from './components'; diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts new file mode 100644 index 0000000000000..eee8e1701b7c5 --- /dev/null +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -0,0 +1,447 @@ +import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { MediaSignalingSession } from '@rocket.chat/media-signaling'; + +import type { ContactInfo, VoipSession } from '../definitions'; +import RemoteStream from './RemoteStream'; + +export type VoipEvents = Omit & { + callestablished: ContactInfo; + incomingcall: ContactInfo; + outgoingcall: ContactInfo; + dialer: { open: boolean }; + incomingcallerror: string; +}; + +type SessionError = { + status: number | undefined; + reason: string; + contact: ContactInfo; +}; + +class MediaCallsClient extends MediaSignalingSession { + public networkEmitter: Emitter; + + private audioElement: HTMLAudioElement | null = null; + + private remoteStream: RemoteStream | undefined; + + private held = false; + + private muted = false; + + private online = true; + + private error: SessionError | null = null; + + private contactInfo: ContactInfo | null = null; + + private reconnecting = false; + + constructor(userId: IUser['_id']) { + super(userId); + + this.networkEmitter = new Emitter(); + } + + public async init() { + window.addEventListener('online', this.onNetworkRestored); + window.addEventListener('offline', this.onNetworkLost); + } + + static async create(userId: IUser['_id']): Promise { + const mediaCalls = new MediaCallsClient(userId); + await mediaCalls.init(); + return mediaCalls; + } + + public async call(calleeURI: string): Promise { + if (!calleeURI) { + throw new Error('Invalid URI'); + } + + if (this.isBusy()) { + throw new Error('Session already exists'); + } + + // #ToDo: Change calleeURI + await this.startCall(calleeURI); + + // this.setContactInfo({ + // id: remoteIdentity.uri.user ?? '', + // name: remoteIdentity.displayName, + // host: remoteIdentity.uri.host, + // }); + + this.emit('stateChanged'); + } + + public async transfer(calleeURI: string): Promise { + if (!calleeURI) { + throw new Error('Invalid URI'); + } + + if (!this.isBusy()) { + throw new Error('No active call'); + } + + // #ToDo + } + + public async answer(): Promise { + // + } + + public async reject(): Promise { + // + } + + public async endCall(): Promise { + // + } + + public async setMute(_mute: boolean): Promise { + // + } + + public async setHold(_hold: boolean): Promise { + // + } + + public async sendDTMF(_tone: string): Promise { + // + } + + public async changeAudioInputDevice(_constraints: MediaStreamConstraints): Promise { + return true; + // if (!this.isBusy()) { + // console.warn('changeAudioInputDevice() : No session.'); + // return false; + // } + // const newStream = await LocalStream.requestNewStream(constraints, this.session); + // if (!newStream) { + // console.warn('changeAudioInputDevice() : Unable to get local stream.'); + // return false; + // } + // const { peerConnection } = this.sessionDescriptionHandler; + // if (!peerConnection) { + // console.warn('changeAudioInputDevice() : No peer connection.'); + // return false; + // } + // LocalStream.replaceTrack(peerConnection, newStream, 'audio'); + // return true; + } + + public switchAudioElement(audioElement: HTMLAudioElement | null): void { + this.audioElement = audioElement; + + if (this.remoteStream) { + this.playRemoteStream(); + } + } + + public getContactInfo() { + if (this.error) { + return this.error.contact; + } + + if (!this.isBusy()) { + return null; + } + + return this.contactInfo; + } + + public getReferredBy() { + return null; + // if (!(this.session instanceof Invitation)) { + // return null; + // } + // + // const referredBy = this.session.request.getHeader('Referred-By'); + // + // if (!referredBy) { + // return null; + // } + // + // const uri = UserAgent.makeURI(referredBy.slice(1, -1)); + // + // if (!uri) { + // return null; + // } + // + // return { + // id: uri.user ?? '', + // host: uri.host, + // }; + } + + public isRegistered(): boolean { + return true; + } + + public isReady(): boolean { + return true; + } + + public isCaller(): boolean { + return this.getMainCall()?.role === 'caller'; + } + + public isCallee(): boolean { + return this.getMainCall()?.role === 'callee'; + } + + public isIncoming(): boolean { + return this.getSessionType() === 'INCOMING'; + } + + public isOngoing(): boolean { + return this.getSessionType() === 'ONGOING'; + } + + public isOutgoing(): boolean { + return this.getSessionType() === 'OUTGOING'; + } + + public isInCall(): boolean { + return this.getSessionType() !== null; + } + + public isError(): boolean { + return !!this.error; + } + + public isReconnecting(): boolean { + return this.reconnecting; + } + + public isOnline(): boolean { + return this.online; + } + + public isMuted(): boolean { + return this.muted; + } + + public isHeld(): boolean { + return this.held; + } + + public getError() { + return this.error ?? null; + } + + public clearErrors = (): void => { + this.setError(null); + }; + + public getSessionType(): VoipSession['type'] | null { + const call = this.getMainCall(); + if (!call) { + return null; + } + + if (call.state === 'error') { + return 'ERROR'; + } + if (call.state === 'active') { + return 'ONGOING'; + } + if (call.role === 'callee') { + return 'INCOMING'; + } + if (call.role === 'caller') { + return 'OUTGOING'; + } + + return null; + } + + public getSession(): VoipSession | null { + const type = this.getSessionType(); + + switch (type) { + case 'ERROR': { + const { contact, ...error } = this.getError() as SessionError; + return { + type: 'ERROR', + error, + contact, + end: this.clearErrors, + }; + } + case 'INCOMING': + case 'ONGOING': + case 'OUTGOING': + return { + type, + contact: this.getContactInfo() as ContactInfo, + transferedBy: this.getReferredBy(), + isMuted: this.isMuted(), + isHeld: this.isHeld(), + mute: this.setMute, + hold: this.setHold, + accept: this.answer, + end: this.endCall, + dtmf: this.sendDTMF, + }; + default: + return null; + } + } + + public getState() { + return { + isRegistered: this.isRegistered(), + isReady: this.isReady(), + isOnline: this.isOnline(), + isIncoming: this.isIncoming(), + isOngoing: this.isOngoing(), + isOutgoing: this.isOutgoing(), + isInCall: this.isBusy(), + isError: this.isError(), + isReconnecting: this.isReconnecting(), + }; + } + + public getAudioElement(): HTMLAudioElement | null { + return this.audioElement; + } + + public notifyDialer(value: { open: boolean }) { + this.emit('dialer', value); + } + + public clear(): void { + window.removeEventListener('online', this.onNetworkRestored); + window.removeEventListener('offline', this.onNetworkLost); + } + + // private setContactInfo(contact: ContactInfo) { + // this.contactInfo = contact; + // this.emit('stateChanged'); + // } + + private async startCall(_roomId: IRoom['_id']): Promise { + // #ToDo: call 'media-calls.start' + } + + // private setupRemoteMedia() { + // // const { remoteMediaStream } = this.sessionDescriptionHandler; + + // // this.remoteStream = new RemoteStream(remoteMediaStream); + // this.playRemoteStream(); + // } + + private playRemoteStream() { + if (!this.remoteStream) { + console.warn(`Attempted to play missing remote media.`); + return; + } + + if (!this.audioElement) { + console.error('Unable to play remote media: VoIPClient is missing an AudioElement reference to play it on.'); + return; + } + + this.remoteStream.init(this.audioElement); + this.remoteStream.play(); + } + + // private toggleMediaStreamTracks(type: 'sender' | 'receiver', enable: boolean): void { + // console.log('toggleMediaStreamTracks', type, enable); + // // const { peerConnection } = this.sessionDescriptionHandler; + + // // if (!peerConnection) { + // // throw new Error('Peer connection closed.'); + // // } + + // // const tracks = type === 'sender' ? peerConnection.getSenders() : peerConnection.getReceivers(); + + // // tracks?.forEach((sender) => { + // // if (sender.track) { + // // sender.track.enabled = enable; + // // } + // // }); + // } + + // private onInvitationCancel(): void { + // // const reason = getMainInviteRejectionReason(invitation, message); + // // if (reason) { + // // this.emit('incomingcallerror', reason); + // // } + // } + + // private async onIncomingCall(): Promise { + // // #ToDo: incoming call + + // this.emit('incomingcall', this.getContactInfo() as ContactInfo); + // this.emit('stateChanged'); + // } + + // private async onTransferedCall() { + // // await referral.accept(); + // // this.sendInvite(referral.makeInviter()); + // } + + // private async onMessageReceived(): Promise { + // // #ToDo: contact update messages + // } + + // private onSessionStablishing(): void { + // this.emit('outgoingcall', this.getContactInfo() as ContactInfo); + // } + + // private onSessionStablished(): void { + // this.setupRemoteMedia(); + // this.emit('callestablished', this.getContactInfo() as ContactInfo); + // this.emit('stateChanged'); + // } + + // private onInviteRejected(response: IncomingResponse): void { + // const { statusCode, reasonPhrase, to } = response.message; + + // if (!reasonPhrase || statusCode === 487) { + // return; + // } + + // this.setError({ + // status: statusCode, + // reason: reasonPhrase, + // contact: { id: to.uri.user ?? '', host: to.uri.host }, + // }); + + // this.emit('callfailed', response.message.reasonPhrase || 'unknown'); + // } + + // private onSessionTerminated(): void { + // this.muted = false; + // this.held = false; + // this.remoteStream?.clear(); + // this.emit('callterminated'); + // this.emit('stateChanged'); + // } + + private onNetworkRestored(): void { + this.online = true; + this.networkEmitter.emit('localnetworkonline'); + this.emit('stateChanged'); + + // this.attemptReconnection(); + } + + private onNetworkLost(): void { + this.online = false; + this.networkEmitter.emit('localnetworkoffline'); + this.emit('stateChanged'); + } + + private setError(error: SessionError | null) { + console.error(error); + this.error = error; + this.emit('stateChanged'); + } +} + +export default MediaCallsClient; diff --git a/packages/ui-voip/src/providers/MediaCallsProvider.tsx b/packages/ui-voip/src/providers/MediaCallsProvider.tsx new file mode 100644 index 0000000000000..ba66afb9da43f --- /dev/null +++ b/packages/ui-voip/src/providers/MediaCallsProvider.tsx @@ -0,0 +1,188 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { Device } from '@rocket.chat/ui-contexts'; +import { + useCustomSound, + usePermission, + useSetInputMediaDevice, + useSetOutputMediaDevice, + useSetting, + useToastMessageDispatch, +} from '@rocket.chat/ui-contexts'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; + +import { VoipPopupDraggable } from '../components'; +import VoipPopupPortal from '../components/VoipPopupPortal'; +import type { VoipContextValue } from '../contexts/VoipContext'; +import { VoipContext } from '../contexts/VoipContext'; +import { useMediaCallsClient } from '../hooks/useMediaCallsClient'; + +const MediaCallsProvider = ({ children }: { children: ReactNode }) => { + // Settings + const isVoipSettingEnabled = useSetting('VoIP_TeamCollab_Enabled', false); + const canViewVoipRegistrationInfo = usePermission('view-user-voip-extension'); + const isVoipEnabled = isVoipSettingEnabled && canViewVoipRegistrationInfo; + + // Hooks + const { t } = useTranslation(); + const { voipSounds } = useCustomSound(); + const { mediaCallsClient, error } = useMediaCallsClient({ + enabled: isVoipEnabled, + }); + const setOutputMediaDevice = useSetOutputMediaDevice(); + const setInputMediaDevice = useSetInputMediaDevice(); + const dispatchToastMessage = useToastMessageDispatch(); + + // Refs + const remoteAudioMediaRef = useCallback( + (node: HTMLMediaElement | null) => { + mediaCallsClient?.switchAudioElement(node); + }, + [mediaCallsClient], + ); + + useEffect(() => { + if (!mediaCallsClient) { + return; + } + + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = true; + }; + + const onCallEstablished = async (): Promise => { + voipSounds.stopDialer(); + voipSounds.stopRinger(); + window.addEventListener('beforeunload', onBeforeUnload); + }; + + const onNetworkDisconnected = (): void => { + if (mediaCallsClient.isOngoing()) { + mediaCallsClient.endCall(); + } + }; + + const onOutgoingCallRinging = (): void => { + // mediaCallsClient 'outgoingcall' event is emitted when the call is establishing + // and that event is also emitted when the call is accepted + // to avoid disrupting the mediaCallsClient flow, we check if the call is outgoing here. + if (mediaCallsClient.isOutgoing()) { + voipSounds.playDialer(); + } + }; + + const onIncomingCallRinging = (): void => { + voipSounds.playRinger(); + }; + + const onCallTerminated = (): void => { + voipSounds.playCallEnded(); + voipSounds.stopDialer(); + voipSounds.stopRinger(); + window.removeEventListener('beforeunload', onBeforeUnload); + }; + + const onIncomingCallError = (reason: string) => { + console.error('incoming call canceled', reason); + if (reason === 'USER_NOT_REGISTERED') { + dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_user_not_registered') }); + return; + } + + dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_suddenly') }); + }; + + mediaCallsClient.on('incomingcall', onIncomingCallRinging); + mediaCallsClient.on('outgoingcall', onOutgoingCallRinging); + mediaCallsClient.on('callestablished', onCallEstablished); + mediaCallsClient.on('callterminated', onCallTerminated); + mediaCallsClient.on('incomingcallerror', onIncomingCallError); + mediaCallsClient.networkEmitter.on('disconnected', onNetworkDisconnected); + mediaCallsClient.networkEmitter.on('connectionerror', onNetworkDisconnected); + mediaCallsClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected); + + return (): void => { + voipSounds.stopCallEnded(); + mediaCallsClient.off('incomingcall', onIncomingCallRinging); + mediaCallsClient.off('outgoingcall', onOutgoingCallRinging); + mediaCallsClient.off('callestablished', onCallEstablished); + mediaCallsClient.off('callterminated', onCallTerminated); + mediaCallsClient.off('incomingcallerror', onIncomingCallError); + mediaCallsClient.networkEmitter.off('disconnected', onNetworkDisconnected); + mediaCallsClient.networkEmitter.off('connectionerror', onNetworkDisconnected); + mediaCallsClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected); + window.removeEventListener('beforeunload', onBeforeUnload); + }; + }, [dispatchToastMessage, t, mediaCallsClient, voipSounds]); + + const changeAudioOutputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { + const element = mediaCallsClient?.getAudioElement(); + if (!element) { + console.warn(`Failed to change audio output device: missing audio element reference.`); + return; + } + + setOutputMediaDevice({ outputDevice: selectedAudioDevice, HTMLAudioElement: element }); + }); + + const changeAudioInputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { + if (!mediaCallsClient) { + return; + } + + await mediaCallsClient.changeAudioInputDevice({ audio: { deviceId: { exact: selectedAudioDevice.id } } }); + setInputMediaDevice(selectedAudioDevice); + }); + + const contextValue = useMemo(() => { + if (!isVoipEnabled) { + return { + isEnabled: false, + voipClient: null, + error: null, + changeAudioInputDevice, + changeAudioOutputDevice, + }; + } + + if (!mediaCallsClient || error) { + return { + isEnabled: true, + voipClient: null, + error, + changeAudioInputDevice, + changeAudioOutputDevice, + }; + } + + return { + isEnabled: true, + voipClient: mediaCallsClient as any, + + changeAudioInputDevice, + changeAudioOutputDevice, + }; + }, [mediaCallsClient, isVoipEnabled, error, changeAudioInputDevice, changeAudioOutputDevice]); + + return ( + + {children} + {contextValue.isEnabled && + createPortal( + , + document.body, + )} + + + + + + ); +}; + +export default MediaCallsProvider; From e9938bd7b46d2eb5c2638ae293102d6748384f14 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 24 Jul 2025 22:08:46 -0300 Subject: [PATCH 10/72] integrating --- apps/meteor/app/api/server/index.ts | 1 + .../roomActions/useVoiceCallRoomAction.tsx | 4 +- .../actions/useVoipCallAction.tsx | 4 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 12 +- .../src/mediaCalls/IMediaCallChannel.ts | 2 + .../src/calls/createCall.ts | 28 ++--- .../src/signals/getChannelForSignal.ts | 6 +- .../src/signals/processSignal.ts | 4 +- .../src/signals/sendNewSignals.ts | 38 ++++++ .../src/signals/sendSignalToActor.ts | 30 +++++ .../src/utils/assertSignalHasSessionId.ts | 9 ++ .../src/utils/mapUserToContactInformation.ts | 13 +++ .../src/definition/MediaSignalHeader.ts | 6 +- packages/media-signaling/src/lib/Session.ts | 109 +++++++++++++++--- packages/models/src/models/MediaCalls.ts | 2 +- .../VoipPopup/views/VoipDialerView.tsx | 2 +- .../ui-voip/src/hooks/useMediaCallsClient.tsx | 47 +++++++- packages/ui-voip/src/hooks/useVoipAPI.tsx | 35 +++++- packages/ui-voip/src/lib/MediaCallsClient.ts | 105 ++++++++++++----- 19 files changed, 381 insertions(+), 76 deletions(-) create mode 100644 packages/media-signaling-server/src/signals/sendNewSignals.ts create mode 100644 packages/media-signaling-server/src/signals/sendSignalToActor.ts create mode 100644 packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts create mode 100644 packages/media-signaling-server/src/utils/mapUserToContactInformation.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 74b9599792b17..33e3723ff2bd1 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -49,6 +49,7 @@ import './v1/voip/omnichannel'; import './v1/voip'; import './v1/federation'; import './v1/moderation'; +import './v1/mediacalls'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx index c858a591387a7..a069695607115 100644 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx @@ -12,7 +12,7 @@ import { useVoipWarningModal } from '../useVoipWarningModal'; export const useVoiceCallRoomAction = () => { const { t } = useTranslation(); - const { uids = [] } = useRoom(); + const { _id: rid, uids = [] } = useRoom(); const ownUserId = useUserId(); const canStartVoiceCall = usePermission('view-user-voip-extension'); const dispatchWarning = useVoipWarningModal(); @@ -48,7 +48,7 @@ export const useVoiceCallRoomAction = () => { const handleOnClick = useEffectEvent(() => { if (canMakeVoipCall) { - return makeCall(remoteUser?.freeSwitchExtension as string); + return makeCall({ rid, extension: remoteUser?.freeSwitchExtension }); } dispatchWarning(); }); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx index 9684c2065d296..a14e111c3be57 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx @@ -24,7 +24,7 @@ export const useVoipCallAction = (user: Pick(() => { const handleClick = () => { - makeCall(user?.freeSwitchExtension as string); + makeCall({ uid: user?._id, extension: user?.freeSwitchExtension }); closeUserCard(); }; @@ -37,7 +37,7 @@ export const useVoipCallAction = (user: Pick { if (caller.type !== 'user' || callee.type !== 'user') { @@ -11,16 +13,16 @@ export async function createCall(caller: MediaCallActor, callee: MediaCallActor) throw new Error('not-implemented'); } - const callerUser = await Users.findOneById>(caller.id, { - projection: { username: 1, name: 1 }, + const callerUser = await Users.findOneById>(caller.id, { + projection: { username: 1, name: 1, freeSwitchExtension: 1 }, }); if (!callerUser?.username) { throw new Error('invalid-caller'); } - const calleeUser = await Users.findOneById>(callee.id, { - projection: { username: 1, name: 1 }, + const calleeUser = await Users.findOneById>(callee.id, { + projection: { username: 1, name: 1, freeSwitchExtension: 1 }, }); if (!calleeUser?.username) { throw new Error('invalid-callee'); @@ -35,8 +37,14 @@ export async function createCall(caller: MediaCallActor, callee: MediaCallActor) createdAt: new Date(), sequence: 0, - caller, - callee, + caller: { + ...caller, + ...mapUserToContactInformation(callerUser), + }, + callee: { + ...callee, + ...mapUserToContactInformation(calleeUser), + }, }; const insertResult = await MediaCalls.insertOne(call); @@ -49,11 +57,5 @@ export async function createCall(caller: MediaCallActor, callee: MediaCallActor) createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), ]); - const insertedCall = await MediaCalls.findOneById(insertResult.insertedId); - - if (!insertedCall) { - throw new Error('failed-to-create-call'); - } - - return insertedCall; + return sendNewSignals(insertResult.insertedId); } diff --git a/packages/media-signaling-server/src/signals/getChannelForSignal.ts b/packages/media-signaling-server/src/signals/getChannelForSignal.ts index 0a50cc0372530..38f731e4c76d6 100644 --- a/packages/media-signaling-server/src/signals/getChannelForSignal.ts +++ b/packages/media-signaling-server/src/signals/getChannelForSignal.ts @@ -7,7 +7,11 @@ import { getRoleForActor } from '../channels/getRoleForActor'; type ValidUser = AtLeast, '_id' | 'username' | 'name'>; -export async function getChannelForSignal(signal: MediaSignal, call: IMediaCall, user: ValidUser): Promise { +export async function getChannelForSignal( + signal: MediaSignal & { sessionId: Required['sessionId'] }, + call: IMediaCall, + user: ValidUser, +): Promise { const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list diff --git a/packages/media-signaling-server/src/signals/processSignal.ts b/packages/media-signaling-server/src/signals/processSignal.ts index c83d872dbd604..dab577e67cd78 100644 --- a/packages/media-signaling-server/src/signals/processSignal.ts +++ b/packages/media-signaling-server/src/signals/processSignal.ts @@ -5,9 +5,11 @@ import { MediaCalls, Users } from '@rocket.chat/models'; import { processDeliver } from './deliver/processDeliver'; import { getChannelForSignal } from './getChannelForSignal'; import { processNotify } from './notify/processNotify'; +import { assertSignalHasSessionId } from '../utils/assertSignalHasSessionId'; export async function processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { - console.log(signal); + assertSignalHasSessionId(signal); + console.log('server.processSignal', signal); const call = await MediaCalls.findOneById(signal.callId); if (!call) { diff --git a/packages/media-signaling-server/src/signals/sendNewSignals.ts b/packages/media-signaling-server/src/signals/sendNewSignals.ts new file mode 100644 index 0000000000000..e9bc0a8c8e4bc --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendNewSignals.ts @@ -0,0 +1,38 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { sendSignalToActor } from './sendSignalToActor'; + +export async function sendNewSignals(callId: string): Promise { + const call = await MediaCalls.getNewSequence(callId); + + console.log('sendNewSignals', call); + + if (!call) { + throw new Error('failed-to-create-call'); + } + + const signalData = { + callId: call._id, + sequence: call.sequence, + type: 'notify', + body: { + notify: 'new', + service: call.service, + kind: call.kind, + }, + } as const; + + await Promise.allSettled([ + sendSignalToActor(call.caller, { + ...signalData, + role: 'caller', + }), + sendSignalToActor(call.callee, { + ...signalData, + role: 'callee', + }), + ]); + + return call; +} diff --git a/packages/media-signaling-server/src/signals/sendSignalToActor.ts b/packages/media-signaling-server/src/signals/sendSignalToActor.ts new file mode 100644 index 0000000000000..e57835410b86e --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendSignalToActor.ts @@ -0,0 +1,30 @@ +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { MediaSignal, MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; + +import { buildSignal } from './buildSignal'; +import { sendSignal } from './signalHandler'; +import { logger } from '../utils/logger'; + +export async function sendSignalToActor( + actor: MediaCallActor, + signal: { callId: IMediaCall['_id']; role: MediaSignal['role']; sequence: number; type: T; body: MediaSignalBody }, +): Promise { + if (actor.type !== 'user') { + return; + } + + const fullSignal = buildSignal( + { + role: signal.role, + ...('sessionId' in actor && { sessionId: actor.sessionId }), + version: 1, + sequence: signal.sequence, + callId: signal.callId, + }, + signal.type, + signal.body, + ); + + logger.debug({ msg: 'sending signal to actor', actor, signal }); + await sendSignal(actor.id, fullSignal); +} diff --git a/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts b/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts new file mode 100644 index 0000000000000..ef3354dff2f63 --- /dev/null +++ b/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts @@ -0,0 +1,9 @@ +import type { MediaSignal } from '@rocket.chat/media-signaling'; + +export function assertSignalHasSessionId( + signal: MediaSignal, +): asserts signal is MediaSignal & { sessionId: Required['sessionId'] } { + if (!signal.sessionId) { + throw new Error('invalid-signal-session'); + } +} diff --git a/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts b/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts new file mode 100644 index 0000000000000..a18fd2d4b2d98 --- /dev/null +++ b/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts @@ -0,0 +1,13 @@ +import type { AtLeast, IUser, MediaCallContactInformation } from '@rocket.chat/core-typings'; + +export function mapUserToContactInformation({ + name, + username, + freeSwitchExtension, +}: AtLeast): MediaCallContactInformation { + return { + ...(name && { name }), + ...(username && { username }), + ...(freeSwitchExtension && { number: freeSwitchExtension }), + }; +} diff --git a/packages/media-signaling/src/definition/MediaSignalHeader.ts b/packages/media-signaling/src/definition/MediaSignalHeader.ts index 95264ff3fd3ab..8fefa5ade16e5 100644 --- a/packages/media-signaling/src/definition/MediaSignalHeader.ts +++ b/packages/media-signaling/src/definition/MediaSignalHeader.ts @@ -2,7 +2,7 @@ import type { JSONSchemaType } from 'ajv'; export type MediaSignalHeaderParams = { callId: string; - sessionId: string; + sessionId?: string; version: number; sequence: number; @@ -20,7 +20,7 @@ export const mediaSignalHeaderParamsSchema: JSONSchemaType; + timeoutHandler?: ReturnType; } @@ -40,11 +42,10 @@ export class MediaSignalingSession; - private ignoredCalls: Set; + // Store contact information for calls that are not yet known + private contactInformation: Map>; - public get agentId(): string { - return `${this._userId}-${this._sessionId}`; - } + private ignoredCalls: Set; public get sessionId(): string { return this._sessionId; @@ -57,6 +58,7 @@ export class MediaSignalingSession(); + this.contactInformation = new Map>(); this.ignoredCalls = new Set(); this._userId = userId; this._sessionId = createRandomToken(8); @@ -66,6 +68,17 @@ export class MediaSignalingSession { + return { + ...this.contactInformation.get(callId), + ...this.knownCalls.get(callId)?.contact, + }; + } + + protected setCallContact(callId: string, contact: Record): void { + const oldContact = this.getStoredCallContact(callId); + const fullContact = { ...oldContact, ...contact }; + + const call = this.knownCalls.get(callId); + if (!call) { + this.contactInformation.set(callId, fullContact); + return; + } + + call.contact = fullContact; + if (this.contactInformation.has(callId)) { + this.contactInformation.delete(callId); + } + } + private isCallIgnored(callId: string): boolean { return this.ignoredCalls.has(callId); } @@ -320,16 +360,32 @@ export class MediaSignalingSession(requestSignal: MediaSignal, type: T, params: DeliverParams) { diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index e581ca3fdd921..7ce08b3de37a0 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -75,6 +75,6 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod } public async getNewSequence(callId: string): Promise { - return this.findOneAndUpdate({ _id: callId }, { $inc: { sequence: 1 } }); + return this.findOneAndUpdate({ _id: callId }, { $inc: { sequence: 1 } }, { returnDocument: 'after' }); } } diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx index a35b28763bda4..c073da30f6194 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx @@ -21,7 +21,7 @@ const VoipDialerView = forwardRef(function const [number, setNumber] = useState(''); const handleCall = () => { - makeCall(number); + makeCall({ extension: number }); closeDialer(); }; diff --git a/packages/ui-voip/src/hooks/useMediaCallsClient.tsx b/packages/ui-voip/src/hooks/useMediaCallsClient.tsx index 3f1e05160bf13..f63a9ec4947ba 100644 --- a/packages/ui-voip/src/hooks/useMediaCallsClient.tsx +++ b/packages/ui-voip/src/hooks/useMediaCallsClient.tsx @@ -1,9 +1,11 @@ -import { useUser } from '@rocket.chat/ui-contexts'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { MediaSignal } from '@rocket.chat/media-signaling'; +import { useEndpoint, useStream, useUser } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; // import { useIceServers } from './useIceServers'; -import MediaCallsClient from '../lib/MediaCallsClient'; +import MediaCallsClient, { type MediaCallsClientConfig } from '../lib/MediaCallsClient'; type MediaCallsClientParams = { enabled?: boolean; @@ -18,6 +20,26 @@ export const useMediaCallsClient = ({ enabled = true }: MediaCallsClientParams = const { _id: userId } = useUser() || {}; const mediaCallsClientRef = useRef(null); + const mediaCallsStart = useEndpoint('POST', '/v1/media-calls.start'); + const mediaCallsSignal = useEndpoint('POST', '/v1/media-calls.signal'); + const notifyUserStream = useStream('notify-user'); + + const startCallFn: MediaCallsClientConfig['startCallFn'] = async (params) => { + const { call } = await mediaCallsStart(params); + const { createdAt, endedAt, _updatedAt, ...callData } = call; + + return { + ...callData, + createdAt: new Date(createdAt), + _updatedAt: new Date(_updatedAt), + ...(endedAt && { endedAt: new Date(endedAt) }), + }; + }; + + const sendSignalFn: MediaCallsClientConfig['sendSignalFn'] = async (signal) => { + await mediaCallsSignal({ signal }); + }; + // const iceServers = useIceServers(); const { data: mediaCallsClient, error } = useQuery({ @@ -31,7 +53,13 @@ export const useMediaCallsClient = ({ enabled = true }: MediaCallsClientParams = throw Error('error-user-not-found'); } - const mediaCallsClient = await MediaCallsClient.create(userId); + const config: MediaCallsClientConfig = { + userId, + startCallFn, + sendSignalFn, + }; + + const mediaCallsClient = await MediaCallsClient.create(config); return mediaCallsClient; }, @@ -39,6 +67,19 @@ export const useMediaCallsClient = ({ enabled = true }: MediaCallsClientParams = enabled, }); + const notifyNewMediaSignal = useEffectEvent((signal: MediaSignal) => { + console.log('new media signal', signal); + mediaCallsClient?.processSignal(signal); + }); + + useEffect(() => { + const unsubNotification = notifyUserStream(`${userId}/media-signal`, notifyNewMediaSignal); + + return () => { + unsubNotification(); + }; + }, [notifyNewMediaSignal, notifyUserStream, userId, mediaCallsClient]); + useEffect(() => { mediaCallsClientRef.current = mediaCallsClient; diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx index 7d2332ac7322e..2658a5a994fc3 100644 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -2,9 +2,10 @@ import { useContext, useMemo } from 'react'; import type { VoipContextReady } from '../contexts/VoipContext'; import { VoipContext, isVoipContextReady } from '../contexts/VoipContext'; +import MediaCallsClient from '../lib/MediaCallsClient'; type VoipAPI = { - makeCall(calleeURI: string): void; + makeCall(callee: { uid?: string; rid?: string; extension?: string } | string): void; endCall(): void; register(): Promise; unregister(): Promise; @@ -41,8 +42,38 @@ export const useVoipAPI = (): VoipAPI => { const { voipClient, changeAudioInputDevice, changeAudioOutputDevice } = context; + // Workaround to use the MediaCallsClient with the VoipContext for now + if (voipClient && voipClient instanceof MediaCallsClient) { + const mediaCallsClient = voipClient as MediaCallsClient; + + return { + makeCall: (callee) => { + if (typeof callee === 'string') { + return mediaCallsClient.call({ extension: callee }); + } + + return mediaCallsClient.call(callee); + }, + endCall: mediaCallsClient.endCall, + register: NOOP, + unregister: NOOP, + transferCall: mediaCallsClient.transfer, + openDialer: () => mediaCallsClient.notifyDialer({ open: true }), + closeDialer: () => mediaCallsClient.notifyDialer({ open: false }), + changeAudioInputDevice, + changeAudioOutputDevice, + onRegisteredOnce: NOOP, + onUnregisteredOnce: NOOP, + }; + } + return { - makeCall: voipClient.call, + makeCall: (callee) => { + if (typeof callee === 'string') { + return voipClient.call(callee); + } + return voipClient.call(callee.extension as string); + }, endCall: voipClient.endCall, register: voipClient.register, unregister: voipClient.unregister, diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts index eee8e1701b7c5..daf20cbb5baa5 100644 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -1,6 +1,6 @@ -import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IRoom } from '@rocket.chat/core-typings'; +import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IRoom, IMediaCall } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { IClientMediaCall, MediaSignal, MediaSignalingSession } from '@rocket.chat/media-signaling'; import type { ContactInfo, VoipSession } from '../definitions'; import RemoteStream from './RemoteStream'; @@ -19,6 +19,14 @@ type SessionError = { contact: ContactInfo; }; +export type MediaCallsClientConfig = { + userId: IUser['_id']; + startCallFn: (params: { roomId: string; sessionId: string }) => Promise; + sendSignalFn: (signal: MediaSignal) => Promise; +}; + +export type MediaCallsCallee = { uid?: IUser['_id']; rid?: IRoom['_id']; extension?: string }; + class MediaCallsClient extends MediaSignalingSession { public networkEmitter: Emitter; @@ -34,12 +42,12 @@ class MediaCallsClient extends MediaSignalingSession { private error: SessionError | null = null; - private contactInfo: ContactInfo | null = null; - private reconnecting = false; - constructor(userId: IUser['_id']) { - super(userId); + private startingNewCall = false; + + constructor(private readonly config: MediaCallsClientConfig) { + super(config.userId); this.networkEmitter = new Emitter(); } @@ -49,31 +57,30 @@ class MediaCallsClient extends MediaSignalingSession { window.addEventListener('offline', this.onNetworkLost); } - static async create(userId: IUser['_id']): Promise { - const mediaCalls = new MediaCallsClient(userId); - await mediaCalls.init(); + static async create(config: MediaCallsClientConfig): Promise { + const mediaCalls = new MediaCallsClient(config); + // await mediaCalls.init(); return mediaCalls; } - public async call(calleeURI: string): Promise { - if (!calleeURI) { - throw new Error('Invalid URI'); + public isBusy(): boolean { + return this.startingNewCall || super.isBusy(); + } + + public async call(callee: MediaCallsCallee): Promise { + console.log('MediaCallsClient.call', callee); + if (!callee.rid) { + if (callee.uid || callee.extension) { + throw new Error('not-implemented'); + } + throw new Error('Invalid Callee'); } if (this.isBusy()) { throw new Error('Session already exists'); } - // #ToDo: Change calleeURI - await this.startCall(calleeURI); - - // this.setContactInfo({ - // id: remoteIdentity.uri.user ?? '', - // name: remoteIdentity.displayName, - // host: remoteIdentity.uri.host, - // }); - - this.emit('stateChanged'); + await this.startCall(callee.rid); } public async transfer(calleeURI: string): Promise { @@ -140,16 +147,22 @@ class MediaCallsClient extends MediaSignalingSession { } } - public getContactInfo() { + public getContactInfo(): ContactInfo | null { if (this.error) { return this.error.contact; } - if (!this.isBusy()) { + const call = this.getMainCall(); + if (!call) { return null; } - return this.contactInfo; + const contactData = this.getStoredCallContact(call.callId); + return { + id: contactData.id, + name: contactData.displayName, + host: '', + }; } public getReferredBy() { @@ -314,8 +327,25 @@ class MediaCallsClient extends MediaSignalingSession { } public clear(): void { - window.removeEventListener('online', this.onNetworkRestored); - window.removeEventListener('offline', this.onNetworkLost); + // window.removeEventListener('online', this.onNetworkRestored); + // window.removeEventListener('offline', this.onNetworkLost); + } + + protected callStateChanged(callId: string, state: IClientMediaCall['state']): void { + super.callStateChanged(callId, state); + + const call = this.getCallData(callId); + if (!call) { + this.emit('stateChanged'); + return; + } + + if (call.role === 'callee') { + this.emit('incomingcall', this.getContactInfo() as ContactInfo); + } else if (call.role === 'caller') { + this.emit('outgoingcall', this.getContactInfo() as ContactInfo); + } + this.emit('stateChanged'); } // private setContactInfo(contact: ContactInfo) { @@ -323,8 +353,25 @@ class MediaCallsClient extends MediaSignalingSession { // this.emit('stateChanged'); // } - private async startCall(_roomId: IRoom['_id']): Promise { - // #ToDo: call 'media-calls.start' + private async startCall(roomId: IRoom['_id']): Promise { + this.startingNewCall = true; + try { + this.emit('stateChanged'); + + const call = await this.config.startCallFn({ roomId, sessionId: this.sessionId }); + + console.log(call); + const { _id: callId, callee } = call; + this.setCallContact(callId, callee); + + this.emit('stateChanged'); + } finally { + this.startingNewCall = false; + } + } + + protected async sendSignal(signal: MediaSignal): Promise { + return this.config.sendSignalFn(signal); } // private setupRemoteMedia() { From 9271940348ebdd2f6199a7c2714bbea58e559a02 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 25 Jul 2025 21:29:15 -0300 Subject: [PATCH 11/72] webrtc processor, some fixes and reorganization --- .../src/channels/getOppositeChannel.ts | 4 +- .../src/signals/notify/ack.ts | 6 +- packages/media-signaling/package.json | 1 - .../IServiceProcessorFactoryList.ts | 5 + .../src/definition/IWebRTCProcessor.ts | 8 +- .../src/definition/MediaSignalDeliver.ts | 120 ----- .../src/definition/MediaSignalHeader.ts | 72 --- .../src/definition/MediaSignalNotify.ts | 298 ------------ .../src/definition/MediaSignalRequest.ts | 98 ---- .../src/definition/MediaSignalTransport.ts | 3 + .../media-signaling/src/definition/WebRTC.ts | 47 -- .../src/definition/call/CallContact.ts | 1 + .../src/definition/call/CallEvents.ts | 8 + .../src/definition/call/CallRole.ts | 5 + .../src/definition/call/CallService.ts | 1 + .../src/definition/call/CallState.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 22 + .../src/definition/call/index.ts | 6 + .../media-signaling/src/definition/index.ts | 9 +- .../definition/{ => signal}/MediaSignal.ts | 10 - .../definition/signal/MediaSignalDeliver.ts | 36 ++ .../definition/signal/MediaSignalHeader.ts | 15 + .../definition/signal/MediaSignalNotify.ts | 85 ++++ .../definition/signal/MediaSignalRequest.ts | 30 ++ .../src/definition/signal/index.ts | 5 + packages/media-signaling/src/lib/Call.ts | 241 ++++++++++ packages/media-signaling/src/lib/Session.ts | 447 ++++-------------- .../src/lib/TransportWrapper.ts | 70 +++ .../src/lib/utils/isNotifyNew.ts | 5 + .../v1/media-calls/MediaCallsSignalProps.ts | 290 +++++++++++- .../src/lib/MediaCallWebRTCProcessor.ts} | 46 +- packages/ui-voip/src/lib/MediaCallsClient.ts | 60 ++- yarn.lock | 1 - 33 files changed, 985 insertions(+), 1071 deletions(-) create mode 100644 packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignalDeliver.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignalHeader.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignalNotify.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignalRequest.ts create mode 100644 packages/media-signaling/src/definition/MediaSignalTransport.ts delete mode 100644 packages/media-signaling/src/definition/WebRTC.ts create mode 100644 packages/media-signaling/src/definition/call/CallContact.ts create mode 100644 packages/media-signaling/src/definition/call/CallEvents.ts create mode 100644 packages/media-signaling/src/definition/call/CallRole.ts create mode 100644 packages/media-signaling/src/definition/call/CallService.ts create mode 100644 packages/media-signaling/src/definition/call/CallState.ts create mode 100644 packages/media-signaling/src/definition/call/IClientMediaCall.ts create mode 100644 packages/media-signaling/src/definition/call/index.ts rename packages/media-signaling/src/definition/{ => signal}/MediaSignal.ts (62%) create mode 100644 packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts create mode 100644 packages/media-signaling/src/definition/signal/MediaSignalHeader.ts create mode 100644 packages/media-signaling/src/definition/signal/MediaSignalNotify.ts create mode 100644 packages/media-signaling/src/definition/signal/MediaSignalRequest.ts create mode 100644 packages/media-signaling/src/definition/signal/index.ts create mode 100644 packages/media-signaling/src/lib/Call.ts create mode 100644 packages/media-signaling/src/lib/TransportWrapper.ts create mode 100644 packages/media-signaling/src/lib/utils/isNotifyNew.ts rename packages/{media-signaling/src/lib/webrtc/WebRTCMediaCall.ts => ui-voip/src/lib/MediaCallWebRTCProcessor.ts} (64%) diff --git a/packages/media-signaling-server/src/channels/getOppositeChannel.ts b/packages/media-signaling-server/src/channels/getOppositeChannel.ts index 688d8a6b3dd94..de0dd2180305e 100644 --- a/packages/media-signaling-server/src/channels/getOppositeChannel.ts +++ b/packages/media-signaling-server/src/channels/getOppositeChannel.ts @@ -5,9 +5,9 @@ import { getActorChannel } from './getActorChannel'; export async function getOppositeChannel(call: IMediaCall, channel: IMediaCallChannel): Promise { switch (channel.role) { case 'callee': - return getActorChannel(call._id, call.callee); - case 'caller': return getActorChannel(call._id, call.caller); + case 'caller': + return getActorChannel(call._id, call.callee); default: return null; } diff --git a/packages/media-signaling-server/src/signals/notify/ack.ts b/packages/media-signaling-server/src/signals/notify/ack.ts index ff61a4bde4cee..1012ac089244b 100644 --- a/packages/media-signaling-server/src/signals/notify/ack.ts +++ b/packages/media-signaling-server/src/signals/notify/ack.ts @@ -2,6 +2,7 @@ import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; +import { getOppositeChannel } from '../../channels/getOppositeChannel'; import { requestChannelOffer } from '../../channels/requestChannelOffer'; export async function processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { @@ -12,7 +13,10 @@ export async function processACK(_signal: MediaSignalNotify<'ack'>, call: IMedia // If the state was changed, request an offer from the caller if (result.modifiedCount) { - await requestChannelOffer(channel); + const otherChannel = await getOppositeChannel(call, channel); + if (otherChannel) { + await requestChannelOffer(channel); + } } } } diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 307733fea96d4..3d074f437b1b1 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -6,7 +6,6 @@ "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", - "ajv": "^8.17.1", "eslint": "~8.45.0", "jest": "~30.0.5", "typescript": "~5.8.3" diff --git a/packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts b/packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts new file mode 100644 index 0000000000000..7afad077b9a7f --- /dev/null +++ b/packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts @@ -0,0 +1,5 @@ +import type { WebRTCProcessorFactory } from './IWebRTCProcessor'; + +export interface IServiceProcessorFactoryList { + webrtc?: WebRTCProcessorFactory; +} diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index 9af4f7adcac03..56584866b406b 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -1,9 +1,7 @@ -import type { DeliverParams } from './MediaSignalDeliver'; -import type { RequestParams } from './MediaSignalRequest'; +import type { DeliverParams } from './signal/MediaSignalDeliver'; +import type { RequestParams } from './signal/MediaSignalRequest'; export interface IWebRTCProcessor { - // Called when the Session manager anticipates a webrtc call may be initiated - initializePeerConnection(configuration?: RTCConfiguration): Promise; // Function used to create a webrtc offer using the pre-initialized peer connection createOffer(params: RequestParams<'offer'>): Promise>; // Function used to create a webrtc answer using the pre-initialized peer connection @@ -18,3 +16,5 @@ export interface IWebRTCProcessor { // Function used to add a callback to be executed when ICE needs to be restarted onNegotiationNeeded(cb: unknown): void; } + +export type WebRTCProcessorFactory = () => IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/MediaSignalDeliver.ts b/packages/media-signaling/src/definition/MediaSignalDeliver.ts deleted file mode 100644 index a3c96e167d9eb..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignalDeliver.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; -import { iceCandidateSchema, sdpSchema } from './WebRTC'; - -export type DeliverSdpBody = { - deliver: 'sdp'; - sdp: RTCSessionDescriptionInit; - endOfCandidates: boolean; -}; - -export const deliverSdpBodySchema: JSONSchemaType = { - type: 'object', - properties: { - deliver: { - type: 'string', - pattern: 'sdp', - }, - sdp: sdpSchema, - endOfCandidates: { - type: 'boolean', - nullable: false, - }, - }, - required: ['deliver', 'sdp', 'endOfCandidates'], - additionalProperties: false, -}; - -export type DeliverIceCandidatesBody = { - deliver: 'ice-candidates'; - candidates: RTCIceCandidateInit[]; - endOfCandidates: boolean; -}; - -export const deliverIceCandidatesBodySchema: JSONSchemaType = { - type: 'object', - properties: { - deliver: { - type: 'string', - pattern: 'ice-candidates', - }, - candidates: { - type: 'array', - items: iceCandidateSchema, - }, - endOfCandidates: { - type: 'boolean', - nullable: false, - }, - }, - required: ['deliver', 'candidates', 'endOfCandidates'], - additionalProperties: false, -}; - -export type DeliverDTMFBody = { - deliver: 'dtmf'; - tone: string; - duration?: number; -}; - -export const deliverDTMFBodySchema: JSONSchemaType = { - type: 'object', - properties: { - deliver: { - type: 'string', - pattern: 'dtmf', - }, - tone: { - type: 'string', - nullable: false, - }, - duration: { - type: 'number', - nullable: true, - }, - }, - required: ['deliver', 'tone'], - additionalProperties: false, -}; - -export type DeliverBodyMap = { - 'sdp': DeliverSdpBody; - 'ice-candidates': DeliverIceCandidatesBody; - 'dtmf': DeliverDTMFBody; -}; - -export type DeliverType = keyof DeliverBodyMap; - -export type DeliverBody = DeliverBodyMap[T]; - -export type MediaSignalDeliver = MediaSignalHeader & { - type: 'deliver'; - body: DeliverBody; -}; - -export type DeliverParams = Omit, 'deliver'>; - -export const mediaSignalDeliverSchema: JSONSchemaType = { - type: 'object', - allOf: [ - mediaSignalHeaderParamsSchema, - { - type: 'object', - properties: { - type: { - type: 'string', - pattern: 'deliver', - }, - body: { - type: 'object', - oneOf: [deliverSdpBodySchema, deliverIceCandidatesBodySchema, deliverDTMFBodySchema], - additionalProperties: false, - }, - }, - required: ['type', 'body'], - additionalProperties: false, - }, - ], - required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], -}; diff --git a/packages/media-signaling/src/definition/MediaSignalHeader.ts b/packages/media-signaling/src/definition/MediaSignalHeader.ts deleted file mode 100644 index 8fefa5ade16e5..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignalHeader.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -export type MediaSignalHeaderParams = { - callId: string; - sessionId?: string; - version: number; - sequence: number; - - role: 'caller' | 'callee'; - - expectACK?: boolean; -}; - -export const mediaSignalHeaderParamsSchema: JSONSchemaType = { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - sessionId: { - type: 'string', - nullable: true, - }, - version: { - type: 'number', - nullable: false, - }, - sequence: { - type: 'number', - nullable: false, - }, - role: { - type: 'string', - nullable: false, - }, - expectACK: { - type: 'boolean', - nullable: true, - }, - }, - required: ['callId', 'version', 'sequence', 'role'], - additionalProperties: true, -}; - -export type MediaSignalHeader = MediaSignalHeaderParams & { - type: 'request' | 'deliver' | 'notify'; - body: Record; -}; - -export const mediaSignalHeaderSchema: JSONSchemaType = { - type: 'object', - allOf: [ - mediaSignalHeaderParamsSchema, - { - type: 'object', - properties: { - type: { - type: 'string', - oneOf: ['request', 'deliver', 'notify'], - }, - body: { - type: 'object', - additionalProperties: true, - }, - }, - required: ['type', 'body'], - additionalProperties: false, - }, - ], - required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], -}; diff --git a/packages/media-signaling/src/definition/MediaSignalNotify.ts b/packages/media-signaling/src/definition/MediaSignalNotify.ts deleted file mode 100644 index d38a74b4c01d5..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignalNotify.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; - -export type NotifyAckBody = { - notify: 'ack'; -}; - -export const notifyAckBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'ack', - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -// export type NotifyInvalidBody = { -// notify: 'invalid'; -// }; - -// export type NotifyUnableBody = { -// notify: 'unable'; -// }; - -// export type NotifyEmptyBody = { -// notify: 'empty'; -// }; - -export type NotifyErrorBody = { - notify: 'error'; - errorCode: string; - errorText?: string; -}; - -export const notifyErrorBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'error', - }, - errorCode: { - type: 'string', - nullable: false, - }, - errorText: { - type: 'string', - nullable: true, - }, - }, - required: ['notify', 'errorCode'], - additionalProperties: false, -}; - -export type NotifyNewBody = { - notify: 'new'; - service: 'webrtc'; - kind: 'direct'; -}; - -export const notifyNewBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'new', - }, - service: { - type: 'string', - pattern: 'webrtc', - }, - kind: { - type: 'string', - pattern: 'direct', - }, - }, - required: ['notify', 'service', 'kind'], - additionalProperties: false, -}; - -export type NotifyStateBody = { - notify: 'state'; - callState?: string; - serviceState?: string; - mediaState?: string; -}; - -export const notifyStateBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'state', - }, - callState: { - type: 'string', - nullable: true, - }, - serviceState: { - type: 'string', - nullable: true, - }, - mediaState: { - type: 'string', - nullable: true, - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -export type NotifyUnavailableBody = { - notify: 'unavailable'; -}; - -export const notifyUnavailableBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'unavailable', - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -export type NotifyAcceptBody = { - notify: 'accept'; -}; - -export const notifyAcceptBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'accept', - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -export type NotifyRejectBody = { - notify: 'reject'; -}; - -export const notifyRejectBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'reject', - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -export type NotifyHangupBody = { - notify: 'hangup'; - reasonCode: string; - reasonText?: string; -}; - -export const notifyHangupBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'hangup', - }, - reasonCode: { - type: 'string', - nullable: false, - }, - reasonText: { - type: 'string', - nullable: true, - }, - }, - required: ['notify', 'reasonCode'], - additionalProperties: false, -}; - -export type NotifyNegotiationNeededBody = { - notify: 'negotiation-needed'; - reason?: string; -}; - -export const notifyNegotiationNeededBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'negotiation-needed', - }, - reason: { - type: 'string', - nullable: true, - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -export type NotifyMultiBody = { - notify: 'multi'; - notifications: NotifyBody>[]; -}; - -const notifySingleBodySchemas = [ - notifyAckBodySchema, - notifyNewBodySchema, - notifyErrorBodySchema, - notifyAcceptBodySchema, - notifyRejectBodySchema, - notifyHangupBodySchema, - notifyNegotiationNeededBodySchema, - notifyUnavailableBodySchema, - notifyStateBodySchema, -]; - -export const notifyMultiBodySchema: JSONSchemaType = { - type: 'object', - properties: { - notify: { - type: 'string', - pattern: 'multi', - }, - notifications: { - type: 'array', - items: { - type: 'object', - anyOf: notifySingleBodySchemas, - }, - }, - }, - required: ['notify'], - additionalProperties: false, -}; - -const notifyAnyBodySchema = [...notifySingleBodySchemas, notifyMultiBodySchema]; - -export type NotifyBodyMap = { - 'new': NotifyNewBody; - 'ack': NotifyAckBody; - // 'invalid': NotifyInvalidBody; - // 'unable': NotifyUnableBody; - // 'empty': NotifyEmptyBody; - 'error': NotifyErrorBody; - 'accept': NotifyAcceptBody; - 'reject': NotifyRejectBody; - 'hangup': NotifyHangupBody; - 'negotiation-needed': NotifyNegotiationNeededBody; - 'unavailable': NotifyUnavailableBody; - 'state': NotifyStateBody; - 'multi': NotifyMultiBody; -}; - -export type NotifyType = keyof NotifyBodyMap; - -export type NotifyBody = NotifyBodyMap[T]; - -export type MediaSignalNotify = MediaSignalHeader & { - type: 'notify'; - body: NotifyBody; -}; - -export type NotifyParams = Omit, 'notify'>; - -export const mediaSignalNotifySchema: JSONSchemaType = { - type: 'object', - allOf: [ - mediaSignalHeaderParamsSchema, - { - type: 'object', - properties: { - type: { - type: 'string', - pattern: 'notify', - }, - body: { - type: 'object', - oneOf: notifyAnyBodySchema, - additionalProperties: false, - }, - }, - required: ['type', 'body'], - additionalProperties: false, - }, - ], - required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], -}; diff --git a/packages/media-signaling/src/definition/MediaSignalRequest.ts b/packages/media-signaling/src/definition/MediaSignalRequest.ts deleted file mode 100644 index fda8cced7ebb5..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignalRequest.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -import { mediaSignalHeaderParamsSchema, type MediaSignalHeader } from './MediaSignalHeader'; -import { sdpSchema } from './WebRTC'; - -export type RequestOfferBody = { - request: 'offer'; - iceRestart?: boolean; -}; - -export const requestOfferBodySchema: JSONSchemaType = { - type: 'object', - properties: { - request: { - type: 'string', - pattern: 'offer', - }, - iceRestart: { - type: 'boolean', - nullable: true, - }, - }, - required: ['request'], - additionalProperties: false, -}; - -export type RequestAnswerBody = { - request: 'answer'; - offer: RTCSessionDescriptionInit; -}; - -export const requestAnswerBodySchema: JSONSchemaType = { - type: 'object', - properties: { - request: { - type: 'string', - pattern: 'answer', - }, - offer: sdpSchema, - }, - required: ['request'], - additionalProperties: false, -}; - -export type RequestSdpBody = { - request: 'sdp'; -}; - -export const requestSdpBodySchema: JSONSchemaType = { - type: 'object', - properties: { - request: { - type: 'string', - pattern: 'sdp', - }, - }, - required: ['request'], - additionalProperties: false, -}; - -export type RequestBodyMap = { - offer: RequestOfferBody; - answer: RequestAnswerBody; - sdp: RequestSdpBody; -}; - -export type RequestBody = RequestBodyMap[T]; - -export type MediaSignalRequest = MediaSignalHeader & { - type: 'request'; - body: RequestBody; -}; - -export type RequestParams = Omit, 'request'>; - -export const mediaSignalRequestSchema: JSONSchemaType = { - type: 'object', - allOf: [ - mediaSignalHeaderParamsSchema, - { - type: 'object', - properties: { - type: { - type: 'string', - pattern: 'request', - }, - body: { - type: 'object', - oneOf: [requestOfferBodySchema, requestAnswerBodySchema, requestSdpBodySchema], - additionalProperties: false, - }, - }, - required: ['type', 'body'], - additionalProperties: false, - }, - ], - required: [...mediaSignalHeaderParamsSchema.required, 'type', 'body'], -}; diff --git a/packages/media-signaling/src/definition/MediaSignalTransport.ts b/packages/media-signaling/src/definition/MediaSignalTransport.ts new file mode 100644 index 0000000000000..5485ea82c939f --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignalTransport.ts @@ -0,0 +1,3 @@ +import type { MediaSignal } from './signal'; + +export type MediaSignalTransport = (signal: MediaSignal) => void; diff --git a/packages/media-signaling/src/definition/WebRTC.ts b/packages/media-signaling/src/definition/WebRTC.ts deleted file mode 100644 index 6ed629d559480..0000000000000 --- a/packages/media-signaling/src/definition/WebRTC.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; - -export type SDP = RTCSessionDescriptionInit; - -export const sdpSchema: JSONSchemaType = { - type: 'object', - properties: { - sdp: { - type: 'string', - nullable: true, - }, - type: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: false, - nullable: false, - required: ['type'], -}; - -export type IceCandidate = RTCIceCandidateInit; - -export const iceCandidateSchema: JSONSchemaType = { - type: 'object', - properties: { - candidate: { - type: 'string', - nullable: true, - }, - sdpMLineIndex: { - type: 'number', - nullable: true, - }, - sdpMid: { - type: 'string', - nullable: true, - }, - usernameFragment: { - type: 'string', - nullable: true, - }, - }, - additionalProperties: false, - nullable: false, - required: [], -}; diff --git a/packages/media-signaling/src/definition/call/CallContact.ts b/packages/media-signaling/src/definition/call/CallContact.ts new file mode 100644 index 0000000000000..6f447233f07fd --- /dev/null +++ b/packages/media-signaling/src/definition/call/CallContact.ts @@ -0,0 +1 @@ +export type CallContact = Record | null; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts new file mode 100644 index 0000000000000..cea5a2c04b59e --- /dev/null +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -0,0 +1,8 @@ +import type { CallState } from './CallState'; + +export type CallEvents = { + // stateChange receives the old state as param + stateChange: CallState; + // contactUpdate may be triggered even if nothing actually changed + contactUpdate: void; +}; diff --git a/packages/media-signaling/src/definition/call/CallRole.ts b/packages/media-signaling/src/definition/call/CallRole.ts new file mode 100644 index 0000000000000..9fd905ea8e64f --- /dev/null +++ b/packages/media-signaling/src/definition/call/CallRole.ts @@ -0,0 +1,5 @@ +export type CallRole = 'caller' | 'callee'; + +export function isCallRole(role: string): role is CallRole { + return ['caller', 'callee'].includes(role); +} diff --git a/packages/media-signaling/src/definition/call/CallService.ts b/packages/media-signaling/src/definition/call/CallService.ts new file mode 100644 index 0000000000000..c2a3c1c6e2183 --- /dev/null +++ b/packages/media-signaling/src/definition/call/CallService.ts @@ -0,0 +1 @@ +export type CallService = 'webrtc'; diff --git a/packages/media-signaling/src/definition/call/CallState.ts b/packages/media-signaling/src/definition/call/CallState.ts new file mode 100644 index 0000000000000..ac9a91b792d11 --- /dev/null +++ b/packages/media-signaling/src/definition/call/CallState.ts @@ -0,0 +1 @@ +export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'error'; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts new file mode 100644 index 0000000000000..96ec4f13eea1a --- /dev/null +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -0,0 +1,22 @@ +import type { Emitter } from '@rocket.chat/emitter'; + +import type { CallContact } from './CallContact'; +import type { CallEvents } from './CallEvents'; +import type { CallRole } from './CallRole'; +import type { CallService } from './CallService'; +import type { CallState } from './CallState'; + +export interface IClientMediaCallData { + callId: string; + role: CallRole; + service: CallService; + + state?: CallState; + ignored?: boolean; + + contact?: CallContact; +} + +export interface IClientMediaCall extends Required { + emitter: Emitter; +} diff --git a/packages/media-signaling/src/definition/call/index.ts b/packages/media-signaling/src/definition/call/index.ts new file mode 100644 index 0000000000000..97c80232026b7 --- /dev/null +++ b/packages/media-signaling/src/definition/call/index.ts @@ -0,0 +1,6 @@ +export * from './CallContact'; +export * from './CallRole'; +export * from './CallState'; +export * from './CallEvents'; +export * from './CallService'; +export * from './IClientMediaCall'; diff --git a/packages/media-signaling/src/definition/index.ts b/packages/media-signaling/src/definition/index.ts index 15934e69af627..fa07addbb63e5 100644 --- a/packages/media-signaling/src/definition/index.ts +++ b/packages/media-signaling/src/definition/index.ts @@ -1,6 +1,5 @@ export * from './IWebRTCProcessor'; -export * from './MediaSignal'; -export * from './MediaSignalDeliver'; -export * from './MediaSignalHeader'; -export * from './MediaSignalNotify'; -export * from './MediaSignalRequest'; +export * from './IServiceProcessorFactoryList'; +export * from './MediaSignalTransport'; +export * from './call'; +export * from './signal'; diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/signal/MediaSignal.ts similarity index 62% rename from packages/media-signaling/src/definition/MediaSignal.ts rename to packages/media-signaling/src/definition/signal/MediaSignal.ts index c106d9d3cf16e..4bb4bb6a6b3cb 100644 --- a/packages/media-signaling/src/definition/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signal/MediaSignal.ts @@ -1,10 +1,5 @@ -import type { JSONSchemaType } from 'ajv'; - -import { mediaSignalDeliverSchema } from './MediaSignalDeliver'; import type { MediaSignalDeliver } from './MediaSignalDeliver'; -import { mediaSignalNotifySchema } from './MediaSignalNotify'; import type { MediaSignalNotify } from './MediaSignalNotify'; -import { mediaSignalRequestSchema } from './MediaSignalRequest'; import type { MediaSignalRequest } from './MediaSignalRequest'; export type MediaSignalMap = { @@ -19,9 +14,4 @@ export type MediaSignal = MediaSign export type ClientMediaSignal = Omit; -export const mediaSignalSchema: JSONSchemaType = { - type: 'object', - oneOf: [mediaSignalRequestSchema, mediaSignalDeliverSchema, mediaSignalNotifySchema], -}; - export type MediaSignalBody = MediaSignal['body']; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts b/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts new file mode 100644 index 0000000000000..0974b720f4e8f --- /dev/null +++ b/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts @@ -0,0 +1,36 @@ +import { type MediaSignalHeader } from './MediaSignalHeader'; + +export type DeliverSdpBody = { + deliver: 'sdp'; + sdp: RTCSessionDescriptionInit; + endOfCandidates: boolean; +}; + +export type DeliverIceCandidatesBody = { + deliver: 'ice-candidates'; + candidates: RTCIceCandidateInit[]; + endOfCandidates: boolean; +}; + +export type DeliverDTMFBody = { + deliver: 'dtmf'; + tone: string; + duration?: number; +}; + +export type DeliverBodyMap = { + 'sdp': DeliverSdpBody; + 'ice-candidates': DeliverIceCandidatesBody; + 'dtmf': DeliverDTMFBody; +}; + +export type DeliverType = keyof DeliverBodyMap; + +export type DeliverBody = DeliverBodyMap[T]; + +export type MediaSignalDeliver = MediaSignalHeader & { + type: 'deliver'; + body: DeliverBody; +}; + +export type DeliverParams = Omit, 'deliver'>; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts b/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts new file mode 100644 index 0000000000000..64fcee6d890eb --- /dev/null +++ b/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts @@ -0,0 +1,15 @@ +export type MediaSignalHeaderParams = { + callId: string; + sessionId?: string; + version: number; + sequence: number; + + role: 'caller' | 'callee'; + + expectACK?: boolean; +}; + +export type MediaSignalHeader = MediaSignalHeaderParams & { + type: 'request' | 'deliver' | 'notify'; + body: Record; +}; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts b/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts new file mode 100644 index 0000000000000..4820cecb6ff87 --- /dev/null +++ b/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts @@ -0,0 +1,85 @@ +import { type MediaSignalHeader } from './MediaSignalHeader'; + +export type NotifyAckBody = { + notify: 'ack'; +}; + +// export type NotifyInvalidBody = { +// notify: 'invalid'; +// }; + +// export type NotifyUnableBody = { +// notify: 'unable'; +// }; + +// export type NotifyEmptyBody = { +// notify: 'empty'; +// }; + +export type NotifyErrorBody = { + notify: 'error'; + errorCode: string; + errorText?: string; +}; + +export type NotifyNewBody = { + notify: 'new'; + service: 'webrtc'; + kind: 'direct'; +}; + +export type NotifyStateBody = { + notify: 'state'; + callState?: string; + serviceState?: string; + mediaState?: string; +}; + +export type NotifyUnavailableBody = { + notify: 'unavailable'; +}; + +export type NotifyAcceptBody = { + notify: 'accept'; +}; + +export type NotifyRejectBody = { + notify: 'reject'; +}; + +export type NotifyHangupBody = { + notify: 'hangup'; + reasonCode: string; + reasonText?: string; +}; + +export type NotifyNegotiationNeededBody = { + notify: 'negotiation-needed'; + reason?: string; +}; + +export type NotifyBodyMap = { + 'new': NotifyNewBody; + 'ack': NotifyAckBody; + // 'invalid': NotifyInvalidBody; + // 'unable': NotifyUnableBody; + // 'empty': NotifyEmptyBody; + 'error': NotifyErrorBody; + 'accept': NotifyAcceptBody; + 'reject': NotifyRejectBody; + 'hangup': NotifyHangupBody; + 'negotiation-needed': NotifyNegotiationNeededBody; + 'unavailable': NotifyUnavailableBody; + 'state': NotifyStateBody; +}; + +export type NotifyType = keyof NotifyBodyMap; + +export type NotifyBody = NotifyBodyMap[T]; + +export type MediaSignalNotify = MediaSignalHeader & { + type: 'notify'; + body: NotifyBody; +}; + +export type NotifyParams = Omit, 'notify'>; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts b/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts new file mode 100644 index 0000000000000..aa9865520f8ce --- /dev/null +++ b/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts @@ -0,0 +1,30 @@ +import { type MediaSignalHeader } from './MediaSignalHeader'; + +export type RequestOfferBody = { + request: 'offer'; + iceRestart?: boolean; +}; + +export type RequestAnswerBody = { + request: 'answer'; + offer: RTCSessionDescriptionInit; +}; + +export type RequestSdpBody = { + request: 'sdp'; +}; + +export type RequestBodyMap = { + offer: RequestOfferBody; + answer: RequestAnswerBody; + sdp: RequestSdpBody; +}; + +export type RequestBody = RequestBodyMap[T]; + +export type MediaSignalRequest = MediaSignalHeader & { + type: 'request'; + body: RequestBody; +}; + +export type RequestParams = Omit, 'request'>; diff --git a/packages/media-signaling/src/definition/signal/index.ts b/packages/media-signaling/src/definition/signal/index.ts new file mode 100644 index 0000000000000..0246c84c75faf --- /dev/null +++ b/packages/media-signaling/src/definition/signal/index.ts @@ -0,0 +1,5 @@ +export * from './MediaSignal'; +export * from './MediaSignalDeliver'; +export * from './MediaSignalHeader'; +export * from './MediaSignalNotify'; +export * from './MediaSignalRequest'; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts new file mode 100644 index 0000000000000..bdd4546cfa3de --- /dev/null +++ b/packages/media-signaling/src/lib/Call.ts @@ -0,0 +1,241 @@ +import { Emitter } from '@rocket.chat/emitter'; + +import type { MediaSignalTransportWrapper } from './TransportWrapper'; +import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; +import type { IClientMediaCall, IClientMediaCallData, CallEvents, CallContact, CallRole, CallState, CallService } from '../definition/call'; +import type { MediaSignalRequest, MediaSignal, DeliverParams, MediaSignalDeliver, MediaSignalNotify } from '../definition/signal'; + +export interface IClientMediaCallConfig { + transporter: MediaSignalTransportWrapper; + webrtcProcessor: IWebRTCProcessor; +} + +export class ClientMediaCall implements IClientMediaCall { + public readonly callId: string; + + public readonly emitter: Emitter; + + private _role: CallRole; + + public get role(): CallRole { + return this._role; + } + + private _state: CallState; + + public get state(): CallState { + return this._state; + } + + private _ignored: boolean; + + public get ignored(): boolean { + return this._ignored; + } + + private _contact: CallContact; + + public get contact(): CallContact { + return this._contact; + } + + private _service: CallService; + + public get service(): CallService { + return this._service; + } + + private transporter: MediaSignalTransportWrapper; + + private webrtcProcessor: IWebRTCProcessor; + + private tricklingSignals = new Set(); + + // private timeoutHandler?: ReturnType; + + constructor(config: IClientMediaCallConfig, { callId, role, state, ignored, contact, service }: IClientMediaCallData) { + this.emitter = new Emitter(); + + this.transporter = config.transporter; + this.webrtcProcessor = config.webrtcProcessor; + + this.callId = callId; + this._role = role; + this._service = service; + + this._state = state || 'none'; + this._ignored = ignored || false; + this._contact = contact || null; + } + + public async initialize(signal: MediaSignalNotify<'new'>): Promise { + // If it's flagged as ignored even before the initialization, tell the server we're unavailable + if (this.ignored) { + return this.transporter.notifyServer(signal, 'unavailable'); + } + + // If the call is targeted, skip the ringing state and go straight to accepted + this._state = signal.sessionId ? 'accepted' : 'ringing'; + + // Send an ACK so the server knows that this session exists and is reachable + this.transporter.notifyServer(signal, 'ack'); + } + + public changeState(newState: CallState): void { + if (newState === this._state) { + return; + } + + const oldState = this._state; + this._state = newState; + this.emitter.emit('stateChange', oldState); + } + + public setContact(contact: CallContact): void { + if (!contact) { + return; + } + + this._contact = { + ...this._contact, + ...contact, + }; + this.emitter.emit('contactUpdate'); + } + + public async processSignal(signal: MediaSignal) { + console.log('ClientMediaCall.processSignal', signal); + switch (signal.type) { + case 'request': + await this.processRequest(signal); + break; + case 'deliver': + await this.processDeliver(signal); + break; + case 'notify': + await this.processNotify(signal); + break; + } + } + + private async processRequest(signal: MediaSignalRequest) { + if (!signal.sessionId) { + console.error('Received an untargeted request.'); + return; + } + + switch (signal.body.request) { + case 'offer': + return this.processOfferRequest(signal as MediaSignalRequest<'offer'>); + case 'answer': + return this.processAnswerRequest(signal as MediaSignalRequest<'answer'>); + case 'sdp': + return this.processSdpRequest(signal as MediaSignalRequest<'sdp'>); + } + } + + private async processOfferRequest(signal: MediaSignalRequest<'offer'>) { + // #ToDo: processor + let offer: DeliverParams<'sdp'> | null = null; + try { + offer = await this.webrtcProcessor.createOffer(signal.body); + } catch (e) { + this.transporter.sendError(signal, 'failed-to-create-offer'); + throw e; + } + + if (!offer) { + this.transporter.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, offer); + } + + private async processAnswerRequest(signal: MediaSignalRequest<'answer'>) { + let answer: DeliverParams<'sdp'> | null = null; + try { + answer = await this.webrtcProcessor.createAnswer(signal.body); + } catch (e) { + this.transporter.sendError(signal, 'failed-to-create-answer'); + throw e; + } + + if (!answer) { + this.transporter.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, answer); + } + + private async processSdpRequest(signal: MediaSignalRequest<'sdp'>) { + let sdp: DeliverParams<'sdp'> | null = null; + try { + sdp = await this.webrtcProcessor.collectLocalDescription(signal.body); + } catch (e) { + this.transporter.sendError(signal, 'failed-to-collect-description'); + throw e; + } + + if (!sdp) { + this.transporter.sendError(signal, 'implementation-error'); + } + + await this.deliverSdp(signal, sdp); + } + + private async deliverSdp(requestSignal: MediaSignal, sdp: DeliverParams<'sdp'>) { + // If we're trickling ICE, keep the signal reference for upcoming candidates + if (!sdp.endOfCandidates) { + this.tricklingSignals.add(requestSignal); + } + + return this.transporter.deliverToServer(requestSignal, 'sdp', sdp); + } + + private async processDeliver(signal: MediaSignalDeliver) { + if (!signal.sessionId) { + throw new Error('Delivery signals MUST target a specific session.'); + } + + switch (signal.body.deliver) { + case 'sdp': + await this.webrtcProcessor.setRemoteDescription(signal.body); + break; + case 'ice-candidates': + await this.webrtcProcessor.addIceCandidates(signal.body); + break; + case 'dtmf': + // #ToDo + break; + } + } + + private async processNotify(signal: MediaSignalNotify) { + switch (signal.body.notify) { + case 'new': + break; + case 'error': + break; + case 'ack': + break; + // case 'invalid': + // break; + // case 'unable': + // break; + // case 'empty': + // break; + case 'state': + break; + case 'unavailable': + break; + case 'accept': + break; + case 'reject': + break; + case 'hangup': + break; + case 'negotiation-needed': + break; + } + } +} diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 43ae6c471fe6e..84baeabbd3d91 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -1,26 +1,17 @@ -import type { DefaultEventMap } from '@rocket.chat/emitter'; import { Emitter } from '@rocket.chat/emitter'; -import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; -import type { ClientMediaSignal, MediaSignal } from '../definition/MediaSignal'; -import type { DeliverBody, DeliverParams, DeliverType, MediaSignalDeliver } from '../definition/MediaSignalDeliver'; -import type { MediaSignalNotify, NotifyBody, NotifyParams, NotifyType } from '../definition/MediaSignalNotify'; -import type { MediaSignalRequest } from '../definition/MediaSignalRequest'; +import { ClientMediaCall } from './Call'; +import { MediaSignalTransportWrapper } from './TransportWrapper'; +import type { IWebRTCProcessor, MediaSignalNotify } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; -import type { MediaSignalHeaderParams } from '../definition/MediaSignalHeader'; - -export interface IClientMediaCall { - callId: string; - role: 'caller' | 'callee'; - state: 'none' | 'ringing' | 'accepted' | 'active' | 'error'; - ignored?: boolean; - - contact?: Record; - - timeoutHandler?: ReturnType; -} - -const stateWeights: Record = { +import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; +import type { IClientMediaCall, IClientMediaCallData, CallContact, CallState } from '../definition/call'; +import { isNotifyNew } from './utils/isNotifyNew'; +import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; +import { isCallRole } from '../definition/call/CallRole'; +import type { MediaSignal } from '../definition/signal/MediaSignal'; + +const stateWeights: Record = { none: 0, ringing: 1, accepted: 2, @@ -28,25 +19,33 @@ const stateWeights: Record = { active: 4, } as const; -export class MediaSignalingSession extends Emitter { - private _sessionId: string; +export type MediaSignalingEvents = { + callStateChange: { call: IClientMediaCall; oldState: CallState }; + newCall: { call: IClientMediaCall }; +}; - private _userId: string; - - private version = 1.0; +export type MediaSignalingSessionConfig = { + userId: string; + processorFactories: IServiceProcessorFactoryList; + transport: MediaSignalTransport; +}; - private tricklingSignals = new Set(); +export class MediaSignalingSession extends Emitter { + private _userId: string; - // #ToDo: handle processor - private processor!: IWebRTCProcessor; + private readonly _sessionId: string; - private knownCalls: Map; + private knownCalls: Map; // Store contact information for calls that are not yet known - private contactInformation: Map>; + private contactInformation: Map; private ignoredCalls: Set; + private failedCalls: Set; + + private transporter: MediaSignalTransportWrapper; + public get sessionId(): string { return this._sessionId; } @@ -55,38 +54,34 @@ export class MediaSignalingSession(); - this.contactInformation = new Map>(); - this.ignoredCalls = new Set(); - this._userId = userId; + this._userId = config.userId; this._sessionId = createRandomToken(8); + this.knownCalls = new Map(); + this.contactInformation = new Map(); + this.ignoredCalls = new Set(); + this.failedCalls = new Set(); + + this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport); } public isBusy(): boolean { return this.hasAnyCallState(['ringing', 'accepted', 'active']); } - public getCallData(callId: IClientMediaCall['callId']): IClientMediaCall | null { - const call = this.knownCalls.get(callId); - if (call) { - return { - ...call, - }; - } - - return null; + public getCallData(callId: string): IClientMediaCall | null { + return this.knownCalls.get(callId) || null; } - public getAllCallStates(): IClientMediaCall['state'][] { + public getAllCallStates(): CallState[] { return this.knownCalls .values() .map(({ state }) => state) .toArray(); } - public hasAnyCallState(states: IClientMediaCall['state'][]): boolean { + public hasAnyCallState(states: CallState[]): boolean { const knownStates = this.getAllCallStates(); for (const state of knownStates) { if (states.includes(state)) { @@ -116,8 +111,8 @@ export class MediaSignalingSession { + if (this.isSignalTargetingAnotherSession(signal)) { return; } @@ -129,183 +124,34 @@ export class MediaSignalingSession); - case 'answer': - return this.processAnswerRequest(signal as MediaSignalRequest<'answer'>); - case 'sdp': - return this.processSdpRequest(signal as MediaSignalRequest<'sdp'>); - } - } - - private async processOfferRequest(signal: MediaSignalRequest<'offer'>) { - // #ToDo: processor - let offer: DeliverParams<'sdp'> | null = null; - try { - offer = await this.processor.createOffer(signal.body); - } catch (e) { - this.sendError(signal, 'failed-to-create-offer'); - throw e; - } - - if (!offer) { - this.sendError(signal, 'implementation-error'); - } - - await this.deliverSdp(signal, offer); - } - - private async processAnswerRequest(signal: MediaSignalRequest<'answer'>) { - let answer: DeliverParams<'sdp'> | null = null; - try { - answer = await this.processor.createAnswer(signal.body); - } catch (e) { - this.sendError(signal, 'failed-to-create-answer'); - throw e; - } - - if (!answer) { - this.sendError(signal, 'implementation-error'); - } - - await this.deliverSdp(signal, answer); - } - - private async processSdpRequest(signal: MediaSignalRequest<'sdp'>) { - let sdp: DeliverParams<'sdp'> | null = null; - try { - sdp = await this.processor.collectLocalDescription(signal.body); - } catch (e) { - this.sendError(signal, 'failed-to-collect-description'); - throw e; - } - - if (!sdp) { - this.sendError(signal, 'implementation-error'); - } - - await this.deliverSdp(signal, sdp); - } - - private async deliverSdp(requestSignal: MediaSignal, sdp: DeliverParams<'sdp'>) { - // If we're trickling ICE, keep the signal reference for upcoming candidates - if (!sdp.endOfCandidates) { - this.tricklingSignals.add(requestSignal); - } - - return this.deliverToServer(requestSignal, 'sdp', sdp); - } - - private async processDeliver(signal: MediaSignalDeliver) { - if (!signal.sessionId) { - throw new Error('Delivery signals MUST target a specific session.'); - } + const call = this.knownCalls.get(signal.callId); + if (!call) { + if (this.failedCalls.has(signal.callId)) { + // do something? + return; + } - switch (signal.body.deliver) { - case 'sdp': - await this.processor.setRemoteDescription(signal.body); - break; - case 'ice-candidates': - await this.processor.addIceCandidates(signal.body); - break; - case 'dtmf': - // #ToDo - break; + // #ToDo: Hold on to unexpected untargeted signals for a few seconds and process them if a notifyNew arrives + console.error('Unexpected Signal', signal); + throw new Error('Unexpected Signal received.'); } - } - private async processNotify(signal: MediaSignalNotify) { - switch (signal.body.notify) { - case 'new': - return this.processNewCall(signal, true); - case 'error': - break; - case 'ack': - break; - // case 'invalid': - // break; - // case 'unable': - // break; - // case 'empty': - // break; - case 'state': - break; - case 'unavailable': - break; - case 'accept': - break; - case 'reject': - break; - case 'hangup': - break; - case 'negotiation-needed': - break; - case 'multi': - break; - } + await call.processSignal(signal); } - protected isCallKnown(callId: string): boolean { - return this.knownCalls.has(callId); - } - - protected getStoredCallContact(callId: string): Record { + public getStoredCallContact(callId: string): CallContact { return { ...this.contactInformation.get(callId), ...this.knownCalls.get(callId)?.contact, }; } - protected setCallContact(callId: string, contact: Record): void { + public setCallContact(callId: string, contact: Record): void { const oldContact = this.getStoredCallContact(callId); const fullContact = { ...oldContact, ...contact }; @@ -315,174 +161,85 @@ export class MediaSignalingSession) { // If we already know about this call, we don't need to process anything if (this.isCallKnown(signal.callId)) { return; } - if (!this.isValidRole(signal.role)) { - console.error('Invalid call role on signal', signal); - return; - } + try { + if (!isCallRole(signal.role)) { + throw new Error('invalid-role'); + } - // Contact will probaby already be stored if this call was requested by this same session - const contact = this.getStoredCallContact(signal.callId); - const call: IClientMediaCall = { - callId: signal.callId, - role: signal.role, - state: 'none', - ignored: this.isCallIgnored(signal.callId), - contact, - }; + // Contact will probaby already be stored if this call was requested by this same session + const contact = this.getStoredCallContact(signal.callId); + const callData: IClientMediaCallData = { + callId: signal.callId, + role: signal.role, + state: 'none', + service: signal.body.service, + ignored: this.isCallIgnored(signal.callId) || this.isBusy(), + contact, + }; - const busy = this.isBusy(); + const call = await this.registerCall(callData); - this.registerCall(call); + await call.initialize(signal); - // If we are busy with another call, we can't take this one - though the server is supposed to know our state - if (busy) { - if (signal.expectACK || isNewNotify) { - return this.notifyServer(signal, 'unavailable'); - } - return; - } - - if (call.ignored) { - return; - } - - try { - await this.initializePeerConnection(); + this.emit('newCall', { call }); } catch (e) { - this.sendError(signal, 'failed-to-initialize-peer'); - throw e; - } + this.failedCalls.add(signal.callId); + const errorCode: string = (e && typeof e === 'object' && (e as any).name) || 'call-initialization-failed'; - // If we're free, ACK the new call - if (signal.expectACK || isNewNotify) { - this.sendACK(signal); + this.transporter.sendError(signal, errorCode); + throw e; } - - // If the call is targeted, flag it as accepted directly - this.setCallState(signal.callId, signal.sessionId ? 'accepted' : 'ringing'); } - protected registerCall(call: IClientMediaCall): void { - this.knownCalls.set(call.callId, call); - } + private async registerCall(callData: IClientMediaCallData): Promise { + const webrtcProcessor = await this.createWebRtcProcessor(); - protected setCallState(callId: string, state: IClientMediaCall['state']): void { - const call = this.knownCalls.get(callId); - if (!call) { - this.unknownCall(callId); - return; - } + const config = { + transporter: this.transporter, + webrtcProcessor, + }; - if (call.state === state) { - return; - } + const call = new ClientMediaCall(config, callData); - this.knownCalls.set(callId, { ...call, state }); - this.callStateChanged(callId, state); + this.knownCalls.set(call.callId, call); + return call; } - protected callStateChanged(callId: string, state: IClientMediaCall['state']): void { - console.log('callStateChanged', callId, state); - } + private async createWebRtcProcessor(): Promise { + const { webrtc: webrtcFactory } = this.config.processorFactories; - protected unknownCall(callId: string): void { - if (this.ignoredCalls.has(callId)) { - return; + if (!webrtcFactory) { + throw new Error('webrtc-not-implemented'); } - throw new Error('invalid-call'); - } - - private deliverToServer(requestSignal: MediaSignal, type: T, params: DeliverParams) { - return this.sendSignalToServer( - { - type: 'deliver', - body: { - deliver: type, - ...params, - } as DeliverBody, - }, - requestSignal, - ); - } - - private notifyServer(requestSignal: MediaSignal, type: T, params?: NotifyParams) { - return this.sendSignalToServer( - { - type: 'notify', - body: { - notify: type, - ...params, - } as NotifyBody, - }, - requestSignal, - ); - } - - private sendError(requestSignal: MediaSignal, errorCode: string, errorText?: string) { - this.notifyServer(requestSignal, 'error', { - errorCode, - ...(errorText && { errorText }), - }); - } - - private sendACK(requestSignal: MediaSignal) { - return this.notifyServer(requestSignal, 'ack'); - } - - private sendSignalToServer(signal: ClientMediaSignal, requestSignal: MediaSignal) { - const header: MediaSignalHeaderParams = { - callId: requestSignal.callId, - sequence: requestSignal.sequence, - role: requestSignal.role, - version: this.version, - sessionId: this._sessionId, - }; - - const newSignal = { - ...signal, - ...header, - } as T; - console.log(newSignal); + return webrtcFactory(); } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts new file mode 100644 index 0000000000000..05bda868155ac --- /dev/null +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -0,0 +1,70 @@ +import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; +import type { ClientMediaSignal, MediaSignal } from '../definition/signal/MediaSignal'; +import type { DeliverBody, DeliverParams, DeliverType } from '../definition/signal/MediaSignalDeliver'; +import type { MediaSignalHeaderParams } from '../definition/signal/MediaSignalHeader'; +import type { NotifyBody, NotifyParams, NotifyType } from '../definition/signal/MediaSignalNotify'; + +export class MediaSignalTransportWrapper { + private readonly version = 1.0; + + constructor( + public readonly sessionId: string, + private sendSignalFn: MediaSignalTransport, + ) {} + + public deliverToServer(requestSignal: MediaSignal, type: T, params: DeliverParams) { + return this.sendSignalToServer( + { + type: 'deliver', + body: { + deliver: type, + ...params, + } as DeliverBody, + }, + requestSignal, + ); + } + + public notifyServer(requestSignal: MediaSignal, type: T, params?: NotifyParams) { + return this.sendSignalToServer( + { + type: 'notify', + body: { + notify: type, + ...params, + } as NotifyBody, + }, + requestSignal, + ); + } + + public sendError(requestSignal: MediaSignal, errorCode: string, errorText?: string) { + this.notifyServer(requestSignal, 'error', { + errorCode, + ...(errorText && { errorText }), + }); + } + + public sendSignalToServer(signal: ClientMediaSignal, requestSignal: MediaSignal) { + const header: MediaSignalHeaderParams = { + callId: requestSignal.callId, + sequence: requestSignal.sequence, + role: requestSignal.role, + version: this.version, + sessionId: this.sessionId, + }; + + const newSignal = { + ...signal, + ...header, + } as T; + + return this.sendSignal(newSignal); + } + + public sendSignal(signal: MediaSignal): void { + console.log('MediaSignalTransport.sendSignal', signal); + this.sendSignalFn(signal); + // this.emit('signal', { signal }); + } +} diff --git a/packages/media-signaling/src/lib/utils/isNotifyNew.ts b/packages/media-signaling/src/lib/utils/isNotifyNew.ts new file mode 100644 index 0000000000000..8883de4cd4429 --- /dev/null +++ b/packages/media-signaling/src/lib/utils/isNotifyNew.ts @@ -0,0 +1,5 @@ +import type { MediaSignal, MediaSignalNotify } from '../../definition'; + +export function isNotifyNew(signal: MediaSignal): signal is MediaSignalNotify<'new'> { + return signal.type === 'notify' && signal.body.notify === 'new'; +} diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts index 35fbd63982c71..13f3a6452c8c1 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -1,15 +1,299 @@ -import { mediaSignalSchema, type MediaSignal } from '@rocket.chat/media-signaling'; +import { type MediaSignal } from '@rocket.chat/media-signaling'; import type { JSONSchemaType } from 'ajv'; import Ajv from 'ajv'; -const ajv = new Ajv(); +const ajv = new Ajv({ discriminator: true }); export type MediaCallsSignalProps = { signal: MediaSignal }; +export type SDP = RTCSessionDescriptionInit; + +export const sdpSchema: JSONSchemaType = { + type: 'object', + properties: { + sdp: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + nullable: false, + required: ['type'], +}; + +export type IceCandidate = RTCIceCandidateInit; + +export const iceCandidateSchema: JSONSchemaType = { + type: 'object', + properties: { + candidate: { + type: 'string', + nullable: true, + }, + sdpMLineIndex: { + type: 'number', + nullable: true, + }, + sdpMid: { + type: 'string', + nullable: true, + }, + usernameFragment: { + type: 'string', + nullable: true, + }, + }, + additionalProperties: false, + nullable: false, + required: [], +}; + +const mandatoryHeader = { + callId: { + type: 'string', + nullable: false, + }, + sessionId: { + type: 'string', + nullable: true, + }, + version: { + type: 'number', + nullable: false, + }, + sequence: { + type: 'number', + nullable: false, + }, + role: { + type: 'string', + nullable: false, + }, +}; +const optionalHeader = { + expectACK: { + type: 'boolean', + nullable: true, + }, +}; + const mediaCallsSignalPropsSchema: JSONSchemaType = { type: 'object', properties: { - signal: mediaSignalSchema, + signal: { + type: 'object', + additionalProperties: true, + discriminator: { propertyName: 'type' }, + required: ['type'], + oneOf: [ + { + type: 'object', + properties: { + ...mandatoryHeader, + ...optionalHeader, + type: { + const: 'request', + }, + body: { + type: 'object', + discriminator: { propertyName: 'request' }, + oneOf: [ + { + properties: { + request: { const: 'offer' }, + iceRestart: { + type: 'boolean', + nullable: true, + }, + }, + }, + { + properties: { + request: { const: 'answer' }, + offer: sdpSchema, + }, + }, + { + properties: { + request: { const: 'sdp' }, + }, + }, + ], + + additionalProperties: true, + required: ['request'], + }, + }, + additionalProperties: false, + required: [...Object.keys(mandatoryHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...mandatoryHeader, + ...optionalHeader, + type: { + const: 'deliver', + }, + body: { + type: 'object', + discriminator: { propertyName: 'deliver' }, + oneOf: [ + { + properties: { + deliver: { const: 'sdp' }, + sdp: sdpSchema, + endOfCandidates: { + type: 'boolean', + nullable: false, + }, + }, + required: ['deliver', 'sdp', 'endOfCandidates'], + }, + { + properties: { + deliver: { const: 'ice-candidates' }, + candidates: { + type: 'array', + items: iceCandidateSchema, + }, + endOfCandidates: { + type: 'boolean', + nullable: false, + }, + }, + required: ['deliver', 'candidates', 'endOfCandidates'], + }, + { + properties: { + deliver: { const: 'dtmf' }, + tone: { + type: 'string', + nullable: false, + }, + duration: { + type: 'number', + nullable: true, + }, + }, + required: ['deliver', 'tone'], + }, + ], + required: ['deliver'], + }, + }, + additionalProperties: false, + required: [...Object.keys(mandatoryHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...mandatoryHeader, + ...optionalHeader, + type: { + const: 'notify', + }, + body: { + type: 'object', + discriminator: { propertyName: 'notify' }, + oneOf: [ + { + properties: { + notify: { const: 'ack' }, + }, + }, + { + properties: { + notify: { const: 'error' }, + errorCode: { + type: 'string', + nullable: false, + }, + errorText: { + type: 'string', + nullable: true, + }, + }, + required: ['notify', 'errorCode'], + }, + { + properties: { + notify: { const: 'new' }, + service: { + const: 'webrtc', + }, + kind: { + const: 'direct', + }, + }, + required: ['notify', 'service', 'kind'], + }, + { + properties: { + notify: { const: 'state' }, + callState: { + type: 'string', + }, + serviceState: { + type: 'string', + }, + mediaState: { + type: 'string', + }, + }, + required: ['notify', 'callState', 'serviceState', 'mediaState'], + }, + { + properties: { + notify: { const: 'unavailable' }, + }, + }, + { + properties: { + notify: { const: 'accept' }, + }, + }, + { + properties: { + notify: { const: 'reject' }, + }, + }, + { + properties: { + notify: { const: 'hangup' }, + reasonCode: { + type: 'string', + nullable: false, + }, + reasonText: { + type: 'string', + nullable: true, + }, + }, + required: ['notify', 'reasonCode'], + }, + { + properties: { + notify: { const: 'negotiation-needed' }, + reason: { + type: 'string', + nullable: true, + }, + }, + }, + ], + required: ['notify'], + }, + }, + additionalProperties: false, + required: [...Object.keys(mandatoryHeader), 'type', 'body'], + }, + ], + }, }, required: ['signal'], additionalProperties: true, diff --git a/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts b/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts similarity index 64% rename from packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts rename to packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts index 5078a35ea2514..fbedf4ba41feb 100644 --- a/packages/media-signaling/src/lib/webrtc/WebRTCMediaCall.ts +++ b/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts @@ -1,36 +1,20 @@ -import type { IWebRTCProcessor } from '../../definition/IWebRTCProcessor'; -import type { DeliverParams } from '../../definition/MediaSignalDeliver'; -import type { RequestParams } from '../../definition/MediaSignalRequest'; +import { DeliverParams, IWebRTCProcessor, RequestParams } from '@rocket.chat/media-signaling'; -export class WebRTCMediaCall implements IWebRTCProcessor { - // #ToDo peer instance state - private peer: RTCPeerConnection | undefined; +export class MediaCallWebRTCProcessor implements IWebRTCProcessor { + private peer: RTCPeerConnection; private iceGatheringFinished = false; - private restartIce() { - this.iceGatheringFinished = false; - - this.peer?.restartIce(); - } - constructor() { - // + this.peer = new RTCPeerConnection(); } - public async initializePeerConnection(configuration?: RTCConfiguration): Promise { - this.peer = new RTCPeerConnection(configuration); + private restartIce() { + this.iceGatheringFinished = false; + this.peer.restartIce(); } - // private onTrack(event) { - // // - // } - public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { - if (!this.peer) { - throw new Error('peer-not-initialized'); - } - if (iceRestart) { this.restartIce(); } @@ -45,10 +29,6 @@ export class WebRTCMediaCall implements IWebRTCProcessor { } public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { - if (!this.peer) { - throw new Error('peer-not-initialized'); - } - if (this.peer.remoteDescription?.sdp !== offer.sdp) { this.peer.setRemoteDescription(offer); } @@ -63,10 +43,6 @@ export class WebRTCMediaCall implements IWebRTCProcessor { } public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { - if (!this.peer) { - throw new Error('peer-not-initialized'); - } - const sdp = this.peer.localDescription; if (!sdp) { @@ -80,18 +56,10 @@ export class WebRTCMediaCall implements IWebRTCProcessor { } public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { - if (!this.peer) { - throw new Error('peer-not-initialized'); - } - this.peer.setRemoteDescription(sdp); } public async addIceCandidates({ candidates }: DeliverParams<'ice-candidates'>): Promise { - if (!this.peer) { - throw new Error('peer-not-initialized'); - } - const results = await Promise.allSettled(candidates.map((candidate) => this.peer?.addIceCandidate(candidate))); for (const result of results) { diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts index daf20cbb5baa5..aeb627751d80d 100644 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -3,6 +3,7 @@ import { Emitter } from '@rocket.chat/emitter'; import { IClientMediaCall, MediaSignal, MediaSignalingSession } from '@rocket.chat/media-signaling'; import type { ContactInfo, VoipSession } from '../definitions'; +import { MediaCallWebRTCProcessor } from './MediaCallWebRTCProcessor'; import RemoteStream from './RemoteStream'; export type VoipEvents = Omit & { @@ -22,12 +23,12 @@ type SessionError = { export type MediaCallsClientConfig = { userId: IUser['_id']; startCallFn: (params: { roomId: string; sessionId: string }) => Promise; - sendSignalFn: (signal: MediaSignal) => Promise; + sendSignalFn: (signal: MediaSignal) => void; }; export type MediaCallsCallee = { uid?: IUser['_id']; rid?: IRoom['_id']; extension?: string }; -class MediaCallsClient extends MediaSignalingSession { +class MediaCallsClient extends Emitter { public networkEmitter: Emitter; private audioElement: HTMLAudioElement | null = null; @@ -46,10 +47,22 @@ class MediaCallsClient extends MediaSignalingSession { private startingNewCall = false; + private session: MediaSignalingSession; + constructor(private readonly config: MediaCallsClientConfig) { - super(config.userId); + super(); + + this.session = new MediaSignalingSession({ + userId: config.userId, + transport: (signal: MediaSignal) => config.sendSignalFn(signal), + processorFactories: { + webrtc: () => new MediaCallWebRTCProcessor(), + }, + }); this.networkEmitter = new Emitter(); + + this.session.on('newCall', ({ call }) => this.onNewCall(call)); } public async init() { @@ -64,7 +77,11 @@ class MediaCallsClient extends MediaSignalingSession { } public isBusy(): boolean { - return this.startingNewCall || super.isBusy(); + return this.startingNewCall || this.session.isBusy(); + } + + public async processSignal(signal: MediaSignal): Promise { + return this.session.processSignal(signal); } public async call(callee: MediaCallsCallee): Promise { @@ -152,12 +169,16 @@ class MediaCallsClient extends MediaSignalingSession { return this.error.contact; } - const call = this.getMainCall(); + const call = this.session.getMainCall(); if (!call) { return null; } - const contactData = this.getStoredCallContact(call.callId); + const contactData = this.session.getStoredCallContact(call.callId); + if (!contactData) { + return null; + } + return { id: contactData.id, name: contactData.displayName, @@ -198,11 +219,11 @@ class MediaCallsClient extends MediaSignalingSession { } public isCaller(): boolean { - return this.getMainCall()?.role === 'caller'; + return this.session.getMainCall()?.role === 'caller'; } public isCallee(): boolean { - return this.getMainCall()?.role === 'callee'; + return this.session.getMainCall()?.role === 'callee'; } public isIncoming(): boolean { @@ -250,7 +271,7 @@ class MediaCallsClient extends MediaSignalingSession { }; public getSessionType(): VoipSession['type'] | null { - const call = this.getMainCall(); + const call = this.session.getMainCall(); if (!call) { return null; } @@ -331,15 +352,8 @@ class MediaCallsClient extends MediaSignalingSession { // window.removeEventListener('offline', this.onNetworkLost); } - protected callStateChanged(callId: string, state: IClientMediaCall['state']): void { - super.callStateChanged(callId, state); - - const call = this.getCallData(callId); - if (!call) { - this.emit('stateChanged'); - return; - } - + private onNewCall(call: IClientMediaCall): void { + // #ToDo: use call reference for getContactInfo if (call.role === 'callee') { this.emit('incomingcall', this.getContactInfo() as ContactInfo); } else if (call.role === 'caller') { @@ -358,11 +372,11 @@ class MediaCallsClient extends MediaSignalingSession { try { this.emit('stateChanged'); - const call = await this.config.startCallFn({ roomId, sessionId: this.sessionId }); + const call = await this.config.startCallFn({ roomId, sessionId: this.session.sessionId }); - console.log(call); + console.log('startCall', call); const { _id: callId, callee } = call; - this.setCallContact(callId, callee); + this.session.setCallContact(callId, callee); this.emit('stateChanged'); } finally { @@ -370,10 +384,6 @@ class MediaCallsClient extends MediaSignalingSession { } } - protected async sendSignal(signal: MediaSignal): Promise { - return this.config.sendSignalFn(signal); - } - // private setupRemoteMedia() { // // const { remoteMediaStream } = this.sessionDescriptionHandler; diff --git a/yarn.lock b/yarn.lock index 552a0e5244fe2..8189e83159fba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7834,7 +7834,6 @@ __metadata: "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" - ajv: "npm:^8.17.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" typescript: "npm:~5.8.3" From 0374b5ab1123e2229182a51c07da6a7bf22610be Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 28 Jul 2025 20:06:33 -0300 Subject: [PATCH 12/72] accept calls, exchange offers and answers --- .../src/calls/acknowledgeCallee.ts | 23 +++ .../src/calls/processAcceptedCall.ts | 67 ++++++++ .../src/channels/getChannelByCallIdAndRole.ts | 21 +++ .../src/channels/getOppositeChannel.ts | 29 +++- .../src/channels/requestChannelOffer.ts | 5 + .../src/channels/setRemoteSDP.ts | 9 +- .../src/signals/deliver/sdp.ts | 1 + .../src/signals/notify/accept.ts | 63 +++----- .../src/signals/notify/ack.ts | 22 +-- .../src/signals/sendSignalToAllActors.ts | 46 ++++++ .../src/signals/sendSignalToChannels.ts | 11 ++ .../src/utils/compareParticipantAndActor.ts | 15 ++ .../src/definition/IWebRTCProcessor.ts | 10 +- .../src/definition/call/CallEvents.ts | 2 + .../src/definition/call/IClientMediaCall.ts | 6 + packages/media-signaling/src/lib/Call.ts | 110 +++++++++++--- packages/media-signaling/src/lib/Session.ts | 36 ++--- .../src/models/IMediaCallsModel.ts | 4 +- packages/models/src/models/MediaCalls.ts | 52 +++++-- packages/ui-voip/src/hooks/useVoipAPI.tsx | 2 +- .../src/lib/MediaCallWebRTCProcessor.ts | 143 +++++++++++++++++- packages/ui-voip/src/lib/MediaCallsClient.ts | 84 ++++++---- 22 files changed, 595 insertions(+), 166 deletions(-) create mode 100644 packages/media-signaling-server/src/calls/acknowledgeCallee.ts create mode 100644 packages/media-signaling-server/src/calls/processAcceptedCall.ts create mode 100644 packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts create mode 100644 packages/media-signaling-server/src/signals/sendSignalToAllActors.ts create mode 100644 packages/media-signaling-server/src/signals/sendSignalToChannels.ts create mode 100644 packages/media-signaling-server/src/utils/compareParticipantAndActor.ts diff --git a/packages/media-signaling-server/src/calls/acknowledgeCallee.ts b/packages/media-signaling-server/src/calls/acknowledgeCallee.ts new file mode 100644 index 0000000000000..c9e42a925b081 --- /dev/null +++ b/packages/media-signaling-server/src/calls/acknowledgeCallee.ts @@ -0,0 +1,23 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { getOppositeChannel } from '../channels/getOppositeChannel'; +import { requestChannelOffer } from '../channels/requestChannelOffer'; +import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; + +export async function acknowledgeCallee(call: IMediaCall, channel: ValidSignalChannel): Promise { + // If we're not the callee, or we are a different callee session than the one where the call was accepted, do nothing + if (!compareParticipantAndActor(channel.participant, call.callee)) { + return; + } + + const callerChannel = await getOppositeChannel(call, channel); + + // Change the call state from 'none' to 'ringing' when any callee session is found + const result = await MediaCalls.startRingingById(call._id); + + // If the state was changed and the caller channel is set, request the webrtc offer from the caller + if (result.modifiedCount && callerChannel) { + await requestChannelOffer(callerChannel); + } +} diff --git a/packages/media-signaling-server/src/calls/processAcceptedCall.ts b/packages/media-signaling-server/src/calls/processAcceptedCall.ts new file mode 100644 index 0000000000000..d80cfc8bd21c9 --- /dev/null +++ b/packages/media-signaling-server/src/calls/processAcceptedCall.ts @@ -0,0 +1,67 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { getNewCallSequence } from './getNewCallSequence'; +import { getActorChannel } from '../channels/getActorChannel'; +// import { requestChannelOffer } from '../channels/requestChannelOffer'; +import { setRemoteSDP } from '../channels/setRemoteSDP'; +import { isValidSignalChannel } from '../signals/isValidSignalChannel'; +import { sendSignalToChannels } from '../signals/sendSignalToChannels'; +import { logger } from '../utils/logger'; + +export async function processAcceptedCall(callId: IMediaCall['_id']): Promise { + const call = await MediaCalls.findOneById(callId); + if (!call) { + throw new Error('error-failed-to-accept-call'); + } + + const callerChannel = await getActorChannel(call._id, call.caller); + if (!callerChannel) { + throw new Error('error-failed-to-locate-caller-channel'); + } + + const offer = callerChannel.webrtc?.local; + + // If the caller doesn't have a webrtc offer yet, we need to wait for it + if (!offer) { + // await requestChannelOffer(callerChannel); + return; + } + + if (offer.description?.type !== 'offer') { + logger.error({ msg: 'The local description of a caller channel is not an offer', local: offer }); + throw new Error('unexpected-state'); + } + + const calleeChannel = await getActorChannel(call._id, call.callee); + if (!calleeChannel) { + throw new Error('error-failed-to-locate-callee-channel'); + } + + // If the callee doesn't have the offer yet, set it and request an answer, but do not wait for it + if (!calleeChannel.webrtc?.remote) { + await setRemoteSDP( + calleeChannel, + { + sdp: offer.description, + endOfCandidates: offer.iceGatheringComplete, + }, + offer.assignSequence, + ); + } + + const validChannels = [callerChannel, calleeChannel].filter((channel) => isValidSignalChannel(channel)); + + if (validChannels.length) { + const newSequence = await getNewCallSequence(callId); + if (newSequence) { + await sendSignalToChannels(validChannels, { + sequence: newSequence.sequence, + type: 'notify', + body: { + notify: 'accept', + }, + }); + } + } +} diff --git a/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts b/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts new file mode 100644 index 0000000000000..a0a5b1a9f056e --- /dev/null +++ b/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts @@ -0,0 +1,21 @@ +import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { getActorChannel } from './getActorChannel'; + +export async function getChannelByCallIdAndRole( + callId: IMediaCall['_id'], + role: T, +): Promise { + const call = await MediaCalls.findOneById>(callId, { projection: { [role]: 1 } }); + if (!call) { + return null; + } + + const { [role]: actor } = call; + if (!actor) { + return null; + } + + return getActorChannel(callId, actor); +} diff --git a/packages/media-signaling-server/src/channels/getOppositeChannel.ts b/packages/media-signaling-server/src/channels/getOppositeChannel.ts index de0dd2180305e..1093fc5c6ac31 100644 --- a/packages/media-signaling-server/src/channels/getOppositeChannel.ts +++ b/packages/media-signaling-server/src/channels/getOppositeChannel.ts @@ -1,14 +1,27 @@ import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; import { getActorChannel } from './getActorChannel'; +import { getChannelByCallIdAndRole } from './getChannelByCallIdAndRole'; +import { isValidSignalRole } from '../signals/isValidSignalRole'; -export async function getOppositeChannel(call: IMediaCall, channel: IMediaCallChannel): Promise { - switch (channel.role) { - case 'callee': - return getActorChannel(call._id, call.caller); - case 'caller': - return getActorChannel(call._id, call.callee); - default: - return null; +export async function getOppositeChannel( + call: IMediaCall, + channel: IMediaCallChannel, + options?: { reloadCallIfNull?: boolean }, +): Promise { + if (!isValidSignalRole(channel.role)) { + return null; } + + const { reloadCallIfNull = false } = options || {}; + + const oppositeRole = channel.role === 'caller' ? 'callee' : 'caller'; + const otherChannel = await getActorChannel(call._id, call[oppositeRole]); + if (otherChannel || !reloadCallIfNull) { + return otherChannel; + } + + // If no channel was found, using the data from the call object, reload the call and try again + // This is needed because the call might have been accepted by ther other participant at the same time as this request. + return getChannelByCallIdAndRole(call._id, oppositeRole); } diff --git a/packages/media-signaling-server/src/channels/requestChannelOffer.ts b/packages/media-signaling-server/src/channels/requestChannelOffer.ts index a4c18cae7187e..37e33026b3f90 100644 --- a/packages/media-signaling-server/src/channels/requestChannelOffer.ts +++ b/packages/media-signaling-server/src/channels/requestChannelOffer.ts @@ -6,6 +6,11 @@ import { sendSignalToChannel } from '../signals/sendSignalToChannel'; import { validateChannelForSignals } from '../signals/validateChannelForSignals'; export async function requestChannelOffer(channel: IMediaCallChannel, params?: RequestParams<'offer'>): Promise { + // If the channel already has a local Sdp, no need to request its offer unless we're restarting ICE + if (channel.webrtc?.local && !params?.iceRestart) { + return; + } + validateChannelForSignals(channel); const call = await getNewCallSequence(channel.callId); diff --git a/packages/media-signaling-server/src/channels/setRemoteSDP.ts b/packages/media-signaling-server/src/channels/setRemoteSDP.ts index 827f344457053..16ff29fdd9c17 100644 --- a/packages/media-signaling-server/src/channels/setRemoteSDP.ts +++ b/packages/media-signaling-server/src/channels/setRemoteSDP.ts @@ -5,12 +5,17 @@ import { MediaCallChannels } from '@rocket.chat/models'; import { deliverChannelSDP } from './deliverChannelSDP'; export async function setRemoteSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>, sequence: number): Promise { - await MediaCallChannels.setRemoteWebRTCSession(channel._id, { + const result = await MediaCallChannels.setRemoteWebRTCSession(channel._id, { description: params.sdp, iceCandidates: [], iceGatheringComplete: Boolean(params.endOfCandidates), assignSequence: sequence, }); + if (!result.modifiedCount) { + console.log('remote sdp NOT changed for ', channel.role); + return; + } + console.log('remote sdp changed for ', channel.role); if (channel.participant.type !== 'user') { // No need to send any signals if the remote participant is not a rocket.chat user @@ -21,4 +26,6 @@ export async function setRemoteSDP(channel: IMediaCallChannel, params: DeliverPa sdp: params.sdp, endOfCandidates: params.endOfCandidates, }); + + // #ToDo: deliver ice candidates } diff --git a/packages/media-signaling-server/src/signals/deliver/sdp.ts b/packages/media-signaling-server/src/signals/deliver/sdp.ts index c589722b90bad..1b1ff6c643d5e 100644 --- a/packages/media-signaling-server/src/signals/deliver/sdp.ts +++ b/packages/media-signaling-server/src/signals/deliver/sdp.ts @@ -16,6 +16,7 @@ export async function processSDP(signal: MediaSignalDeliver<'sdp'>, call: IMedia // Find the opposite channel and save the SDP there as well const otherChannel = await getOppositeChannel(call, channel); + // otherChannel will only be defined if the other participant has already accepted the call if (otherChannel) { await setRemoteSDP( otherChannel, diff --git a/packages/media-signaling-server/src/signals/notify/accept.ts b/packages/media-signaling-server/src/signals/notify/accept.ts index 52eaea83bb514..a53a2fde3e0e7 100644 --- a/packages/media-signaling-server/src/signals/notify/accept.ts +++ b/packages/media-signaling-server/src/signals/notify/accept.ts @@ -2,55 +2,32 @@ import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; -import { getNewCallSequence } from '../../calls/getNewCallSequence'; -import { getOppositeChannel } from '../../channels/getOppositeChannel'; -import { compareActorsIgnoringSession } from '../../utils/compareActorsIgnoringSession'; -import { isValidSignalChannel } from '../isValidSignalChannel'; -import { sendSignalToChannel } from '../sendSignalToChannel'; +import { processAcceptedCall } from '../../calls/processAcceptedCall'; +import { compareParticipantAndActor } from '../../utils/compareParticipantAndActor'; +import { isValidSignalRole } from '../isValidSignalRole'; export async function processAccept(_signal: MediaSignalNotify<'accept'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - if (channel.role === 'callee') { - if (!compareActorsIgnoringSession(call.callee, channel.participant)) { - // Someone else tried to accept the call... should we respond something? - return; - } - const sessionId = (channel.participant.type === 'user' && channel.participant.sessionId) || undefined; - - const result = await MediaCalls.acceptCallById(call._id, sessionId); - if (!result.modifiedCount) { - // # nothing was changed by this acceptance... should we respond something? - return; - } - - const newSequence = await getNewCallSequence(channel.callId); - await sendSignalToChannel(channel, { - sequence: newSequence.sequence, - type: 'notify', - body: { - notify: 'state', - callState: 'accepted', - }, - }); + if (!isValidSignalRole(channel.role)) { + throw new Error('error-invalid-role'); + } - const otherChannel = await getOppositeChannel(call, channel); - if (otherChannel && isValidSignalChannel(otherChannel)) { - await sendSignalToChannel(otherChannel, { - sequence: newSequence.sequence, - type: 'notify', - body: { - notify: 'state', - callState: 'accepted', - }, - }); - } + // If the channel's participant doesn't match the actor assigned to the call for this role, do nothing + if (!compareParticipantAndActor(channel.participant, call[channel.role])) { + return; + } + const result = await MediaCalls.setActorSessionIdByIdAndRole(call._id, channel.participant.sessionId, channel.role); + if (!result.modifiedCount) { + // If nothing changed, the call already had a sessionId for this actor return; } - if (channel.role === 'caller' && call.caller.type === 'user' && !call.caller.sessionId) { - const result = await MediaCalls.setCallerSessionIdById(call._id, channel.participant.sessionId); - if (result.modifiedCount) { - // #Todo: Calls initiated without a caller sessionId - } + // With session decided for this actor, let's try to move the call state to 'accepted'. This will only work if the other actor also has an assigned session + const stateResult = await MediaCalls.acceptCallById(call._id); + if (!stateResult.modifiedCount) { + return; } + + // #ToDo: notify client if this throws any error + return processAcceptedCall(call._id); } diff --git a/packages/media-signaling-server/src/signals/notify/ack.ts b/packages/media-signaling-server/src/signals/notify/ack.ts index 1012ac089244b..17aa74af06150 100644 --- a/packages/media-signaling-server/src/signals/notify/ack.ts +++ b/packages/media-signaling-server/src/signals/notify/ack.ts @@ -1,22 +1,14 @@ import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; -import { getOppositeChannel } from '../../channels/getOppositeChannel'; -import { requestChannelOffer } from '../../channels/requestChannelOffer'; +import { acknowledgeCallee } from '../../calls/acknowledgeCallee'; +// import { acknowledgeCaller } from '../../calls/acknowledgeCaller'; export async function processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - // If the call is still on an empty state and a signal reached a callee, change the state to 'ringing' - - if (channel.role === 'callee' && call.state === 'none') { - const result = await MediaCalls.startRingingById(call._id); - - // If the state was changed, request an offer from the caller - if (result.modifiedCount) { - const otherChannel = await getOppositeChannel(call, channel); - if (otherChannel) { - await requestChannelOffer(channel); - } - } + switch (channel.role) { + case 'callee': + return acknowledgeCallee(call, channel); + // case 'caller': + // return acknowledgeCaller(call, channel); } } diff --git a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts new file mode 100644 index 0000000000000..f05348ca9c55e --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts @@ -0,0 +1,46 @@ +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; + +import { sendSignalToActor } from './sendSignalToActor'; + +function getActorData( + call: IMediaCall, + role: 'caller' | 'callee', + options: { onlyDefinedSessions?: boolean }, +): { actor: MediaCallActor; role: 'caller' | 'callee' } | null { + const { onlyDefinedSessions = false } = options || {}; + + const actor = call[role]; + if (actor?.type !== 'user') { + return null; + } + + if (!actor.sessionId && onlyDefinedSessions) { + return null; + } + + return { actor, role }; +} + +export async function sendSignalToAllActors( + call: IMediaCall, + { sequence, type, body }: { sequence: number; type: T; body: MediaSignalBody }, + options: { onlyDefinedSessions?: boolean }, +): Promise { + const actors = [getActorData(call, 'caller', options), getActorData(call, 'callee', options)].filter((data) => data) as { + actor: MediaCallActor; + role: 'caller' | 'callee'; + }[]; + + await Promise.all( + actors.map(async ({ actor, role }) => + sendSignalToActor(actor, { + callId: call._id, + role, + sequence, + type, + body, + }), + ), + ); +} diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannels.ts b/packages/media-signaling-server/src/signals/sendSignalToChannels.ts new file mode 100644 index 0000000000000..b49c793cb7b5c --- /dev/null +++ b/packages/media-signaling-server/src/signals/sendSignalToChannels.ts @@ -0,0 +1,11 @@ +import type { ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; + +import { sendSignalToChannel } from './sendSignalToChannel'; + +export async function sendSignalToChannels( + channel: ValidSignalChannel[], + signal: { sequence: number; type: T; body: MediaSignalBody }, +): Promise { + await Promise.all(channel.map((channel) => sendSignalToChannel(channel, signal))); +} diff --git a/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts b/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts new file mode 100644 index 0000000000000..5095e8343fec5 --- /dev/null +++ b/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts @@ -0,0 +1,15 @@ +import type { MediaCallActor, MediaCallChannelUserRC, MediaCallParticipant } from '@rocket.chat/core-typings'; + +export function compareParticipantAndActor(participant: MediaCallParticipant, actor: MediaCallActor): boolean { + if (participant.type !== actor.type || participant.id !== actor.id) { + return false; + } + + if (actor.type === 'user') { + const user = participant as MediaCallChannelUserRC; + + return !actor.sessionId || actor.sessionId === user.sessionId; + } + + return true; +} diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index 56584866b406b..30c7a32189079 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -11,10 +11,12 @@ export interface IWebRTCProcessor { setRemoteDescription(params: DeliverParams<'sdp'>): Promise; addIceCandidates(params: DeliverParams<'ice-candidates'>): Promise; - // Function used to add a callback to be executed when an ice candidate is gathered - onIceCandidate(cb: unknown): void; - // Function used to add a callback to be executed when ICE needs to be restarted - onNegotiationNeeded(cb: unknown): void; + getRemoteMediaStream(): MediaStream; + + // // Function used to add a callback to be executed when an ice candidate is gathered + // onIceCandidate(cb: unknown): void; + // // Function used to add a callback to be executed when ICE needs to be restarted + // onNegotiationNeeded(cb: unknown): void; } export type WebRTCProcessorFactory = () => IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index cea5a2c04b59e..381725265deae 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -5,4 +5,6 @@ export type CallEvents = { stateChange: CallState; // contactUpdate may be triggered even if nothing actually changed contactUpdate: void; + + accepted: void; }; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 96ec4f13eea1a..a8df7ba45e278 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -19,4 +19,10 @@ export interface IClientMediaCallData { export interface IClientMediaCall extends Required { emitter: Emitter; + + getRemoteMediaStream(): MediaStream; + + accept(): Promise; + reject(): Promise; + hangup(): Promise; } diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index bdd4546cfa3de..4a4eeda2b4338 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -51,44 +51,38 @@ export class ClientMediaCall implements IClientMediaCall { private tricklingSignals = new Set(); + private firstSignal: MediaSignalNotify<'new'>; + + private acceptedLocally: boolean; + // private timeoutHandler?: ReturnType; - constructor(config: IClientMediaCallConfig, { callId, role, state, ignored, contact, service }: IClientMediaCallData) { + constructor( + config: IClientMediaCallConfig, + signal: MediaSignalNotify<'new'>, + { ignored, contact }: Pick = {}, + ) { this.emitter = new Emitter(); + this.firstSignal = signal; + this.transporter = config.transporter; this.webrtcProcessor = config.webrtcProcessor; - this.callId = callId; - this._role = role; - this._service = service; + this.callId = signal.callId; + this._role = signal.role; + this._service = signal.body.service; - this._state = state || 'none'; + this.acceptedLocally = false; + this._state = 'none'; this._ignored = ignored || false; this._contact = contact || null; - } - - public async initialize(signal: MediaSignalNotify<'new'>): Promise { - // If it's flagged as ignored even before the initialization, tell the server we're unavailable - if (this.ignored) { - return this.transporter.notifyServer(signal, 'unavailable'); - } - // If the call is targeted, skip the ringing state and go straight to accepted - this._state = signal.sessionId ? 'accepted' : 'ringing'; - - // Send an ACK so the server knows that this session exists and is reachable - this.transporter.notifyServer(signal, 'ack'); + this.initialize(this.firstSignal); } - public changeState(newState: CallState): void { - if (newState === this._state) { - return; - } - - const oldState = this._state; - this._state = newState; - this.emitter.emit('stateChange', oldState); + public getRemoteMediaStream(): MediaStream { + return this.webrtcProcessor.getRemoteMediaStream(); } public setContact(contact: CallContact): void { @@ -118,6 +112,61 @@ export class ClientMediaCall implements IClientMediaCall { } } + public async accept(): Promise { + if (!this.isPendingAcceptance()) { + throw new Error('call-not-pending-acceptance'); + } + this.acceptedLocally = true; + this.transporter.notifyServer(this.firstSignal, 'accept'); + } + + public async reject(): Promise { + if (!this.isPendingAcceptance()) { + throw new Error('call-not-pending-acceptance'); + } + this.transporter.notifyServer(this.firstSignal, 'reject'); + } + + public async hangup(): Promise { + this.transporter.notifyServer(this.firstSignal, 'hangup', { + reasonCode: 'normal', + }); + } + + public isPendingAcceptance(): boolean { + return ['none', 'ringing'].includes(this._state); + } + + private initialize(signal: MediaSignalNotify<'new'>): void { + // If it's flagged as ignored even before the initialization, tell the server we're unavailable + if (this.ignored) { + return this.transporter.notifyServer(signal, 'unavailable'); + } + + // If the call is targeted, assume we already accepted it: + if (signal.sessionId) { + this.acceptedLocally = true; + } + this._state = 'ringing'; + + // Send an ACK so the server knows that this session exists and is reachable + this.transporter.notifyServer(signal, 'ack'); + } + + private changeState(newState: CallState): void { + if (newState === this._state) { + return; + } + + const oldState = this._state; + this._state = newState; + this.emitter.emit('stateChange', oldState); + + if (newState === 'accepted') { + this.emitter.emit('accepted'); + } + } + private async processRequest(signal: MediaSignalRequest) { if (!signal.sessionId) { console.error('Received an untargeted request.'); @@ -229,6 +278,7 @@ export class ClientMediaCall implements IClientMediaCall { case 'unavailable': break; case 'accept': + await this.flagAsAccepted(signal); break; case 'reject': break; @@ -238,4 +288,14 @@ export class ClientMediaCall implements IClientMediaCall { break; } } + + private async flagAsAccepted(signal: MediaSignalNotify): Promise { + if (!this.acceptedLocally) { + this.transporter.sendError(signal, 'not-accepted'); + throw new Error('Trying to activate a call that was not yet accepted locally.'); + } + + // Both sides of the call have accepted it, we can change the state now + this.changeState('accepted'); + } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 84baeabbd3d91..a5b5d525bd12a 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -5,7 +5,7 @@ import { MediaSignalTransportWrapper } from './TransportWrapper'; import type { IWebRTCProcessor, MediaSignalNotify } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; -import type { IClientMediaCall, IClientMediaCallData, CallContact, CallState } from '../definition/call'; +import type { IClientMediaCall, CallContact, CallState } from '../definition/call'; import { isNotifyNew } from './utils/isNotifyNew'; import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; import { isCallRole } from '../definition/call/CallRole'; @@ -22,6 +22,7 @@ const stateWeights: Record = { export type MediaSignalingEvents = { callStateChange: { call: IClientMediaCall; oldState: CallState }; newCall: { call: IClientMediaCall }; + acceptedCall: { call: IClientMediaCall }; }; export type MediaSignalingSessionConfig = { @@ -193,21 +194,22 @@ export class MediaSignalingSession extends Emitter { if (!isCallRole(signal.role)) { throw new Error('invalid-role'); } + const webrtcProcessor = await this.createWebRtcProcessor(); // Contact will probaby already be stored if this call was requested by this same session const contact = this.getStoredCallContact(signal.callId); - const callData: IClientMediaCallData = { - callId: signal.callId, - role: signal.role, - state: 'none', - service: signal.body.service, - ignored: this.isCallIgnored(signal.callId) || this.isBusy(), - contact, + const ignored = this.isCallIgnored(signal.callId) || this.isBusy(); + + const config = { + transporter: this.transporter, + webrtcProcessor, }; - const call = await this.registerCall(callData); + const call = new ClientMediaCall(config, signal, { contact, ignored }); + this.knownCalls.set(call.callId, call); - await call.initialize(signal); + call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); + call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); this.emit('newCall', { call }); } catch (e) { @@ -219,20 +221,6 @@ export class MediaSignalingSession extends Emitter { } } - private async registerCall(callData: IClientMediaCallData): Promise { - const webrtcProcessor = await this.createWebRtcProcessor(); - - const config = { - transporter: this.transporter, - webrtcProcessor, - }; - - const call = new ClientMediaCall(config, callData); - - this.knownCalls.set(call.callId, call); - return call; - } - private async createWebRtcProcessor(): Promise { const { webrtc: webrtcFactory } = this.config.processorFactories; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index f885bf06ad8de..e6ebc081dd6af 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -8,6 +8,8 @@ export interface IMediaCallsModel extends IBaseModel { setStateById(callId: string, state: IMediaCall['state']): Promise; startRingingById(callId: string): Promise; getNewSequence(callId: string): Promise; - acceptCallById(callId: string, calleeSessionId?: string): Promise; + acceptCallById(callId: string): Promise; setCallerSessionIdById(callId: string, callerSessionId: string): Promise; + setCalleeSessionIdById(callId: string, calleeSessionId: string): Promise; + setActorSessionIdByIdAndRole(callId: string, sessionId: string, role: 'caller' | 'callee'): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 7ce08b3de37a0..e80da204d9382 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -48,25 +48,43 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async acceptCallById(callId: string, calleeSessionId?: string): Promise { - if (!calleeSessionId) { - return this.updateOne({ _id: callId, state: { $in: ['none', 'ringing'] } }, { $set: { state: 'accepted' } }); - } - + public async acceptCallById(callId: string): Promise { return this.updateOne( { - _id: callId, - $or: [ + '_id': callId, + 'state': { $in: ['none', 'ringing'] }, + 'caller.type': { $exists: true }, + 'callee.type': { $exists: true }, + '$and': [ { - state: { $in: ['none', 'ringing'] }, + $or: [ + { + 'caller.type': 'user', + 'caller.sessionId': { $exists: true }, + }, + { + 'caller.type': { + $ne: 'user', + }, + }, + ], }, { - 'state': 'accepted', - 'callee.sessionId': { $exists: false }, + $or: [ + { + 'callee.type': 'user', + 'callee.sessionId': { $exists: true }, + }, + { + 'callee.type': { + $ne: 'user', + }, + }, + ], }, ], }, - { $set: { 'state': 'accepted', 'callee.sessionId': calleeSessionId } }, + { $set: { state: 'accepted' } }, ); } @@ -74,6 +92,18 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne({ '_id': callId, 'caller.sessionId': { $exists: false } }, { $set: { 'caller.sessionId': callerSessionId } }); } + public async setCalleeSessionIdById(callId: string, calleeSessionId: string): Promise { + return this.updateOne({ '_id': callId, 'callee.sessionId': { $exists: false } }, { $set: { 'callee.sessionId': calleeSessionId } }); + } + + public async setActorSessionIdByIdAndRole(callId: string, sessionId: string, role: 'caller' | 'callee'): Promise { + if (role === 'caller') { + return this.setCallerSessionIdById(callId, sessionId); + } + + return this.setCalleeSessionIdById(callId, sessionId); + } + public async getNewSequence(callId: string): Promise { return this.findOneAndUpdate({ _id: callId }, { $inc: { sequence: 1 } }, { returnDocument: 'after' }); } diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx index 2658a5a994fc3..8616b8b4705b9 100644 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -54,7 +54,7 @@ export const useVoipAPI = (): VoipAPI => { return mediaCallsClient.call(callee); }, - endCall: mediaCallsClient.endCall, + endCall: () => mediaCallsClient.endCall(), register: NOOP, unregister: NOOP, transferCall: mediaCallsClient.transfer, diff --git a/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts b/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts index fbedf4ba41feb..2892e6cda80dd 100644 --- a/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts +++ b/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts @@ -5,20 +5,33 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private iceGatheringFinished = false; + private localMediaStream: MediaStream; + + private localMediaStreamInitialized = false; + + private remoteMediaStream: MediaStream; + constructor() { + this.localMediaStream = new MediaStream(); + this.remoteMediaStream = new MediaStream(); + this.peer = new RTCPeerConnection(); + this.registerPeerEvents(); } - private restartIce() { - this.iceGatheringFinished = false; - this.peer.restartIce(); + public getRemoteMediaStream() { + return this.remoteMediaStream; } public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { + await this.initializeLocalMediaStream(); + if (iceRestart) { this.restartIce(); } + // #ToDo: direction changes + const offer = await this.peer.createOffer(); await this.peer.setLocalDescription(offer); @@ -29,6 +42,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { + await this.initializeLocalMediaStream(); + + // #ToDo: direction changes if (this.peer.remoteDescription?.sdp !== offer.sdp) { this.peer.setRemoteDescription(offer); } @@ -43,6 +59,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { + await this.initializeLocalMediaStream(); + const sdp = this.peer.localDescription; if (!sdp) { @@ -56,6 +74,10 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { + await this.initializeLocalMediaStream(); + + // #ToDo: validate this.peer.signalingState ? + this.peer.setRemoteDescription(sdp); } @@ -69,11 +91,122 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } } - public onIceCandidate(_cb: unknown) { + protected async getuserMedia(constraints: MediaStreamConstraints) { + if (navigator.mediaDevices === undefined) { + throw new Error('Media devices not available in insecure contexts.'); + } + + return navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices, constraints); + } + + private registerPeerEvents() { + this.peer.ontrack = (event) => this.onTrack(event); + this.peer.onicecandidate = (event) => this.onIceCandidate(event); + this.peer.onicecandidateerror = (event) => this.onIceCandidateError(event); + this.peer.onconnectionstatechange = () => this.onConnectionStateChange(); + this.peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange(); + this.peer.onnegotiationneeded = () => this.onNegotiationNeeded(); + } + + private restartIce() { + this.iceGatheringFinished = false; + this.peer.restartIce(); + } + + private onIceCandidate(_event: RTCPeerConnectionIceEvent) { + console.log('onIceCandidate event'); + // + } + + private onIceCandidateError(_event: RTCPeerConnectionIceErrorEvent) { + console.log('onIceCandidate ERROR event'); + // + } + + private onNegotiationNeeded() { + console.log('onNegotiationNeeded event'); + // + } + + private onTrack(event: RTCTrackEvent): void { + // Received a remote stream + console.log('ontrack', event.track.kind, event.track.enabled); + + this.setRemoteTrack(event.track); + } + + private onConnectionStateChange() { // } - public onNegotiationNeeded(_cb: unknown) { + private onIceConnectionStateChange() { // } + + private setRemoteTrack(track: MediaStreamTrack): void { + console.log('setRemoteTrack'); + + if (this.remoteMediaStream.getTrackById(track.id)) { + console.log('remote track already set'); + return; + } + + if (track.kind !== 'audio') { + console.log('received non-audio track: ', track.kind); + return; + } + + this.remoteMediaStream.getAudioTracks().forEach((track) => { + track.stop(); + this.remoteMediaStream.removeTrack(track); + }); + + this.remoteMediaStream.addTrack(track); + } + + private async setLocalTrack(newTrack: MediaStreamTrack): Promise { + if (newTrack.kind === 'video') { + console.log('skipping video track'); + return; + } + + if (newTrack.kind !== 'audio') { + console.log(newTrack); + throw new Error('Unsupported track kind'); + } + + const sender = this.peer.getSenders().find((sender) => sender.track?.kind === newTrack.kind); + if (!sender) { + this.peer.addTrack(newTrack, this.localMediaStream); + this.localMediaStream.addTrack(newTrack); + return; + } + + console.log('Replacing sender track'); + await sender.replaceTrack(newTrack); + + console.log('Replacing track on local media stream'); + const oldTrack = this.localMediaStream.getTracks().find((localTrack) => localTrack.kind === newTrack.kind); + if (oldTrack) { + oldTrack.stop(); + this.localMediaStream.removeTrack(oldTrack); + } + + this.localMediaStream.addTrack(newTrack); + } + + private async initializeLocalMediaStream(): Promise { + if (this.localMediaStreamInitialized) { + return; + } + + const mediaStream = await this.getuserMedia({ audio: true }); + + const audioTracks = mediaStream.getAudioTracks(); + if (audioTracks.length) { + await this.setLocalTrack(audioTracks[0]); + } + + this.localMediaStreamInitialized = true; + } } diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts index aeb627751d80d..42df555b898e6 100644 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -63,6 +63,7 @@ class MediaCallsClient extends Emitter { this.networkEmitter = new Emitter(); this.session.on('newCall', ({ call }) => this.onNewCall(call)); + this.session.on('acceptedCall', ({ call }) => this.onAcceptedCall(call)); } public async init() { @@ -113,15 +114,30 @@ class MediaCallsClient extends Emitter { } public async answer(): Promise { - // + const call = this.session.getMainCall(); + if (!call) { + throw new Error('No call available to answer'); + } + + return call.accept(); } public async reject(): Promise { - // + const call = this.session.getMainCall(); + if (!call) { + throw new Error('No call available to reject'); + } + + return call.reject(); } public async endCall(): Promise { - // + const call = this.session.getMainCall(); + if (!call) { + throw new Error('No call available to hangup'); + } + + return call.hangup(); } public async setMute(_mute: boolean): Promise { @@ -174,16 +190,7 @@ class MediaCallsClient extends Emitter { return null; } - const contactData = this.session.getStoredCallContact(call.callId); - if (!contactData) { - return null; - } - - return { - id: contactData.id, - name: contactData.displayName, - host: '', - }; + return this.getCallContactInfo(call); } public getReferredBy() { @@ -314,11 +321,11 @@ class MediaCallsClient extends Emitter { transferedBy: this.getReferredBy(), isMuted: this.isMuted(), isHeld: this.isHeld(), - mute: this.setMute, - hold: this.setHold, - accept: this.answer, - end: this.endCall, - dtmf: this.sendDTMF, + mute: (mute) => this.setMute(mute ?? true), + hold: (hold) => this.setHold(hold ?? true), + accept: () => this.answer(), + end: () => this.endCall(), + dtmf: (tone) => this.sendDTMF(tone), }; default: return null; @@ -353,15 +360,43 @@ class MediaCallsClient extends Emitter { } private onNewCall(call: IClientMediaCall): void { - // #ToDo: use call reference for getContactInfo + const contact = this.getCallContactInfo(call) as ContactInfo; + if (call.role === 'callee') { - this.emit('incomingcall', this.getContactInfo() as ContactInfo); + this.emit('incomingcall', contact); } else if (call.role === 'caller') { - this.emit('outgoingcall', this.getContactInfo() as ContactInfo); + this.emit('outgoingcall', contact); } this.emit('stateChanged'); } + private onAcceptedCall(call: IClientMediaCall): void { + const remoteMediaStream = call.getRemoteMediaStream(); + this.remoteStream = new RemoteStream(remoteMediaStream); + this.playRemoteStream(); + + const contact = this.getCallContactInfo(call) as ContactInfo; + this.emit('callestablished', contact); + this.emit('stateChanged'); + } + + private getCallContactInfo(call: IClientMediaCall): ContactInfo | null { + if (this.error) { + return this.error.contact; + } + + const contactData = this.session.getStoredCallContact(call.callId); + if (!contactData) { + return null; + } + + return { + id: contactData.id, + name: contactData.displayName, + host: '', + }; + } + // private setContactInfo(contact: ContactInfo) { // this.contactInfo = contact; // this.emit('stateChanged'); @@ -384,13 +419,6 @@ class MediaCallsClient extends Emitter { } } - // private setupRemoteMedia() { - // // const { remoteMediaStream } = this.sessionDescriptionHandler; - - // // this.remoteStream = new RemoteStream(remoteMediaStream); - // this.playRemoteStream(); - // } - private playRemoteStream() { if (!this.remoteStream) { console.warn(`Attempted to play missing remote media.`); From 9046b7673ccd6b96b2e3d9830e91fab6ddc219f5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 29 Jul 2025 17:37:58 -0300 Subject: [PATCH 13/72] first working state --- .../notifications/notifications.module.ts | 5 - .../core-typings/src/mediaCalls/IMediaCall.ts | 4 +- .../src/calls/processEndedCall.ts | 17 + .../src/signals/notify/hangup.ts | 30 ++ .../src/signals/notify/processNotify.ts | 3 + .../src/signals/sendSignalToAllActors.ts | 25 +- .../src/definition/IWebRTCProcessor.ts | 12 +- .../src/definition/MediaStreamFactory.ts | 1 + .../src/definition/call/CallEvents.ts | 2 + .../src/definition/call/CallState.ts | 2 +- packages/media-signaling/src/lib/Call.ts | 57 +++- packages/media-signaling/src/lib/Session.ts | 17 +- .../src/lib/TransportWrapper.ts | 1 - packages/media-signaling/src/lib/index.ts | 3 + .../src/lib/services/webrtc/LocalStream.ts | 41 +++ .../src/lib/services/webrtc/Processor.ts | 322 ++++++++++++++++++ .../src/lib/services/webrtc/RemoteStream.ts | 24 ++ .../src/lib/services/webrtc/Stream.ts | 7 + .../src/lib/services/webrtc/index.ts | 1 + .../src/models/IMediaCallsModel.ts | 1 + packages/models/src/models/MediaCalls.ts | 19 ++ .../src/lib/MediaCallWebRTCProcessor.ts | 212 ------------ packages/ui-voip/src/lib/MediaCallsClient.ts | 47 ++- packages/ui-voip/src/lib/getUserMedia.ts | 7 + 24 files changed, 611 insertions(+), 249 deletions(-) create mode 100644 packages/media-signaling-server/src/calls/processEndedCall.ts create mode 100644 packages/media-signaling-server/src/signals/notify/hangup.ts create mode 100644 packages/media-signaling/src/definition/MediaStreamFactory.ts create mode 100644 packages/media-signaling/src/lib/services/webrtc/LocalStream.ts create mode 100644 packages/media-signaling/src/lib/services/webrtc/Processor.ts create mode 100644 packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts create mode 100644 packages/media-signaling/src/lib/services/webrtc/Stream.ts create mode 100644 packages/media-signaling/src/lib/services/webrtc/index.ts delete mode 100644 packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts create mode 100644 packages/ui-voip/src/lib/getUserMedia.ts diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index 22074d6a47d7f..ad6bacba354b2 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -308,11 +308,6 @@ export class NotificationsModule { }); } - if (e === 'media-signal') { - console.log('allowWrite', data); - return true; - } - return Boolean(this.userId); }); this.streamUser.allowRead(async function (eventName) { diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 693f4f4ee4bc6..b6fe2b81c4aa5 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -39,9 +39,7 @@ export interface IMediaCall extends IRocketChatRecord { endedBy?: MediaCallActor; endedAt?: Date; + hangupReason?: string; sequence: number; - - // providerName: string; - // providerData?: Record; } diff --git a/packages/media-signaling-server/src/calls/processEndedCall.ts b/packages/media-signaling-server/src/calls/processEndedCall.ts new file mode 100644 index 0000000000000..6401844b36517 --- /dev/null +++ b/packages/media-signaling-server/src/calls/processEndedCall.ts @@ -0,0 +1,17 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +import { getNewCallSequence } from './getNewCallSequence'; +import { sendSignalToAllActors } from '../signals/sendSignalToAllActors'; + +export async function processEndedCall(callId: IMediaCall['_id']): Promise { + const call = await getNewCallSequence(callId); + + await sendSignalToAllActors(call, { + sequence: call.sequence, + type: 'notify', + body: { + notify: 'hangup', + reasonCode: 'normal', + }, + }); +} diff --git a/packages/media-signaling-server/src/signals/notify/hangup.ts b/packages/media-signaling-server/src/signals/notify/hangup.ts new file mode 100644 index 0000000000000..efe63d0061dff --- /dev/null +++ b/packages/media-signaling-server/src/signals/notify/hangup.ts @@ -0,0 +1,30 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { processEndedCall } from '../../calls/processEndedCall'; +import { compareParticipantAndActor } from '../../utils/compareParticipantAndActor'; +import { isValidSignalRole } from '../isValidSignalRole'; + +export async function processHangup(signal: MediaSignalNotify<'hangup'>, call: IMediaCall, channel: ValidSignalChannel): Promise { + if (!isValidSignalRole(channel.role)) { + throw new Error('error-invalid-role'); + } + + const actor = call[channel.role]; + + // If the channel's participant doesn't match the actor assigned to the call for this role, do nothing + if (!compareParticipantAndActor(channel.participant, actor)) { + return; + } + + const endedBy = { ...actor, ...(actor.type === 'user' && !actor.sessionId && { sessionId: channel.participant.sessionId }) }; + const reason = signal.body.reasonCode || 'normal'; + + const stateResult = await MediaCalls.hangupCallById(call._id, { endedBy, reason }); + if (!stateResult.modifiedCount) { + return; + } + + return processEndedCall(call._id); +} diff --git a/packages/media-signaling-server/src/signals/notify/processNotify.ts b/packages/media-signaling-server/src/signals/notify/processNotify.ts index 7e886c3cd39b7..50c5e49f17b49 100644 --- a/packages/media-signaling-server/src/signals/notify/processNotify.ts +++ b/packages/media-signaling-server/src/signals/notify/processNotify.ts @@ -3,6 +3,7 @@ import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; import { processAccept } from './accept'; import { processACK } from './ack'; +import { processHangup } from './hangup'; export async function processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: ValidSignalChannel): Promise { switch (signal.body.notify) { @@ -10,5 +11,7 @@ export async function processNotify(signal: MediaSignalNotify, call: IMediaCall, return processACK(signal as MediaSignalNotify<'ack'>, call, channel); case 'accept': return processAccept(signal as MediaSignalNotify<'accept'>, call, channel); + case 'hangup': + return processHangup(signal as MediaSignalNotify<'hangup'>, call, channel); } } diff --git a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts index f05348ca9c55e..e2a1964f4e9c8 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts @@ -3,12 +3,19 @@ import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signal import { sendSignalToActor } from './sendSignalToActor'; +type SendSignalToAllActorsOptions = { + // default false + onlyDefinedSessions?: boolean; + // default true for 'deliver' and 'request', or false for 'notify' + targetedSignal?: boolean; +}; + function getActorData( call: IMediaCall, role: 'caller' | 'callee', - options: { onlyDefinedSessions?: boolean }, + options: SendSignalToAllActorsOptions, ): { actor: MediaCallActor; role: 'caller' | 'callee' } | null { - const { onlyDefinedSessions = false } = options || {}; + const { onlyDefinedSessions = false, targetedSignal = true } = options || {}; const actor = call[role]; if (actor?.type !== 'user') { @@ -19,15 +26,25 @@ function getActorData( return null; } + if (!targetedSignal) { + const { sessionId, ...actorData } = actor; + return { actor: actorData, role }; + } + return { actor, role }; } export async function sendSignalToAllActors( call: IMediaCall, { sequence, type, body }: { sequence: number; type: T; body: MediaSignalBody }, - options: { onlyDefinedSessions?: boolean }, + options: SendSignalToAllActorsOptions = {}, ): Promise { - const actors = [getActorData(call, 'caller', options), getActorData(call, 'callee', options)].filter((data) => data) as { + const subOptions: SendSignalToAllActorsOptions = { + onlyDefinedSessions: options.onlyDefinedSessions ?? false, + targetedSignal: options.targetedSignal ?? ['deliver', 'request'].includes(type), + }; + + const actors = [getActorData(call, 'caller', subOptions), getActorData(call, 'callee', subOptions)].filter((data) => data) as { actor: MediaCallActor; role: 'caller' | 'callee'; }[]; diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index 30c7a32189079..ac578695bd279 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -1,3 +1,4 @@ +import type { MediaStreamFactory } from './MediaStreamFactory'; import type { DeliverParams } from './signal/MediaSignalDeliver'; import type { RequestParams } from './signal/MediaSignalRequest'; @@ -12,11 +13,10 @@ export interface IWebRTCProcessor { addIceCandidates(params: DeliverParams<'ice-candidates'>): Promise; getRemoteMediaStream(): MediaStream; - - // // Function used to add a callback to be executed when an ice candidate is gathered - // onIceCandidate(cb: unknown): void; - // // Function used to add a callback to be executed when ICE needs to be restarted - // onNegotiationNeeded(cb: unknown): void; } -export type WebRTCProcessorFactory = () => IWebRTCProcessor; +export type WebRTCProcessorConfig = { + mediaStreamFactory: MediaStreamFactory; +}; + +export type WebRTCProcessorFactory = (config: WebRTCProcessorConfig) => IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/MediaStreamFactory.ts b/packages/media-signaling/src/definition/MediaStreamFactory.ts new file mode 100644 index 0000000000000..b1d5b680ee2ee --- /dev/null +++ b/packages/media-signaling/src/definition/MediaStreamFactory.ts @@ -0,0 +1 @@ +export type MediaStreamFactory = (constraints: MediaStreamConstraints) => Promise; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 381725265deae..c907f1ccd97b2 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -7,4 +7,6 @@ export type CallEvents = { contactUpdate: void; accepted: void; + + ended: void; }; diff --git a/packages/media-signaling/src/definition/call/CallState.ts b/packages/media-signaling/src/definition/call/CallState.ts index ac9a91b792d11..ae0d2eae8ae28 100644 --- a/packages/media-signaling/src/definition/call/CallState.ts +++ b/packages/media-signaling/src/definition/call/CallState.ts @@ -1 +1 @@ -export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'error'; +export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup' | 'error'; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 4a4eeda2b4338..e880821daed72 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -55,6 +55,8 @@ export class ClientMediaCall implements IClientMediaCall { private acceptedLocally: boolean; + private endedLocally: boolean; + // private timeoutHandler?: ReturnType; constructor( @@ -74,6 +76,7 @@ export class ClientMediaCall implements IClientMediaCall { this._service = signal.body.service; this.acceptedLocally = false; + this.endedLocally = false; this._state = 'none'; this._ignored = ignored || false; this._contact = contact || null; @@ -98,7 +101,7 @@ export class ClientMediaCall implements IClientMediaCall { } public async processSignal(signal: MediaSignal) { - console.log('ClientMediaCall.processSignal', signal); + console.log('ClientMediaCall.processSignal', signal.type); switch (signal.type) { case 'request': await this.processRequest(signal); @@ -113,6 +116,7 @@ export class ClientMediaCall implements IClientMediaCall { } public async accept(): Promise { + console.log('call.accept'); if (!this.isPendingAcceptance()) { throw new Error('call-not-pending-acceptance'); } @@ -121,6 +125,7 @@ export class ClientMediaCall implements IClientMediaCall { } public async reject(): Promise { + console.log('call.reject'); if (!this.isPendingAcceptance()) { throw new Error('call-not-pending-acceptance'); } @@ -128,9 +133,13 @@ export class ClientMediaCall implements IClientMediaCall { } public async hangup(): Promise { - this.transporter.notifyServer(this.firstSignal, 'hangup', { - reasonCode: 'normal', - }); + console.log('call.hangup'); + if (this.endedLocally || this._state === 'hangup') { + return; + } + + this.endedLocally = true; + await this.flagAsEnded(this.firstSignal, 'normal'); } public isPendingAcceptance(): boolean { @@ -138,6 +147,7 @@ export class ClientMediaCall implements IClientMediaCall { } private initialize(signal: MediaSignalNotify<'new'>): void { + console.log('call.initialize'); // If it's flagged as ignored even before the initialization, tell the server we're unavailable if (this.ignored) { return this.transporter.notifyServer(signal, 'unavailable'); @@ -158,16 +168,24 @@ export class ClientMediaCall implements IClientMediaCall { return; } + console.log('call.changeState', newState); + const oldState = this._state; this._state = newState; this.emitter.emit('stateChange', oldState); - if (newState === 'accepted') { - this.emitter.emit('accepted'); + switch (newState) { + case 'accepted': + this.emitter.emit('accepted'); + break; + case 'hangup': + this.emitter.emit('ended'); + break; } } private async processRequest(signal: MediaSignalRequest) { + console.log('call.processRequest', signal.body.request); if (!signal.sessionId) { console.error('Received an untargeted request.'); return; @@ -184,7 +202,7 @@ export class ClientMediaCall implements IClientMediaCall { } private async processOfferRequest(signal: MediaSignalRequest<'offer'>) { - // #ToDo: processor + console.log('call.processOfferRequest'); let offer: DeliverParams<'sdp'> | null = null; try { offer = await this.webrtcProcessor.createOffer(signal.body); @@ -201,6 +219,7 @@ export class ClientMediaCall implements IClientMediaCall { } private async processAnswerRequest(signal: MediaSignalRequest<'answer'>) { + console.log('Call.processAnswerRequest'); let answer: DeliverParams<'sdp'> | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal.body); @@ -217,6 +236,7 @@ export class ClientMediaCall implements IClientMediaCall { } private async processSdpRequest(signal: MediaSignalRequest<'sdp'>) { + console.log('call.processSdpRequest'); let sdp: DeliverParams<'sdp'> | null = null; try { sdp = await this.webrtcProcessor.collectLocalDescription(signal.body); @@ -233,6 +253,7 @@ export class ClientMediaCall implements IClientMediaCall { } private async deliverSdp(requestSignal: MediaSignal, sdp: DeliverParams<'sdp'>) { + console.log('Call.deliverSdp'); // If we're trickling ICE, keep the signal reference for upcoming candidates if (!sdp.endOfCandidates) { this.tricklingSignals.add(requestSignal); @@ -242,6 +263,8 @@ export class ClientMediaCall implements IClientMediaCall { } private async processDeliver(signal: MediaSignalDeliver) { + console.log('Call.processDeliver', signal.body.deliver); + if (!signal.sessionId) { throw new Error('Delivery signals MUST target a specific session.'); } @@ -260,8 +283,11 @@ export class ClientMediaCall implements IClientMediaCall { } private async processNotify(signal: MediaSignalNotify) { + console.log('Call.processNotify', signal.body.notify); + switch (signal.body.notify) { case 'new': + // New notifications should have alreayd been processed by the constructor break; case 'error': break; @@ -283,6 +309,7 @@ export class ClientMediaCall implements IClientMediaCall { case 'reject': break; case 'hangup': + await this.flagAsEnded(signal, 'remote'); break; case 'negotiation-needed': break; @@ -290,6 +317,8 @@ export class ClientMediaCall implements IClientMediaCall { } private async flagAsAccepted(signal: MediaSignalNotify): Promise { + console.log('flagAsAccepted'); + if (!this.acceptedLocally) { this.transporter.sendError(signal, 'not-accepted'); throw new Error('Trying to activate a call that was not yet accepted locally.'); @@ -298,4 +327,18 @@ export class ClientMediaCall implements IClientMediaCall { // Both sides of the call have accepted it, we can change the state now this.changeState('accepted'); } + + private async flagAsEnded(signal: MediaSignal, reasonCode: string): Promise { + console.log('flagAsEnded'); + + if (this._state === 'hangup') { + return; + } + + this.transporter.notifyServer(signal, 'hangup', { + reasonCode, + }); + + this.changeState('hangup'); + } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index a5b5d525bd12a..748a4d7856c9b 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -5,6 +5,7 @@ import { MediaSignalTransportWrapper } from './TransportWrapper'; import type { IWebRTCProcessor, MediaSignalNotify } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; +import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; import type { IClientMediaCall, CallContact, CallState } from '../definition/call'; import { isNotifyNew } from './utils/isNotifyNew'; import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; @@ -17,17 +18,20 @@ const stateWeights: Record = { accepted: 2, error: 3, active: 4, + hangup: 0, } as const; export type MediaSignalingEvents = { callStateChange: { call: IClientMediaCall; oldState: CallState }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; + endedCall: { call: IClientMediaCall }; }; export type MediaSignalingSessionConfig = { userId: string; processorFactories: IServiceProcessorFactoryList; + mediaStreamFactory: MediaStreamFactory; transport: MediaSignalTransport; }; @@ -113,6 +117,7 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: MediaSignal): Promise { + console.log('session.processSignal', signal.type); if (this.isSignalTargetingAnotherSession(signal)) { return; } @@ -153,6 +158,7 @@ export class MediaSignalingSession extends Emitter { } public setCallContact(callId: string, contact: Record): void { + console.log('session.setCallContact'); const oldContact = this.getStoredCallContact(callId); const fullContact = { ...oldContact, ...contact }; @@ -185,6 +191,7 @@ export class MediaSignalingSession extends Emitter { } private async processNewCall(signal: MediaSignalNotify<'new'>) { + console.log('session.processNewCall'); // If we already know about this call, we don't need to process anything if (this.isCallKnown(signal.callId)) { return; @@ -210,6 +217,7 @@ export class MediaSignalingSession extends Emitter { call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); + call.emitter.on('ended', () => this.emit('endedCall', { call })); this.emit('newCall', { call }); } catch (e) { @@ -222,12 +230,17 @@ export class MediaSignalingSession extends Emitter { } private async createWebRtcProcessor(): Promise { - const { webrtc: webrtcFactory } = this.config.processorFactories; + console.log('session.createWebRtcProcessor'); + + const { + mediaStreamFactory, + processorFactories: { webrtc: webrtcFactory }, + } = this.config; if (!webrtcFactory) { throw new Error('webrtc-not-implemented'); } - return webrtcFactory(); + return webrtcFactory({ mediaStreamFactory }); } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index 05bda868155ac..2a6531edb53aa 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -65,6 +65,5 @@ export class MediaSignalTransportWrapper { public sendSignal(signal: MediaSignal): void { console.log('MediaSignalTransport.sendSignal', signal); this.sendSignalFn(signal); - // this.emit('signal', { signal }); } } diff --git a/packages/media-signaling/src/lib/index.ts b/packages/media-signaling/src/lib/index.ts index d398127b4a2c1..46e331fe03a2e 100644 --- a/packages/media-signaling/src/lib/index.ts +++ b/packages/media-signaling/src/lib/index.ts @@ -1 +1,4 @@ export * from './Session'; +export * from './Call'; +export * from './TransportWrapper'; +export * from './services/webrtc'; diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts new file mode 100644 index 0000000000000..41aeec8a0e0af --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -0,0 +1,41 @@ +import { Stream } from './Stream'; + +export class LocalStream extends Stream { + public async setTrack(newTrack: MediaStreamTrack, peer: RTCPeerConnection): Promise { + if (newTrack.kind === 'video') { + console.log('skipping video track'); + return; + } + + if (newTrack.kind !== 'audio') { + console.log(newTrack); + throw new Error('Unsupported track kind'); + } + + const sender = peer.getSenders().find((sender) => sender.track?.kind === newTrack.kind); + if (!sender) { + peer.addTrack(newTrack, this.mediaStream); + this.mediaStream.addTrack(newTrack); + return; + } + + console.log('Replacing sender track'); + await sender.replaceTrack(newTrack); + + console.log('Replacing track on local media stream'); + const oldTrack = this.mediaStream.getTracks().find((localTrack) => localTrack.kind === newTrack.kind); + if (oldTrack) { + oldTrack.stop(); + this.mediaStream.removeTrack(oldTrack); + } + + this.mediaStream.addTrack(newTrack); + } + + public async setStreamTracks(stream: MediaStream, peer: RTCPeerConnection): Promise { + const audioTracks = stream.getAudioTracks(); + if (audioTracks.length) { + await this.setTrack(audioTracks[0], peer); + } + } +} diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts new file mode 100644 index 0000000000000..5bf548761df68 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -0,0 +1,322 @@ +import { LocalStream } from './LocalStream'; +import { RemoteStream } from './RemoteStream'; +import type { DeliverParams, IWebRTCProcessor, RequestParams, WebRTCProcessorConfig } from '../../../definition'; + +type IceGatheringData = { + promise: Promise; + promiseReject: (error: Error) => void; + promiseResolve: () => void; + timeout: ReturnType; +}; + +export class MediaCallWebRTCProcessor implements IWebRTCProcessor { + private peer: RTCPeerConnection; + + private iceGatheringFinished = false; + + private localStream: LocalStream; + + private localMediaStream: MediaStream; + + private localMediaStreamInitialized = false; + + private remoteStream: RemoteStream; + + private remoteMediaStream: MediaStream; + + private localCandidates: Set; + + private remoteCandidates: Set; + + private iceGatheringWaiters: Set; + + constructor(private readonly config: WebRTCProcessorConfig) { + this.localMediaStream = new MediaStream(); + this.remoteMediaStream = new MediaStream(); + this.localCandidates = new Set(); + this.remoteCandidates = new Set(); + this.iceGatheringWaiters = new Set(); + + this.localStream = new LocalStream(this.localMediaStream); + this.remoteStream = new RemoteStream(this.remoteMediaStream); + + this.peer = new RTCPeerConnection(); + this.registerPeerEvents(); + } + + public getRemoteMediaStream() { + return this.remoteMediaStream; + } + + public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { + console.log('processor.createOffer'); + await this.initializeLocalMediaStream(); + + if (iceRestart) { + this.restartIce(); + } + + console.log('peer.createOffer'); + // #ToDo: direction changes + + const offer = await this.peer.createOffer(); + console.log('setLocalDescription'); + await this.peer.setLocalDescription(offer); + + console.log('return getLocalDescription'); + return this.getLocalDescription(); + } + + public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { + await this.initializeLocalMediaStream(); + + // #ToDo: direction changes + if (this.peer.remoteDescription?.sdp !== offer.sdp) { + this.peer.setRemoteDescription(offer); + } + + const answer = await this.peer.createAnswer(); + await this.peer.setLocalDescription(answer); + + return this.getLocalDescription(); + } + + public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { + await this.initializeLocalMediaStream(); + + return this.getLocalDescription(); + } + + public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { + console.log('setRemoteDescription'); + await this.initializeLocalMediaStream(); + + // #ToDo: validate this.peer.signalingState ? + + console.log('peer.setRemoteDescription'); + + this.peer.setRemoteDescription(sdp); + } + + public async addIceCandidates({ candidates }: DeliverParams<'ice-candidates'>): Promise { + const results = await Promise.allSettled( + candidates.map(async (candidate) => { + await this.peer?.addIceCandidate(candidate); + }), + ); + + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason; + } + } + } + + protected async getuserMedia(constraints: MediaStreamConstraints) { + return this.config.mediaStreamFactory(constraints); + } + + private async getLocalDescription(): Promise> { + console.log('getLocalDescription'); + await this.waitForIceGathering(); + console.log('waited'); + + await new Promise((resolve) => setTimeout(resolve, 30)); + console.log('extra waited'); + + const sdp = this.peer.localDescription; + + if (!sdp) { + console.log('no-local-sdp'); + throw new Error('no-local-sdp'); + } + + console.log('return sdp'); + return { + sdp, + endOfCandidates: this.iceGatheringFinished, + }; + } + + private async waitForIceGathering(): Promise { + if (this.iceGatheringFinished) { + return; + } + + if (this.peer.iceGatheringState === 'complete') { + console.log('ice gathering already complete'); + return; + } + + const data: Partial = {}; + + data.promise = new Promise((resolve, reject) => { + data.promiseResolve = resolve; + data.promiseReject = reject; + }); + + const iceGatheringData = data as IceGatheringData; + data.timeout = setTimeout(() => { + console.log('timeout'); + if (this.iceGatheringWaiters.has(iceGatheringData)) { + this.clearIceGatheringData(iceGatheringData); + } else { + console.log('ice gathering not on the list'); + } + }, 500); + + this.iceGatheringWaiters.add(iceGatheringData); + return data.promise; + } + + private registerPeerEvents() { + const { peer } = this; + + peer.ontrack = (event) => this.onTrack(peer, event); + peer.onicecandidate = (event) => this.onIceCandidate(peer, event); + peer.onicecandidateerror = (event) => this.onIceCandidateError(peer, event); + peer.onconnectionstatechange = () => this.onConnectionStateChange(peer); + peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange(peer); + peer.onnegotiationneeded = () => this.onNegotiationNeeded(peer); + peer.onicegatheringstatechange = () => this.onIceGatheringStateChange(peer); + peer.onsignalingstatechange = () => this.onSignalingStateChange(peer); + } + + private restartIce() { + this.iceGatheringFinished = false; + + this.localCandidates.clear(); + this.remoteCandidates.clear(); + + this.clearIceGatheringWaiters(new Error('ice-restart')); + + this.peer.restartIce(); + } + + private onIceCandidate(peer: RTCPeerConnection, event: RTCPeerConnectionIceEvent) { + if (peer !== this.peer) { + return; + } + + console.log('onIceCandidate event', event.candidate); + if (event.candidate) { + this.localCandidates.add(event.candidate); + } + } + + private onIceCandidateError(peer: RTCPeerConnection, _event: RTCPeerConnectionIceErrorEvent) { + if (peer !== this.peer) { + return; + } + console.log('onIceCandidate ERROR event'); + // + } + + private onNegotiationNeeded(peer: RTCPeerConnection) { + if (peer !== this.peer) { + return; + } + console.log('onNegotiationNeeded event'); + } + + private onTrack(peer: RTCPeerConnection, event: RTCTrackEvent): void { + if (peer !== this.peer) { + return; + } + // Received a remote stream + console.log('ontrack', event.track.kind, event.track.enabled); + + this.remoteStream.setTrack(event.track, peer); + } + + private onConnectionStateChange(peer: RTCPeerConnection) { + if (peer !== this.peer) { + return; + } + + console.log('Connection state change', peer.connectionState); + } + + private onIceConnectionStateChange(peer: RTCPeerConnection) { + if (peer !== this.peer) { + return; + } + + console.log('Ice connection state change', peer.iceConnectionState); + } + + private onSignalingStateChange(peer: RTCPeerConnection) { + if (peer !== this.peer) { + return; + } + + console.log('Signaling state change', peer.signalingState); + } + + private onIceGatheringStateChange(peer: RTCPeerConnection) { + if (peer !== this.peer) { + return; + } + + console.log('Ice gathering state change', peer.iceGatheringState); + + if (peer.iceGatheringState === 'complete') { + this.onIceGatheringComplete(); + } + } + + private async initializeLocalMediaStream(): Promise { + if (this.localMediaStreamInitialized) { + return; + } + + const mediaStream = await this.getuserMedia({ audio: true }); + + this.localStream.setStreamTracks(mediaStream, this.peer); + + this.localMediaStreamInitialized = true; + } + + private onIceGatheringComplete() { + console.log('onIceGatheringComplete'); + this.iceGatheringFinished = true; + + this.clearIceGatheringWaiters(); + } + + private clearIceGatheringData(iceGatheringData: IceGatheringData, error?: Error) { + console.log('clearIceGatheringData'); + if (this.iceGatheringWaiters.has(iceGatheringData)) { + this.iceGatheringWaiters.delete(iceGatheringData); + } + + if (iceGatheringData.timeout) { + console.log('clearTimeout'); + clearTimeout(iceGatheringData.timeout); + } + + if (error) { + console.log('reject wait promise'); + if (iceGatheringData.promiseReject) { + iceGatheringData.promiseReject(error); + } + + return; + } + + console.log('resolve wait promise'); + if (iceGatheringData.promiseResolve) { + iceGatheringData.promiseResolve(); + } + } + + private clearIceGatheringWaiters(error?: Error) { + console.log('clear waiters'); + const waiters = this.iceGatheringWaiters.values().toArray(); + this.iceGatheringWaiters.clear(); + + for (const iceGatheringData of waiters) { + this.clearIceGatheringData(iceGatheringData, error); + } + } +} diff --git a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts new file mode 100644 index 0000000000000..34e076bca4096 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts @@ -0,0 +1,24 @@ +import { Stream } from './Stream'; + +export class RemoteStream extends Stream { + public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { + console.log('setRemoteTrack'); + + if (this.mediaStream.getTrackById(newTrack.id)) { + console.log('remote track already set'); + return; + } + + if (newTrack.kind !== 'audio') { + console.log('received non-audio track: ', newTrack.kind); + return; + } + + this.mediaStream.getAudioTracks().forEach((track) => { + track.stop(); + this.mediaStream.removeTrack(track); + }); + + this.mediaStream.addTrack(newTrack); + } +} diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts new file mode 100644 index 0000000000000..525f3906593d6 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -0,0 +1,7 @@ +export class Stream { + protected mediaStream: MediaStream; + + constructor(mediaStream: MediaStream) { + this.mediaStream = mediaStream; + } +} diff --git a/packages/media-signaling/src/lib/services/webrtc/index.ts b/packages/media-signaling/src/lib/services/webrtc/index.ts new file mode 100644 index 0000000000000..c0b1f5e865bd7 --- /dev/null +++ b/packages/media-signaling/src/lib/services/webrtc/index.ts @@ -0,0 +1 @@ +export * from './Processor'; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index e6ebc081dd6af..524aec9fb1e75 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -12,4 +12,5 @@ export interface IMediaCallsModel extends IBaseModel { setCallerSessionIdById(callId: string, callerSessionId: string): Promise; setCalleeSessionIdById(callId: string, calleeSessionId: string): Promise; setActorSessionIdByIdAndRole(callId: string, sessionId: string, role: 'caller' | 'callee'): Promise; + hangupCallById(callId: string, params: { endedBy?: MediaCallActor; reason?: string } | undefined): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index e80da204d9382..3dc640a3f3458 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -88,6 +88,25 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async hangupCallById(callId: string, params?: { endedBy?: MediaCallActor; reason?: string }): Promise { + const { endedBy, reason } = params || {}; + + return this.updateOne( + { + _id: callId, + state: { $ne: 'hangup' }, + }, + { + $set: { + state: 'hangup', + endedAt: new Date(), + ...(endedBy && { endedBy }), + ...(reason && { hangupReason: reason }), + }, + }, + ); + } + public async setCallerSessionIdById(callId: string, callerSessionId: string): Promise { return this.updateOne({ '_id': callId, 'caller.sessionId': { $exists: false } }, { $set: { 'caller.sessionId': callerSessionId } }); } diff --git a/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts b/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts deleted file mode 100644 index 2892e6cda80dd..0000000000000 --- a/packages/ui-voip/src/lib/MediaCallWebRTCProcessor.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { DeliverParams, IWebRTCProcessor, RequestParams } from '@rocket.chat/media-signaling'; - -export class MediaCallWebRTCProcessor implements IWebRTCProcessor { - private peer: RTCPeerConnection; - - private iceGatheringFinished = false; - - private localMediaStream: MediaStream; - - private localMediaStreamInitialized = false; - - private remoteMediaStream: MediaStream; - - constructor() { - this.localMediaStream = new MediaStream(); - this.remoteMediaStream = new MediaStream(); - - this.peer = new RTCPeerConnection(); - this.registerPeerEvents(); - } - - public getRemoteMediaStream() { - return this.remoteMediaStream; - } - - public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { - await this.initializeLocalMediaStream(); - - if (iceRestart) { - this.restartIce(); - } - - // #ToDo: direction changes - - const offer = await this.peer.createOffer(); - await this.peer.setLocalDescription(offer); - - return { - sdp: offer, - endOfCandidates: this.iceGatheringFinished, - }; - } - - public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { - await this.initializeLocalMediaStream(); - - // #ToDo: direction changes - if (this.peer.remoteDescription?.sdp !== offer.sdp) { - this.peer.setRemoteDescription(offer); - } - - const answer = await this.peer.createAnswer(); - await this.peer.setLocalDescription(answer); - - return { - sdp: answer, - endOfCandidates: this.iceGatheringFinished, - }; - } - - public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { - await this.initializeLocalMediaStream(); - - const sdp = this.peer.localDescription; - - if (!sdp) { - throw new Error('no-local-sdp'); - } - - return { - sdp, - endOfCandidates: this.iceGatheringFinished, - }; - } - - public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { - await this.initializeLocalMediaStream(); - - // #ToDo: validate this.peer.signalingState ? - - this.peer.setRemoteDescription(sdp); - } - - public async addIceCandidates({ candidates }: DeliverParams<'ice-candidates'>): Promise { - const results = await Promise.allSettled(candidates.map((candidate) => this.peer?.addIceCandidate(candidate))); - - for (const result of results) { - if (result.status === 'rejected') { - throw result.reason; - } - } - } - - protected async getuserMedia(constraints: MediaStreamConstraints) { - if (navigator.mediaDevices === undefined) { - throw new Error('Media devices not available in insecure contexts.'); - } - - return navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices, constraints); - } - - private registerPeerEvents() { - this.peer.ontrack = (event) => this.onTrack(event); - this.peer.onicecandidate = (event) => this.onIceCandidate(event); - this.peer.onicecandidateerror = (event) => this.onIceCandidateError(event); - this.peer.onconnectionstatechange = () => this.onConnectionStateChange(); - this.peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange(); - this.peer.onnegotiationneeded = () => this.onNegotiationNeeded(); - } - - private restartIce() { - this.iceGatheringFinished = false; - this.peer.restartIce(); - } - - private onIceCandidate(_event: RTCPeerConnectionIceEvent) { - console.log('onIceCandidate event'); - // - } - - private onIceCandidateError(_event: RTCPeerConnectionIceErrorEvent) { - console.log('onIceCandidate ERROR event'); - // - } - - private onNegotiationNeeded() { - console.log('onNegotiationNeeded event'); - // - } - - private onTrack(event: RTCTrackEvent): void { - // Received a remote stream - console.log('ontrack', event.track.kind, event.track.enabled); - - this.setRemoteTrack(event.track); - } - - private onConnectionStateChange() { - // - } - - private onIceConnectionStateChange() { - // - } - - private setRemoteTrack(track: MediaStreamTrack): void { - console.log('setRemoteTrack'); - - if (this.remoteMediaStream.getTrackById(track.id)) { - console.log('remote track already set'); - return; - } - - if (track.kind !== 'audio') { - console.log('received non-audio track: ', track.kind); - return; - } - - this.remoteMediaStream.getAudioTracks().forEach((track) => { - track.stop(); - this.remoteMediaStream.removeTrack(track); - }); - - this.remoteMediaStream.addTrack(track); - } - - private async setLocalTrack(newTrack: MediaStreamTrack): Promise { - if (newTrack.kind === 'video') { - console.log('skipping video track'); - return; - } - - if (newTrack.kind !== 'audio') { - console.log(newTrack); - throw new Error('Unsupported track kind'); - } - - const sender = this.peer.getSenders().find((sender) => sender.track?.kind === newTrack.kind); - if (!sender) { - this.peer.addTrack(newTrack, this.localMediaStream); - this.localMediaStream.addTrack(newTrack); - return; - } - - console.log('Replacing sender track'); - await sender.replaceTrack(newTrack); - - console.log('Replacing track on local media stream'); - const oldTrack = this.localMediaStream.getTracks().find((localTrack) => localTrack.kind === newTrack.kind); - if (oldTrack) { - oldTrack.stop(); - this.localMediaStream.removeTrack(oldTrack); - } - - this.localMediaStream.addTrack(newTrack); - } - - private async initializeLocalMediaStream(): Promise { - if (this.localMediaStreamInitialized) { - return; - } - - const mediaStream = await this.getuserMedia({ audio: true }); - - const audioTracks = mediaStream.getAudioTracks(); - if (audioTracks.length) { - await this.setLocalTrack(audioTracks[0]); - } - - this.localMediaStreamInitialized = true; - } -} diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts index 42df555b898e6..cbf62c3e2a95e 100644 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -1,10 +1,10 @@ import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IRoom, IMediaCall } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import { IClientMediaCall, MediaSignal, MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { IClientMediaCall, MediaSignal, MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; import type { ContactInfo, VoipSession } from '../definitions'; -import { MediaCallWebRTCProcessor } from './MediaCallWebRTCProcessor'; import RemoteStream from './RemoteStream'; +import { getUserMedia } from './getUserMedia'; export type VoipEvents = Omit & { callestablished: ContactInfo; @@ -56,14 +56,16 @@ class MediaCallsClient extends Emitter { userId: config.userId, transport: (signal: MediaSignal) => config.sendSignalFn(signal), processorFactories: { - webrtc: () => new MediaCallWebRTCProcessor(), + webrtc: (config) => new MediaCallWebRTCProcessor(config), }, + mediaStreamFactory: getUserMedia, }); this.networkEmitter = new Emitter(); this.session.on('newCall', ({ call }) => this.onNewCall(call)); this.session.on('acceptedCall', ({ call }) => this.onAcceptedCall(call)); + this.session.on('endedCall', ({ call }) => this.onEndedCall(call)); } public async init() { @@ -114,6 +116,7 @@ class MediaCallsClient extends Emitter { } public async answer(): Promise { + console.log('answer'); const call = this.session.getMainCall(); if (!call) { throw new Error('No call available to answer'); @@ -123,6 +126,8 @@ class MediaCallsClient extends Emitter { } public async reject(): Promise { + console.log('reject'); + const call = this.session.getMainCall(); if (!call) { throw new Error('No call available to reject'); @@ -132,6 +137,8 @@ class MediaCallsClient extends Emitter { } public async endCall(): Promise { + console.log('endCall'); + const call = this.session.getMainCall(); if (!call) { throw new Error('No call available to hangup'); @@ -153,6 +160,8 @@ class MediaCallsClient extends Emitter { } public async changeAudioInputDevice(_constraints: MediaStreamConstraints): Promise { + console.log('changeAudioInputDevice'); + return true; // if (!this.isBusy()) { // console.warn('changeAudioInputDevice() : No session.'); @@ -173,6 +182,8 @@ class MediaCallsClient extends Emitter { } public switchAudioElement(audioElement: HTMLAudioElement | null): void { + console.log('switchAudioElement'); + this.audioElement = audioElement; if (this.remoteStream) { @@ -286,14 +297,20 @@ class MediaCallsClient extends Emitter { if (call.state === 'error') { return 'ERROR'; } - if (call.state === 'active') { + if (['active', 'accepted'].includes(call.state)) { return 'ONGOING'; } - if (call.role === 'callee') { - return 'INCOMING'; + if (call.state === 'hangup') { + return null; } - if (call.role === 'caller') { - return 'OUTGOING'; + + if (['none', 'ringing'].includes(call.state)) { + if (call.role === 'callee') { + return 'INCOMING'; + } + if (call.role === 'caller') { + return 'OUTGOING'; + } } return null; @@ -360,6 +377,7 @@ class MediaCallsClient extends Emitter { } private onNewCall(call: IClientMediaCall): void { + console.log('onNewCall'); const contact = this.getCallContactInfo(call) as ContactInfo; if (call.role === 'callee') { @@ -371,6 +389,8 @@ class MediaCallsClient extends Emitter { } private onAcceptedCall(call: IClientMediaCall): void { + console.log('onAcceptedCall'); + const remoteMediaStream = call.getRemoteMediaStream(); this.remoteStream = new RemoteStream(remoteMediaStream); this.playRemoteStream(); @@ -380,6 +400,14 @@ class MediaCallsClient extends Emitter { this.emit('stateChanged'); } + private onEndedCall(_call: IClientMediaCall): void { + console.log('onEndedCall'); + + this.remoteStream?.clear(); + this.emit('callterminated'); + this.emit('stateChanged'); + } + private getCallContactInfo(call: IClientMediaCall): ContactInfo | null { if (this.error) { return this.error.contact; @@ -403,6 +431,7 @@ class MediaCallsClient extends Emitter { // } private async startCall(roomId: IRoom['_id']): Promise { + console.log('startCall'); this.startingNewCall = true; try { this.emit('stateChanged'); @@ -420,6 +449,8 @@ class MediaCallsClient extends Emitter { } private playRemoteStream() { + console.log('playRemoteStream'); + if (!this.remoteStream) { console.warn(`Attempted to play missing remote media.`); return; diff --git a/packages/ui-voip/src/lib/getUserMedia.ts b/packages/ui-voip/src/lib/getUserMedia.ts new file mode 100644 index 0000000000000..59da4006b3172 --- /dev/null +++ b/packages/ui-voip/src/lib/getUserMedia.ts @@ -0,0 +1,7 @@ +export async function getUserMedia(constraints: MediaStreamConstraints): Promise { + if (navigator.mediaDevices === undefined) { + throw new Error('Media devices not available in insecure contexts.'); + } + + return navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices, constraints); +} From 1f2642c40e58e6343539696fbb4659e9c4fb667b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 29 Jul 2025 18:59:11 -0300 Subject: [PATCH 14/72] call by extension --- apps/meteor/app/api/server/v1/mediacalls.ts | 31 ++++++++++++++----- .../roomActions/useVoiceCallRoomAction.tsx | 4 +-- .../server/services/media-call/service.ts | 28 ++++++++++++++++- .../src/types/IMediaCallService.ts | 2 ++ .../v1/media-calls/MediaCallsStartProps.ts | 12 ++++--- .../VoipPopup/views/VoipDialerView.tsx | 2 +- packages/ui-voip/src/hooks/useVoipAPI.tsx | 17 ++-------- packages/ui-voip/src/lib/MediaCallsClient.ts | 23 ++++++-------- 8 files changed, 76 insertions(+), 43 deletions(-) diff --git a/apps/meteor/app/api/server/v1/mediacalls.ts b/apps/meteor/app/api/server/v1/mediacalls.ts index 8769db49590f5..e8cb5c68f64c9 100644 --- a/apps/meteor/app/api/server/v1/mediacalls.ts +++ b/apps/meteor/app/api/server/v1/mediacalls.ts @@ -1,8 +1,7 @@ import { MediaCall } from '@rocket.chat/core-services'; +import type { IMediaCall } from '@rocket.chat/core-typings'; import { isMediaCallsStartProps, isMediaCallsSignalProps } from '@rocket.chat/rest-typings'; -import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { API } from '../api'; API.v1.addRoute( @@ -10,18 +9,34 @@ API.v1.addRoute( { authRequired: true, validateParams: isMediaCallsStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, { async post() { - const { roomId, sessionId } = this.bodyParams; + const { sessionId, identifier, identifierKind } = this.bodyParams; const { userId } = this; - if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + + if (!userId) { return API.v1.failure('invalid-params'); } - if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { - return API.v1.forbidden(); + // #ToDo: Validate access and permissions + + // if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { + // return API.v1.forbidden(); + // } + + const caller = { uid: userId, sessionId }; + + const functions: Record Promise> = { + room: () => MediaCall.callRoom(caller, identifier), + user: () => MediaCall.callUser(caller, identifier), + extension: () => MediaCall.callExtension(caller, identifier), + }; + const startFn = functions[identifierKind]; + + if (!startFn) { + return API.v1.failure('invalid-params'); } try { - const call = await MediaCall.callRoom({ uid: userId, sessionId }, roomId); + const call = await startFn(); return API.v1.success({ call, @@ -35,7 +50,7 @@ API.v1.addRoute( API.v1.addRoute( 'media-calls.signal', - { authRequired: true, validateParams: isMediaCallsSignalProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, + { authRequired: true, validateParams: isMediaCallsSignalProps, rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 } }, { async post() { const { signal } = this.bodyParams; diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx index a069695607115..c858a591387a7 100644 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx @@ -12,7 +12,7 @@ import { useVoipWarningModal } from '../useVoipWarningModal'; export const useVoiceCallRoomAction = () => { const { t } = useTranslation(); - const { _id: rid, uids = [] } = useRoom(); + const { uids = [] } = useRoom(); const ownUserId = useUserId(); const canStartVoiceCall = usePermission('view-user-voip-extension'); const dispatchWarning = useVoipWarningModal(); @@ -48,7 +48,7 @@ export const useVoiceCallRoomAction = () => { const handleOnClick = useEffectEvent(() => { if (canMakeVoipCall) { - return makeCall({ rid, extension: remoteUser?.freeSwitchExtension }); + return makeCall(remoteUser?.freeSwitchExtension as string); } dispatchWarning(); }); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f2558b938c048..8ff05c37a4272 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -2,7 +2,7 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/ import type { IUser, IMediaCall, IRoom } from '@rocket.chat/core-typings'; import type { MediaSignal } from '@rocket.chat/media-signaling'; import { processSignal, createCall, setSignalHandler } from '@rocket.chat/media-signaling-server'; -import { Rooms } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; @@ -17,6 +17,11 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } public async createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise { + const user = await Users.findOneActiveById(callee.uid, { projection: { _id: 1 } }); + if (!user) { + throw new Error('invalid-user'); + } + return createCall( { type: 'user', @@ -40,6 +45,27 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return this.createInternalCall(caller, { uid: calleeId }); } + public async callExtension(caller: { uid: IUser['_id']; sessionId: string }, extension: string): Promise { + const user = await Users.findOneByFreeSwitchExtension(extension, { projection: { _id: 1 } }); + if (!user) { + throw new Error('invalid-user'); + } + + return createCall( + { + type: 'user', + id: caller.uid, + sessionId: caller.sessionId, + }, + { type: 'user', id: user._id }, + ); + } + + public async callUser(caller: { uid: IUser['_id']; sessionId: string }, userId: IUser['_id']): Promise { + // For now every call is between two users + return this.createInternalCall(caller, { uid: userId }); + } + private async sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { void api.broadcast('user.media-signal', { userId: toUid, signal }); } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index b4ceaf6a1c40e..3645977f83de6 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -5,4 +5,6 @@ export interface IMediaCallService { processSignal(signal: MediaSignal, fromUid: IUser['_id']): Promise; createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise; callRoom(caller: { uid: IUser['_id']; sessionId: string }, roomId: IRoom['_id']): Promise; + callExtension(caller: { uid: IUser['_id']; sessionId: string }, extension: string): Promise; + callUser(caller: { uid: IUser['_id']; sessionId: string }, userId: IUser['_id']): Promise; } diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts index ecb2520d439d2..02e9d0564fbd4 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts @@ -3,21 +3,25 @@ import Ajv from 'ajv'; const ajv = new Ajv(); -export type MediaCallsStartProps = { roomId: string; sessionId: string }; +export type MediaCallsStartProps = { sessionId: string; identifier: string; identifierKind: 'room' | 'user' | 'extension' }; const mediaCallsStartPropsSchema: JSONSchemaType = { type: 'object', properties: { - roomId: { + sessionId: { type: 'string', nullable: false, }, - sessionId: { + identifier: { + type: 'string', + nullable: false, + }, + identifierKind: { type: 'string', nullable: false, }, }, - required: ['roomId', 'sessionId'], + required: ['sessionId', 'identifier', 'identifierKind'], additionalProperties: true, }; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx index c073da30f6194..a35b28763bda4 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx @@ -21,7 +21,7 @@ const VoipDialerView = forwardRef(function const [number, setNumber] = useState(''); const handleCall = () => { - makeCall({ extension: number }); + makeCall(number); closeDialer(); }; diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx index 8616b8b4705b9..c8fae738ba052 100644 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -5,7 +5,7 @@ import { VoipContext, isVoipContextReady } from '../contexts/VoipContext'; import MediaCallsClient from '../lib/MediaCallsClient'; type VoipAPI = { - makeCall(callee: { uid?: string; rid?: string; extension?: string } | string): void; + makeCall(callee: string): void; endCall(): void; register(): Promise; unregister(): Promise; @@ -47,13 +47,7 @@ export const useVoipAPI = (): VoipAPI => { const mediaCallsClient = voipClient as MediaCallsClient; return { - makeCall: (callee) => { - if (typeof callee === 'string') { - return mediaCallsClient.call({ extension: callee }); - } - - return mediaCallsClient.call(callee); - }, + makeCall: (callee) => mediaCallsClient.call({ identifier: callee, identifierKind: 'extension' }), endCall: () => mediaCallsClient.endCall(), register: NOOP, unregister: NOOP, @@ -68,12 +62,7 @@ export const useVoipAPI = (): VoipAPI => { } return { - makeCall: (callee) => { - if (typeof callee === 'string') { - return voipClient.call(callee); - } - return voipClient.call(callee.extension as string); - }, + makeCall: voipClient.call, endCall: voipClient.endCall, register: voipClient.register, unregister: voipClient.unregister, diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts index cbf62c3e2a95e..e877e4fb32182 100644 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ b/packages/ui-voip/src/lib/MediaCallsClient.ts @@ -1,4 +1,4 @@ -import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IRoom, IMediaCall } from '@rocket.chat/core-typings'; +import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IMediaCall } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { IClientMediaCall, MediaSignal, MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; @@ -20,14 +20,17 @@ type SessionError = { contact: ContactInfo; }; +export type MediaCallsCallee = { + identifier: string; + identifierKind: 'user' | 'room' | 'extension'; +}; + export type MediaCallsClientConfig = { userId: IUser['_id']; - startCallFn: (params: { roomId: string; sessionId: string }) => Promise; + startCallFn: (params: { sessionId: string } & MediaCallsCallee) => Promise; sendSignalFn: (signal: MediaSignal) => void; }; -export type MediaCallsCallee = { uid?: IUser['_id']; rid?: IRoom['_id']; extension?: string }; - class MediaCallsClient extends Emitter { public networkEmitter: Emitter; @@ -89,18 +92,12 @@ class MediaCallsClient extends Emitter { public async call(callee: MediaCallsCallee): Promise { console.log('MediaCallsClient.call', callee); - if (!callee.rid) { - if (callee.uid || callee.extension) { - throw new Error('not-implemented'); - } - throw new Error('Invalid Callee'); - } if (this.isBusy()) { throw new Error('Session already exists'); } - await this.startCall(callee.rid); + await this.startCall(callee); } public async transfer(calleeURI: string): Promise { @@ -430,13 +427,13 @@ class MediaCallsClient extends Emitter { // this.emit('stateChanged'); // } - private async startCall(roomId: IRoom['_id']): Promise { + private async startCall(target: MediaCallsCallee): Promise { console.log('startCall'); this.startingNewCall = true; try { this.emit('stateChanged'); - const call = await this.config.startCallFn({ roomId, sessionId: this.session.sessionId }); + const call = await this.config.startCallFn({ sessionId: this.session.sessionId, ...target }); console.log('startCall', call); const { _id: callId, callee } = call; From b5f5cba599fc5e5e3b6933670f97ae4a910828b1 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 29 Jul 2025 19:01:01 -0300 Subject: [PATCH 15/72] missed a file --- .../hooks/useUserInfoActions/actions/useVoipCallAction.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx index a14e111c3be57..9684c2065d296 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx @@ -24,7 +24,7 @@ export const useVoipCallAction = (user: Pick(() => { const handleClick = () => { - makeCall({ uid: user?._id, extension: user?.freeSwitchExtension }); + makeCall(user?.freeSwitchExtension as string); closeUserCard(); }; @@ -37,7 +37,7 @@ export const useVoipCallAction = (user: Pick Date: Thu, 31 Jul 2025 02:59:09 -0300 Subject: [PATCH 16/72] simplifying everything --- .../core-typings/src/mediaCalls/IMediaCall.ts | 2 - .../src/mediaCalls/IMediaCallChannel.ts | 16 +- .../src/calls/acknowledgeCallee.ts | 11 +- .../src/calls/acknowledgeCaller.ts | 15 + .../src/calls/createCall.ts | 1 - .../src/calls/getNewCallSequence.ts | 11 - .../src/calls/processAcceptedCall.ts | 37 +- .../src/calls/processEndedCall.ts | 11 +- .../src/channels/createInitialChannel.ts | 6 +- .../src/channels/deliverChannelSDP.ts | 31 +- .../src/channels/getRoleForActor.ts | 7 +- .../src/channels/requestChannelOffer.ts | 17 +- .../src/channels/setRemoteSDP.ts | 31 -- .../src/signals/buildSignal.ts | 11 - .../src/signals/deliver/processDeliver.ts | 11 - .../src/signals/deliver/sdp.ts | 30 -- .../src/signals/getChannelForSignal.ts | 41 +-- .../src/signals/isValidSignalRole.ts | 4 +- .../src/signals/notify/accept.ts | 33 -- .../src/signals/notify/ack.ts | 14 - .../src/signals/notify/hangup.ts | 30 -- .../src/signals/notify/processNotify.ts | 17 - .../src/signals/processAnswer.ts | 46 +++ .../src/signals/processHangup.ts | 21 ++ .../src/signals/processSDP.ts | 20 ++ .../src/signals/processSignal.ts | 78 +++-- .../src/signals/sendNewSignals.ts | 38 ++- .../src/signals/sendSignalToActor.ts | 22 +- .../src/signals/sendSignalToAllActors.ts | 44 ++- .../src/signals/sendSignalToChannel.ts | 23 +- .../src/signals/sendSignalToChannels.ts | 4 +- .../src/definition/IWebRTCProcessor.ts | 14 +- .../src/definition/MediaSignal.ts | 69 ++++ .../src/definition/MediaSignalTransport.ts | 2 +- .../src/definition/call/CallContact.ts | 1 - .../src/definition/call/CallEvents.ts | 2 +- .../src/definition/call/CallRole.ts | 5 - .../src/definition/call/CallService.ts | 1 - .../src/definition/call/CallState.ts | 1 - .../src/definition/call/IClientMediaCall.ts | 25 +- .../src/definition/call/index.ts | 4 - .../media-signaling/src/definition/index.ts | 2 +- .../src/definition/signal/MediaSignal.ts | 17 - .../definition/signal/MediaSignalDeliver.ts | 36 -- .../definition/signal/MediaSignalHeader.ts | 15 - .../definition/signal/MediaSignalNotify.ts | 85 ----- .../definition/signal/MediaSignalRequest.ts | 30 -- .../src/definition/signal/index.ts | 5 - packages/media-signaling/src/lib/Call.ts | 188 ++++------- packages/media-signaling/src/lib/Session.ts | 20 +- .../src/lib/TransportWrapper.ts | 78 ++--- .../src/lib/services/webrtc/Processor.ts | 54 +-- .../src/lib/utils/isNotifyNew.ts | 5 - .../lib/utils/signalTypeRequiresTargeting.ts | 5 + .../src/models/IMediaCallChannelsModel.ts | 8 +- .../models/src/models/MediaCallChannels.ts | 46 ++- .../v1/media-calls/MediaCallsSignalProps.ts | 318 ++++++------------ packages/ui-voip/src/hooks/useVoipAPI.tsx | 2 +- 58 files changed, 632 insertions(+), 1089 deletions(-) create mode 100644 packages/media-signaling-server/src/calls/acknowledgeCaller.ts delete mode 100644 packages/media-signaling-server/src/calls/getNewCallSequence.ts delete mode 100644 packages/media-signaling-server/src/channels/setRemoteSDP.ts delete mode 100644 packages/media-signaling-server/src/signals/buildSignal.ts delete mode 100644 packages/media-signaling-server/src/signals/deliver/processDeliver.ts delete mode 100644 packages/media-signaling-server/src/signals/deliver/sdp.ts delete mode 100644 packages/media-signaling-server/src/signals/notify/accept.ts delete mode 100644 packages/media-signaling-server/src/signals/notify/ack.ts delete mode 100644 packages/media-signaling-server/src/signals/notify/hangup.ts delete mode 100644 packages/media-signaling-server/src/signals/notify/processNotify.ts create mode 100644 packages/media-signaling-server/src/signals/processAnswer.ts create mode 100644 packages/media-signaling-server/src/signals/processHangup.ts create mode 100644 packages/media-signaling-server/src/signals/processSDP.ts create mode 100644 packages/media-signaling/src/definition/MediaSignal.ts delete mode 100644 packages/media-signaling/src/definition/call/CallContact.ts delete mode 100644 packages/media-signaling/src/definition/call/CallRole.ts delete mode 100644 packages/media-signaling/src/definition/call/CallService.ts delete mode 100644 packages/media-signaling/src/definition/call/CallState.ts delete mode 100644 packages/media-signaling/src/definition/signal/MediaSignal.ts delete mode 100644 packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts delete mode 100644 packages/media-signaling/src/definition/signal/MediaSignalHeader.ts delete mode 100644 packages/media-signaling/src/definition/signal/MediaSignalNotify.ts delete mode 100644 packages/media-signaling/src/definition/signal/MediaSignalRequest.ts delete mode 100644 packages/media-signaling/src/definition/signal/index.ts delete mode 100644 packages/media-signaling/src/lib/utils/isNotifyNew.ts create mode 100644 packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index b6fe2b81c4aa5..eb1f5cb5e5d28 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -40,6 +40,4 @@ export interface IMediaCall extends IRocketChatRecord { endedBy?: MediaCallActor; endedAt?: Date; hangupReason?: string; - - sequence: number; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index 11fd0c9dcae22..db5ee24ff9c49 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -29,19 +29,12 @@ export type MediaCallChannelUserSIP = MediaCallParticipantIdentification & { export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; -export type MediaCallChannelWebRTCSession = { - description: RTCSessionDescriptionInit | null; - iceCandidates: RTCIceCandidateInit[]; - iceGatheringComplete: boolean; - assignSequence: number; -}; - export interface IMediaCallChannel extends IRocketChatRecord { callId: string; participant: MediaCallParticipant; - role: 'caller' | 'callee' | 'none'; + role: 'caller' | 'callee'; state: 'none' | 'ringing' | 'joining' | 'active' | 'left'; @@ -52,10 +45,9 @@ export interface IMediaCallChannel extends IRocketChatRecord { // The moment when the user left the call or hanged up leftAt?: Date; - webrtc?: { - local?: MediaCallChannelWebRTCSession; - remote?: MediaCallChannelWebRTCSession; - }; + localDescription?: RTCSessionDescriptionInit; + + acknowledged: boolean; } export type ValidSignalChannel = IMediaCallChannel & { diff --git a/packages/media-signaling-server/src/calls/acknowledgeCallee.ts b/packages/media-signaling-server/src/calls/acknowledgeCallee.ts index c9e42a925b081..8be292a7dc6a3 100644 --- a/packages/media-signaling-server/src/calls/acknowledgeCallee.ts +++ b/packages/media-signaling-server/src/calls/acknowledgeCallee.ts @@ -1,8 +1,6 @@ import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; import { MediaCalls } from '@rocket.chat/models'; -import { getOppositeChannel } from '../channels/getOppositeChannel'; -import { requestChannelOffer } from '../channels/requestChannelOffer'; import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; export async function acknowledgeCallee(call: IMediaCall, channel: ValidSignalChannel): Promise { @@ -11,13 +9,6 @@ export async function acknowledgeCallee(call: IMediaCall, channel: ValidSignalCh return; } - const callerChannel = await getOppositeChannel(call, channel); - // Change the call state from 'none' to 'ringing' when any callee session is found - const result = await MediaCalls.startRingingById(call._id); - - // If the state was changed and the caller channel is set, request the webrtc offer from the caller - if (result.modifiedCount && callerChannel) { - await requestChannelOffer(callerChannel); - } + await MediaCalls.startRingingById(call._id); } diff --git a/packages/media-signaling-server/src/calls/acknowledgeCaller.ts b/packages/media-signaling-server/src/calls/acknowledgeCaller.ts new file mode 100644 index 0000000000000..621d32697b49f --- /dev/null +++ b/packages/media-signaling-server/src/calls/acknowledgeCaller.ts @@ -0,0 +1,15 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; + +import { requestChannelOffer } from '../channels/requestChannelOffer'; +import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; + +export async function acknowledgeCaller(call: IMediaCall, channel: ValidSignalChannel): Promise { + // If we're not the caller, or we are a different caller session than the one where the call was started, do nothing + if (!compareParticipantAndActor(channel.participant, call.caller)) { + return; + } + + if (call.caller.type === 'user' && call.caller.sessionId) { + await requestChannelOffer(channel, {}); + } +} diff --git a/packages/media-signaling-server/src/calls/createCall.ts b/packages/media-signaling-server/src/calls/createCall.ts index def6c91197479..2dbfd58e0d199 100644 --- a/packages/media-signaling-server/src/calls/createCall.ts +++ b/packages/media-signaling-server/src/calls/createCall.ts @@ -35,7 +35,6 @@ export async function createCall(caller: MediaCallActor, callee: MediaCallActor) createdBy: caller, createdAt: new Date(), - sequence: 0, caller: { ...caller, diff --git a/packages/media-signaling-server/src/calls/getNewCallSequence.ts b/packages/media-signaling-server/src/calls/getNewCallSequence.ts deleted file mode 100644 index dd596b0390599..0000000000000 --- a/packages/media-signaling-server/src/calls/getNewCallSequence.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; - -export async function getNewCallSequence(callId: IMediaCall['_id']): Promise { - const call = await MediaCalls.getNewSequence(callId); - if (!call) { - throw new Error('failed-to-reserve-sequence'); - } - - return call; -} diff --git a/packages/media-signaling-server/src/calls/processAcceptedCall.ts b/packages/media-signaling-server/src/calls/processAcceptedCall.ts index d80cfc8bd21c9..7ea82d744782b 100644 --- a/packages/media-signaling-server/src/calls/processAcceptedCall.ts +++ b/packages/media-signaling-server/src/calls/processAcceptedCall.ts @@ -1,10 +1,9 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; import { MediaCalls } from '@rocket.chat/models'; -import { getNewCallSequence } from './getNewCallSequence'; +import { deliverChannelSDP } from '../channels/deliverChannelSDP'; import { getActorChannel } from '../channels/getActorChannel'; // import { requestChannelOffer } from '../channels/requestChannelOffer'; -import { setRemoteSDP } from '../channels/setRemoteSDP'; import { isValidSignalChannel } from '../signals/isValidSignalChannel'; import { sendSignalToChannels } from '../signals/sendSignalToChannels'; import { logger } from '../utils/logger'; @@ -20,7 +19,7 @@ export async function processAcceptedCall(callId: IMediaCall['_id']): Promise isValidSignalChannel(channel)); if (validChannels.length) { - const newSequence = await getNewCallSequence(callId); - if (newSequence) { - await sendSignalToChannels(validChannels, { - sequence: newSequence.sequence, - type: 'notify', - body: { - notify: 'accept', - }, - }); - } + await sendSignalToChannels(validChannels, { + type: 'notification', + body: { + notification: 'accepted', + }, + }); } } diff --git a/packages/media-signaling-server/src/calls/processEndedCall.ts b/packages/media-signaling-server/src/calls/processEndedCall.ts index 6401844b36517..5e35bee7cd7c7 100644 --- a/packages/media-signaling-server/src/calls/processEndedCall.ts +++ b/packages/media-signaling-server/src/calls/processEndedCall.ts @@ -1,17 +1,12 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; -import { getNewCallSequence } from './getNewCallSequence'; import { sendSignalToAllActors } from '../signals/sendSignalToAllActors'; -export async function processEndedCall(callId: IMediaCall['_id']): Promise { - const call = await getNewCallSequence(callId); - +export async function processEndedCall(call: IMediaCall): Promise { await sendSignalToAllActors(call, { - sequence: call.sequence, - type: 'notify', + type: 'notification', body: { - notify: 'hangup', - reasonCode: 'normal', + notification: 'hangup', }, }); } diff --git a/packages/media-signaling-server/src/channels/createInitialChannel.ts b/packages/media-signaling-server/src/channels/createInitialChannel.ts index ad5f6537a582e..3130278f9bba4 100644 --- a/packages/media-signaling-server/src/channels/createInitialChannel.ts +++ b/packages/media-signaling-server/src/channels/createInitialChannel.ts @@ -1,11 +1,11 @@ -import type { IMediaCallChannel, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { AtLeast, IMediaCallChannel, IUser, MediaCallActor } from '@rocket.chat/core-typings'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels } from '@rocket.chat/models'; export async function createInitialChannel( callId: string, actor: MediaCallActor, - channelData: Partial>, + channelData: AtLeast, 'role'>, userData?: Pick, ): Promise { // Rocket.Chat users will have a separate channel entry for each session involved in the call @@ -19,7 +19,6 @@ export async function createInitialChannel( } await MediaCallChannels.insertOne({ - role: 'none', ...channelData, callId, participant: { @@ -31,5 +30,6 @@ export async function createInitialChannel( }, state: 'none', joinedAt: new Date(), + acknowledged: false, }); } diff --git a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts index a8244f25f09f0..784be33372722 100644 --- a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts +++ b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts @@ -1,33 +1,14 @@ import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { DeliverParams } from '@rocket.chat/media-signaling'; +import type { MediaSignalSDP } from '@rocket.chat/media-signaling'; -import { getNewCallSequence } from '../calls/getNewCallSequence'; import { sendSignalToChannel } from '../signals/sendSignalToChannel'; import { validateChannelForSignals } from '../signals/validateChannelForSignals'; -export async function deliverChannelSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>): Promise { +export async function deliverChannelSDP(channel: IMediaCallChannel, body: MediaSignalSDP): Promise { validateChannelForSignals(channel); - const call = await getNewCallSequence(channel.callId); - - // If the sdp is an offer, send an answer request, otherwise simply deliver it - if (params.sdp.type === 'offer') { - await sendSignalToChannel(channel, { - sequence: call.sequence, - type: 'request', - body: { - request: 'answer', - offer: params.sdp, - }, - }); - } else { - await sendSignalToChannel(channel, { - sequence: call.sequence, - type: 'deliver', - body: { - deliver: 'sdp', - ...params, - }, - }); - } + await sendSignalToChannel(channel, { + type: 'sdp', + body, + }); } diff --git a/packages/media-signaling-server/src/channels/getRoleForActor.ts b/packages/media-signaling-server/src/channels/getRoleForActor.ts index ffb5772a35d9a..2f5528f6d7194 100644 --- a/packages/media-signaling-server/src/channels/getRoleForActor.ts +++ b/packages/media-signaling-server/src/channels/getRoleForActor.ts @@ -1,8 +1,9 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; import { compareActorsIgnoringSession } from '../utils/compareActorsIgnoringSession'; -export function getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMediaCallChannel['role'] { +export function getRoleForActor(call: IMediaCall, actor: MediaCallActor): CallRole | null { if (compareActorsIgnoringSession(call.caller, actor)) { return 'caller'; } @@ -11,5 +12,5 @@ export function getRoleForActor(call: IMediaCall, actor: MediaCallActor): IMedia return 'callee'; } - return 'none'; + return null; } diff --git a/packages/media-signaling-server/src/channels/requestChannelOffer.ts b/packages/media-signaling-server/src/channels/requestChannelOffer.ts index 37e33026b3f90..1db77af53145d 100644 --- a/packages/media-signaling-server/src/channels/requestChannelOffer.ts +++ b/packages/media-signaling-server/src/channels/requestChannelOffer.ts @@ -1,26 +1,19 @@ import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { RequestParams } from '@rocket.chat/media-signaling'; +import type { MediaSignalRequestOffer } from '@rocket.chat/media-signaling'; -import { getNewCallSequence } from '../calls/getNewCallSequence'; import { sendSignalToChannel } from '../signals/sendSignalToChannel'; import { validateChannelForSignals } from '../signals/validateChannelForSignals'; -export async function requestChannelOffer(channel: IMediaCallChannel, params?: RequestParams<'offer'>): Promise { +export async function requestChannelOffer(channel: IMediaCallChannel, params: MediaSignalRequestOffer): Promise { // If the channel already has a local Sdp, no need to request its offer unless we're restarting ICE - if (channel.webrtc?.local && !params?.iceRestart) { + if (channel.localDescription?.sdp && !params.iceRestart) { return; } validateChannelForSignals(channel); - const call = await getNewCallSequence(channel.callId); - await sendSignalToChannel(channel, { - sequence: call.sequence, - type: 'request', - body: { - request: 'offer', - ...params, - }, + type: 'request-offer', + body: params, }); } diff --git a/packages/media-signaling-server/src/channels/setRemoteSDP.ts b/packages/media-signaling-server/src/channels/setRemoteSDP.ts deleted file mode 100644 index 16ff29fdd9c17..0000000000000 --- a/packages/media-signaling-server/src/channels/setRemoteSDP.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { DeliverParams } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; - -import { deliverChannelSDP } from './deliverChannelSDP'; - -export async function setRemoteSDP(channel: IMediaCallChannel, params: DeliverParams<'sdp'>, sequence: number): Promise { - const result = await MediaCallChannels.setRemoteWebRTCSession(channel._id, { - description: params.sdp, - iceCandidates: [], - iceGatheringComplete: Boolean(params.endOfCandidates), - assignSequence: sequence, - }); - if (!result.modifiedCount) { - console.log('remote sdp NOT changed for ', channel.role); - return; - } - console.log('remote sdp changed for ', channel.role); - - if (channel.participant.type !== 'user') { - // No need to send any signals if the remote participant is not a rocket.chat user - return; - } - - await deliverChannelSDP(channel, { - sdp: params.sdp, - endOfCandidates: params.endOfCandidates, - }); - - // #ToDo: deliver ice candidates -} diff --git a/packages/media-signaling-server/src/signals/buildSignal.ts b/packages/media-signaling-server/src/signals/buildSignal.ts deleted file mode 100644 index b66a30bcc66bc..0000000000000 --- a/packages/media-signaling-server/src/signals/buildSignal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { MediaSignal, MediaSignalBody, MediaSignalHeaderParams, MediaSignalType } from '@rocket.chat/media-signaling'; - -// This function's only purpose is to make the typecast safer; -// If all the params can be infered to the right types, then the signal can be typecasted. -export function buildSignal(header: MediaSignalHeaderParams, type: T, body: MediaSignalBody): MediaSignal { - return { - ...header, - type, - body, - } as MediaSignal; -} diff --git a/packages/media-signaling-server/src/signals/deliver/processDeliver.ts b/packages/media-signaling-server/src/signals/deliver/processDeliver.ts deleted file mode 100644 index 2d9e03cf8c929..0000000000000 --- a/packages/media-signaling-server/src/signals/deliver/processDeliver.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalDeliver } from '@rocket.chat/media-signaling'; - -import { processSDP } from './sdp'; - -export async function processDeliver(signal: MediaSignalDeliver, call: IMediaCall, channel: ValidSignalChannel): Promise { - switch (signal.body.deliver) { - case 'sdp': - return processSDP(signal as MediaSignalDeliver<'sdp'>, call, channel); - } -} diff --git a/packages/media-signaling-server/src/signals/deliver/sdp.ts b/packages/media-signaling-server/src/signals/deliver/sdp.ts deleted file mode 100644 index 1b1ff6c643d5e..0000000000000 --- a/packages/media-signaling-server/src/signals/deliver/sdp.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalDeliver } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; - -import { getOppositeChannel } from '../../channels/getOppositeChannel'; -import { setRemoteSDP } from '../../channels/setRemoteSDP'; - -export async function processSDP(signal: MediaSignalDeliver<'sdp'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - // Save the SDP for the local session of the channel - await MediaCallChannels.setLocalWebRTCSession(channel._id, { - description: signal.body.sdp, - iceCandidates: [], - iceGatheringComplete: Boolean(signal.body.endOfCandidates), - assignSequence: signal.sequence, - }); - - // Find the opposite channel and save the SDP there as well - const otherChannel = await getOppositeChannel(call, channel); - // otherChannel will only be defined if the other participant has already accepted the call - if (otherChannel) { - await setRemoteSDP( - otherChannel, - { - sdp: signal.body.sdp, - endOfCandidates: Boolean(signal.body.endOfCandidates), - }, - signal.sequence, - ); - } -} diff --git a/packages/media-signaling-server/src/signals/getChannelForSignal.ts b/packages/media-signaling-server/src/signals/getChannelForSignal.ts index 38f731e4c76d6..1a92e52120791 100644 --- a/packages/media-signaling-server/src/signals/getChannelForSignal.ts +++ b/packages/media-signaling-server/src/signals/getChannelForSignal.ts @@ -1,46 +1,19 @@ -import type { AtLeast, IMediaCall, IMediaCallChannel, IUser, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels } from '@rocket.chat/models'; -import { getRoleForActor } from '../channels/getRoleForActor'; - -type ValidUser = AtLeast, '_id' | 'username' | 'name'>; - export async function getChannelForSignal( - signal: MediaSignal & { sessionId: Required['sessionId'] }, - call: IMediaCall, - user: ValidUser, + channelData: Pick, ): Promise { - const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; - - // Every time the server receives any signal, we need to check if the client that sent it is already in the call's channel list - const existingChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); - - if (existingChannel) { - return existingChannel as ValidSignalChannel; - } - - const role = getRoleForActor(call, actor); - + console.log('getChannelForSignal', channelData.role); const newChannel: InsertionModel = { - callId: call._id, - participant: { - ...actor, - username: user.username, - displayName: user.name, - }, - role, + ...channelData, state: 'none', + acknowledged: true, }; - try { - await MediaCallChannels.insertOne(newChannel); - } catch (e) { - // #ToDo: Check if it was really a race condition? - } - - const insertedChannel = await MediaCallChannels.findOneByCallIdAndParticipant(call._id, actor); + // Create this channel if it doesn't yet exist, or update it with ack = true if it does + const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); if (!insertedChannel) { throw new Error('failed-to-insert-channel'); } diff --git a/packages/media-signaling-server/src/signals/isValidSignalRole.ts b/packages/media-signaling-server/src/signals/isValidSignalRole.ts index 7f30e53840d59..4312e619d9fbe 100644 --- a/packages/media-signaling-server/src/signals/isValidSignalRole.ts +++ b/packages/media-signaling-server/src/signals/isValidSignalRole.ts @@ -1,6 +1,6 @@ import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { CallRole } from '@rocket.chat/media-signaling'; -export function isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is MediaSignal['role'] { +export function isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is CallRole { return ['caller', 'callee'].includes(channelRole); } diff --git a/packages/media-signaling-server/src/signals/notify/accept.ts b/packages/media-signaling-server/src/signals/notify/accept.ts deleted file mode 100644 index a53a2fde3e0e7..0000000000000 --- a/packages/media-signaling-server/src/signals/notify/accept.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { processAcceptedCall } from '../../calls/processAcceptedCall'; -import { compareParticipantAndActor } from '../../utils/compareParticipantAndActor'; -import { isValidSignalRole } from '../isValidSignalRole'; - -export async function processAccept(_signal: MediaSignalNotify<'accept'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - if (!isValidSignalRole(channel.role)) { - throw new Error('error-invalid-role'); - } - - // If the channel's participant doesn't match the actor assigned to the call for this role, do nothing - if (!compareParticipantAndActor(channel.participant, call[channel.role])) { - return; - } - - const result = await MediaCalls.setActorSessionIdByIdAndRole(call._id, channel.participant.sessionId, channel.role); - if (!result.modifiedCount) { - // If nothing changed, the call already had a sessionId for this actor - return; - } - - // With session decided for this actor, let's try to move the call state to 'accepted'. This will only work if the other actor also has an assigned session - const stateResult = await MediaCalls.acceptCallById(call._id); - if (!stateResult.modifiedCount) { - return; - } - - // #ToDo: notify client if this throws any error - return processAcceptedCall(call._id); -} diff --git a/packages/media-signaling-server/src/signals/notify/ack.ts b/packages/media-signaling-server/src/signals/notify/ack.ts deleted file mode 100644 index 17aa74af06150..0000000000000 --- a/packages/media-signaling-server/src/signals/notify/ack.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; - -import { acknowledgeCallee } from '../../calls/acknowledgeCallee'; -// import { acknowledgeCaller } from '../../calls/acknowledgeCaller'; - -export async function processACK(_signal: MediaSignalNotify<'ack'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - switch (channel.role) { - case 'callee': - return acknowledgeCallee(call, channel); - // case 'caller': - // return acknowledgeCaller(call, channel); - } -} diff --git a/packages/media-signaling-server/src/signals/notify/hangup.ts b/packages/media-signaling-server/src/signals/notify/hangup.ts deleted file mode 100644 index efe63d0061dff..0000000000000 --- a/packages/media-signaling-server/src/signals/notify/hangup.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { processEndedCall } from '../../calls/processEndedCall'; -import { compareParticipantAndActor } from '../../utils/compareParticipantAndActor'; -import { isValidSignalRole } from '../isValidSignalRole'; - -export async function processHangup(signal: MediaSignalNotify<'hangup'>, call: IMediaCall, channel: ValidSignalChannel): Promise { - if (!isValidSignalRole(channel.role)) { - throw new Error('error-invalid-role'); - } - - const actor = call[channel.role]; - - // If the channel's participant doesn't match the actor assigned to the call for this role, do nothing - if (!compareParticipantAndActor(channel.participant, actor)) { - return; - } - - const endedBy = { ...actor, ...(actor.type === 'user' && !actor.sessionId && { sessionId: channel.participant.sessionId }) }; - const reason = signal.body.reasonCode || 'normal'; - - const stateResult = await MediaCalls.hangupCallById(call._id, { endedBy, reason }); - if (!stateResult.modifiedCount) { - return; - } - - return processEndedCall(call._id); -} diff --git a/packages/media-signaling-server/src/signals/notify/processNotify.ts b/packages/media-signaling-server/src/signals/notify/processNotify.ts deleted file mode 100644 index 50c5e49f17b49..0000000000000 --- a/packages/media-signaling-server/src/signals/notify/processNotify.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalNotify } from '@rocket.chat/media-signaling'; - -import { processAccept } from './accept'; -import { processACK } from './ack'; -import { processHangup } from './hangup'; - -export async function processNotify(signal: MediaSignalNotify, call: IMediaCall, channel: ValidSignalChannel): Promise { - switch (signal.body.notify) { - case 'ack': - return processACK(signal as MediaSignalNotify<'ack'>, call, channel); - case 'accept': - return processAccept(signal as MediaSignalNotify<'accept'>, call, channel); - case 'hangup': - return processHangup(signal as MediaSignalNotify<'hangup'>, call, channel); - } -} diff --git a/packages/media-signaling-server/src/signals/processAnswer.ts b/packages/media-signaling-server/src/signals/processAnswer.ts new file mode 100644 index 0000000000000..f97bb964c5a64 --- /dev/null +++ b/packages/media-signaling-server/src/signals/processAnswer.ts @@ -0,0 +1,46 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalAnswer } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { acknowledgeCallee } from '../calls/acknowledgeCallee'; +import { acknowledgeCaller } from '../calls/acknowledgeCaller'; +import { processAcceptedCall } from '../calls/processAcceptedCall'; + +async function processAccept(call: IMediaCall, channel: ValidSignalChannel): Promise { + const result = await MediaCalls.setActorSessionIdByIdAndRole(call._id, channel.participant.sessionId, channel.role); + if (!result.modifiedCount) { + // If nothing changed, the call already had a sessionId for this actor + return; + } + + // With session decided for this actor, let's try to move the call state to 'accepted'. This will only work if the other actor also has an assigned session + const stateResult = await MediaCalls.acceptCallById(call._id); + if (!stateResult.modifiedCount) { + return; + } + + // #ToDo: notify client if this throws any error + return processAcceptedCall(call._id); +} + +async function processACK(call: IMediaCall, channel: ValidSignalChannel): Promise { + if (channel.role === 'callee') { + return acknowledgeCallee(call, channel); + } + return acknowledgeCaller(call, channel); +} + +export async function processAnswer(params: MediaSignalAnswer, call: IMediaCall, channel: ValidSignalChannel): Promise { + console.log('processAnswer'); + + switch (params.answer) { + case 'ack': + return processACK(call, channel); + case 'accept': + return processAccept(call, channel); + case 'unavailable': + break; + case 'reject': + break; + } +} diff --git a/packages/media-signaling-server/src/signals/processHangup.ts b/packages/media-signaling-server/src/signals/processHangup.ts new file mode 100644 index 0000000000000..732c8f3c1cb2f --- /dev/null +++ b/packages/media-signaling-server/src/signals/processHangup.ts @@ -0,0 +1,21 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalHangup } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { processEndedCall } from '../calls/processEndedCall'; + +export async function processHangup(params: MediaSignalHangup, call: IMediaCall, channel: ValidSignalChannel): Promise { + console.log('processHangup'); + + const actor = call[channel.role]; + + const endedBy = { ...actor, ...(actor.type === 'user' && !actor.sessionId && { sessionId: channel.participant.sessionId }) }; + const { reason } = params; + + const stateResult = await MediaCalls.hangupCallById(call._id, { endedBy, reason }); + if (!stateResult.modifiedCount) { + return; + } + + return processEndedCall(call); +} diff --git a/packages/media-signaling-server/src/signals/processSDP.ts b/packages/media-signaling-server/src/signals/processSDP.ts new file mode 100644 index 0000000000000..3d5936f5a69b5 --- /dev/null +++ b/packages/media-signaling-server/src/signals/processSDP.ts @@ -0,0 +1,20 @@ +import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; +import type { MediaSignalSDP } from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import { deliverChannelSDP } from '../channels/deliverChannelSDP'; +import { getOppositeChannel } from '../channels/getOppositeChannel'; + +export async function processSDP(params: MediaSignalSDP, call: IMediaCall, channel: ValidSignalChannel): Promise { + console.log('processSDP'); + + // Save the SDP for the local session of the channel + await MediaCallChannels.setLocalDescription(channel._id, params.sdp); + + // Find the opposite channel and send them the SDP as remote + const otherChannel = await getOppositeChannel(call, channel); + // otherChannel will only be defined if the other participant has already accepted the call + if (otherChannel) { + await deliverChannelSDP(otherChannel, params); + } +} diff --git a/packages/media-signaling-server/src/signals/processSignal.ts b/packages/media-signaling-server/src/signals/processSignal.ts index dab577e67cd78..58ab3b00e3bc6 100644 --- a/packages/media-signaling-server/src/signals/processSignal.ts +++ b/packages/media-signaling-server/src/signals/processSignal.ts @@ -1,37 +1,67 @@ import type { IUser } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { MediaSignal, MediaSignalAnswer, MediaSignalHangup, MediaSignalSDP } from '@rocket.chat/media-signaling'; import { MediaCalls, Users } from '@rocket.chat/models'; -import { processDeliver } from './deliver/processDeliver'; import { getChannelForSignal } from './getChannelForSignal'; -import { processNotify } from './notify/processNotify'; +import { processAnswer } from './processAnswer'; +import { processHangup } from './processHangup'; +import { processSDP } from './processSDP'; +import { getRoleForActor } from '../channels/getRoleForActor'; import { assertSignalHasSessionId } from '../utils/assertSignalHasSessionId'; +import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; export async function processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { assertSignalHasSessionId(signal); - console.log('server.processSignal', signal); + console.log('server.processSignal', signal, uid); - const call = await MediaCalls.findOneById(signal.callId); - if (!call) { - throw new Error('invalid-call'); - } + try { + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + throw new Error('invalid-call'); + } - const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); - if (!user?.username || !user?.name) { - throw new Error('invalid-user'); - } + const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); + if (!user?.username || !user?.name) { + throw new Error('invalid-user'); + } + + const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; + const role = getRoleForActor(call, actor); + if (!role) { + throw new Error('invalid-role'); + } + + const channel = await getChannelForSignal({ + callId: call._id, + participant: { + ...actor, + username: user.username, + displayName: user.name, + }, + role, + }); + + // This shouldn't be possible unless something tried to switch the roles of the call's actors + if (channel.role !== role || !compareParticipantAndActor(channel.participant, call[channel.role])) { + throw new Error('invalid-channel-data'); + } - const channel = await getChannelForSignal(signal, call, user); - - switch (signal.type) { - // case 'request': - // await processRequest(signal); - // break; - case 'deliver': - await processDeliver(signal, call, channel); - break; - case 'notify': - await processNotify(signal, call, channel); - break; + switch (signal.type) { + case 'sdp': + await processSDP(signal.body as MediaSignalSDP, call, channel); + break; + case 'error': + // #ToDo + break; + case 'answer': + await processAnswer(signal.body as MediaSignalAnswer, call, channel); + break; + case 'hangup': + await processHangup(signal.body as MediaSignalHangup, call, channel); + break; + } + } catch (e) { + console.log(e); + throw e; } } diff --git a/packages/media-signaling-server/src/signals/sendNewSignals.ts b/packages/media-signaling-server/src/signals/sendNewSignals.ts index e9bc0a8c8e4bc..bdc3bd634ac1f 100644 --- a/packages/media-signaling-server/src/signals/sendNewSignals.ts +++ b/packages/media-signaling-server/src/signals/sendNewSignals.ts @@ -1,4 +1,5 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { MediaSignalNewCall } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { sendSignalToActor } from './sendSignalToActor'; @@ -12,27 +13,30 @@ export async function sendNewSignals(callId: string): Promise { throw new Error('failed-to-create-call'); } - const signalData = { + const signalBody: Omit = { + service: call.service, + kind: call.kind, + }; + + const header = { callId: call._id, - sequence: call.sequence, - type: 'notify', - body: { - notify: 'new', - service: call.service, - kind: call.kind, - }, + type: 'new', } as const; - await Promise.allSettled([ - sendSignalToActor(call.caller, { - ...signalData, - role: 'caller', - }), - sendSignalToActor(call.callee, { - ...signalData, - role: 'callee', + const roles = ['caller', 'callee'] as const; + + await Promise.all( + roles.map(async (role) => { + const actor = call[role]; + await sendSignalToActor(actor, { + ...header, + body: { + ...signalBody, + role, + }, + }); }), - ]); + ); return call; } diff --git a/packages/media-signaling-server/src/signals/sendSignalToActor.ts b/packages/media-signaling-server/src/signals/sendSignalToActor.ts index e57835410b86e..9656a61ec0a06 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToActor.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToActor.ts @@ -1,30 +1,20 @@ import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { MediaSignal, MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; +import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; -import { buildSignal } from './buildSignal'; import { sendSignal } from './signalHandler'; import { logger } from '../utils/logger'; export async function sendSignalToActor( actor: MediaCallActor, - signal: { callId: IMediaCall['_id']; role: MediaSignal['role']; sequence: number; type: T; body: MediaSignalBody }, + signal: { callId: IMediaCall['_id'] } & MediaSignalBodyAndType, ): Promise { if (actor.type !== 'user') { return; } - const fullSignal = buildSignal( - { - role: signal.role, - ...('sessionId' in actor && { sessionId: actor.sessionId }), - version: 1, - sequence: signal.sequence, - callId: signal.callId, - }, - signal.type, - signal.body, - ); - logger.debug({ msg: 'sending signal to actor', actor, signal }); - await sendSignal(actor.id, fullSignal); + await sendSignal(actor.id, { + ...('sessionId' in actor && { sessionId: actor.sessionId }), + ...signal, + }); } diff --git a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts index e2a1964f4e9c8..e49c9e50a18b2 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts @@ -1,21 +1,17 @@ import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; +import type { CallRole, MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; import { sendSignalToActor } from './sendSignalToActor'; type SendSignalToAllActorsOptions = { // default false onlyDefinedSessions?: boolean; - // default true for 'deliver' and 'request', or false for 'notify' + // default false targetedSignal?: boolean; }; -function getActorData( - call: IMediaCall, - role: 'caller' | 'callee', - options: SendSignalToAllActorsOptions, -): { actor: MediaCallActor; role: 'caller' | 'callee' } | null { - const { onlyDefinedSessions = false, targetedSignal = true } = options || {}; +function getActorData(call: IMediaCall, role: CallRole, options: SendSignalToAllActorsOptions): MediaCallActor | null { + const { onlyDefinedSessions = false, targetedSignal = false } = options || {}; const actor = call[role]; if (actor?.type !== 'user') { @@ -28,35 +24,33 @@ function getActorData( if (!targetedSignal) { const { sessionId, ...actorData } = actor; - return { actor: actorData, role }; + return actorData; } - return { actor, role }; + return actor; } -export async function sendSignalToAllActors( - call: IMediaCall, - { sequence, type, body }: { sequence: number; type: T; body: MediaSignalBody }, - options: SendSignalToAllActorsOptions = {}, -): Promise { +export async function getAllActors(call: IMediaCall, options: SendSignalToAllActorsOptions = {}): Promise { const subOptions: SendSignalToAllActorsOptions = { onlyDefinedSessions: options.onlyDefinedSessions ?? false, - targetedSignal: options.targetedSignal ?? ['deliver', 'request'].includes(type), + targetedSignal: options.targetedSignal ?? false, }; - const actors = [getActorData(call, 'caller', subOptions), getActorData(call, 'callee', subOptions)].filter((data) => data) as { - actor: MediaCallActor; - role: 'caller' | 'callee'; - }[]; + return [getActorData(call, 'caller', subOptions), getActorData(call, 'callee', subOptions)].filter((data) => data) as MediaCallActor[]; +} + +export async function sendSignalToAllActors( + call: IMediaCall, + signal: MediaSignalBodyAndType, + options: SendSignalToAllActorsOptions = {}, +): Promise { + const actors = await getAllActors(call, options); await Promise.all( - actors.map(async ({ actor, role }) => + actors.map(async (actor) => sendSignalToActor(actor, { callId: call._id, - role, - sequence, - type, - body, + ...signal, }), ), ); diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts index fb2b44905eda1..218be29e1c38c 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts @@ -1,32 +1,23 @@ import type { ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; +import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; -import { buildSignal } from './buildSignal'; import { isValidSignalRole } from './isValidSignalRole'; import { sendSignal } from './signalHandler'; import { logger } from '../utils/logger'; export async function sendSignalToChannel( channel: ValidSignalChannel, - signal: { sequence: number; type: T; body: MediaSignalBody }, + signal: MediaSignalBodyAndType, ): Promise { if (!isValidSignalRole(channel.role)) { logger.info({ msg: 'tried to send a signal to a channel with invalid role', channel, signal }); return; } - const fullSignal = buildSignal( - { - role: channel.role, - sessionId: channel.participant.sessionId, - version: 1, - sequence: signal.sequence, - callId: channel.callId, - }, - signal.type, - signal.body, - ); - logger.debug({ msg: 'sending signal to channel', channel, signal }); - await sendSignal(channel.participant.id, fullSignal); + await sendSignal(channel.participant.id, { + callId: channel.callId, + sessionId: channel.participant.sessionId, + ...signal, + }); } diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannels.ts b/packages/media-signaling-server/src/signals/sendSignalToChannels.ts index b49c793cb7b5c..1beb2cb452723 100644 --- a/packages/media-signaling-server/src/signals/sendSignalToChannels.ts +++ b/packages/media-signaling-server/src/signals/sendSignalToChannels.ts @@ -1,11 +1,11 @@ import type { ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalBody, MediaSignalType } from '@rocket.chat/media-signaling'; +import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; import { sendSignalToChannel } from './sendSignalToChannel'; export async function sendSignalToChannels( channel: ValidSignalChannel[], - signal: { sequence: number; type: T; body: MediaSignalBody }, + signal: MediaSignalBodyAndType, ): Promise { await Promise.all(channel.map((channel) => sendSignalToChannel(channel, signal))); } diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index ac578695bd279..ab067b389c1b5 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -1,16 +1,10 @@ +import type { MediaSignalRequestOffer, MediaSignalSDP } from './MediaSignal'; import type { MediaStreamFactory } from './MediaStreamFactory'; -import type { DeliverParams } from './signal/MediaSignalDeliver'; -import type { RequestParams } from './signal/MediaSignalRequest'; export interface IWebRTCProcessor { - // Function used to create a webrtc offer using the pre-initialized peer connection - createOffer(params: RequestParams<'offer'>): Promise>; - // Function used to create a webrtc answer using the pre-initialized peer connection - createAnswer(params: RequestParams<'answer'>): Promise>; - // Function used to retrieve the local sdp from the peer connection - collectLocalDescription(params: RequestParams<'sdp'>): Promise>; - setRemoteDescription(params: DeliverParams<'sdp'>): Promise; - addIceCandidates(params: DeliverParams<'ice-candidates'>): Promise; + createOffer(params: MediaSignalRequestOffer): Promise; + createAnswer(params: MediaSignalSDP): Promise; + setRemoteDescription(params: MediaSignalSDP): Promise; getRemoteMediaStream(): MediaStream; } diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/MediaSignal.ts new file mode 100644 index 0000000000000..11eab9097e815 --- /dev/null +++ b/packages/media-signaling/src/definition/MediaSignal.ts @@ -0,0 +1,69 @@ +import type { CallHangupReason, CallRole, CallService } from './call'; + +export type MediaSignalHeader = { + callId: string; + sessionId?: string; +}; + +// Sent by the server to notify an agent that there's a new call for their actor +export type MediaSignalNewCall = { + service: CallService; + kind: 'direct'; + role: CallRole; +}; + +// Sent by both the agent and the server to deliver an SDP to the other +// the SDP might be an offer or an answer. If it's an offer, the sender will be waiting for an answer from the receiver +export type MediaSignalSDP = { + sdp: RTCSessionDescriptionInit; + // endOfCandidates: boolean; +}; + +// Sent by the server to request a WebRTC offer from an agent +export type MediaSignalRequestOffer = { + iceRestart?: boolean; +}; + +// Sent by an agent to notify the server that something failed +export type MediaSignalError = { + errorCode: string; +}; + +// Sent by an agent to notify the server that the user accepted or rejected a call, or simply to report a status before for that user +export type MediaSignalAnswer = { + answer: + | 'accept' // the user has accepted the call + | 'reject' // the user has rejected the call + | 'ack' // pre-answer notifying that the agent can reach the user, a new answer will be sent later + | 'unavailable'; // the user is busy, or the agent can't reach them +}; + +// Sent by an agent to notify the server that it had to hangup from a call. The reason specifies if its a clean hangup or an error +export type MediaSignalHangup = { + reason: CallHangupReason; +}; + +// Simple notifications sent by the server to the agents +export type MediaSignalNotification = { + notification: + | 'accepted' // notify that the call has been accepted by both actors + | 'hangup'; // notify that the call is over; +}; + +export type MediaSignalMap = { + 'new': MediaSignalNewCall; + 'sdp': MediaSignalSDP; + 'request-offer': MediaSignalRequestOffer; + 'error': MediaSignalError; + 'answer': MediaSignalAnswer; + 'hangup': MediaSignalHangup; + 'notification': MediaSignalNotification; +}; + +export type MediaSignalType = keyof MediaSignalMap; + +export type MediaSignalBody = MediaSignalMap[T]; + +export type MediaSignalBodyAndType = { type: T; body: MediaSignalBody }; + +export type MediaSignal = MediaSignalHeader & MediaSignalBodyAndType; diff --git a/packages/media-signaling/src/definition/MediaSignalTransport.ts b/packages/media-signaling/src/definition/MediaSignalTransport.ts index 5485ea82c939f..42010ded144ef 100644 --- a/packages/media-signaling/src/definition/MediaSignalTransport.ts +++ b/packages/media-signaling/src/definition/MediaSignalTransport.ts @@ -1,3 +1,3 @@ -import type { MediaSignal } from './signal'; +import type { MediaSignal } from './MediaSignal'; export type MediaSignalTransport = (signal: MediaSignal) => void; diff --git a/packages/media-signaling/src/definition/call/CallContact.ts b/packages/media-signaling/src/definition/call/CallContact.ts deleted file mode 100644 index 6f447233f07fd..0000000000000 --- a/packages/media-signaling/src/definition/call/CallContact.ts +++ /dev/null @@ -1 +0,0 @@ -export type CallContact = Record | null; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index c907f1ccd97b2..4fbfe961180a6 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -1,4 +1,4 @@ -import type { CallState } from './CallState'; +import type { CallState } from './IClientMediaCall'; export type CallEvents = { // stateChange receives the old state as param diff --git a/packages/media-signaling/src/definition/call/CallRole.ts b/packages/media-signaling/src/definition/call/CallRole.ts deleted file mode 100644 index 9fd905ea8e64f..0000000000000 --- a/packages/media-signaling/src/definition/call/CallRole.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type CallRole = 'caller' | 'callee'; - -export function isCallRole(role: string): role is CallRole { - return ['caller', 'callee'].includes(role); -} diff --git a/packages/media-signaling/src/definition/call/CallService.ts b/packages/media-signaling/src/definition/call/CallService.ts deleted file mode 100644 index c2a3c1c6e2183..0000000000000 --- a/packages/media-signaling/src/definition/call/CallService.ts +++ /dev/null @@ -1 +0,0 @@ -export type CallService = 'webrtc'; diff --git a/packages/media-signaling/src/definition/call/CallState.ts b/packages/media-signaling/src/definition/call/CallState.ts deleted file mode 100644 index ae0d2eae8ae28..0000000000000 --- a/packages/media-signaling/src/definition/call/CallState.ts +++ /dev/null @@ -1 +0,0 @@ -export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup' | 'error'; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index a8df7ba45e278..d4f497bdea02a 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -1,10 +1,23 @@ import type { Emitter } from '@rocket.chat/emitter'; -import type { CallContact } from './CallContact'; import type { CallEvents } from './CallEvents'; -import type { CallRole } from './CallRole'; -import type { CallService } from './CallService'; -import type { CallState } from './CallState'; + +export type CallContact = Record | null; + +export type CallRole = 'caller' | 'callee'; + +export type CallService = 'webrtc'; + +export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup' | 'error'; + +export type CallHangupReason = + | 'normal' // User explicitly hanged up + | 'remote' // Server told the client to hang up + | 'unavailable' // The opposite actor is not available + | 'signaling-error' // Hanging up because of an error during the signal processing + | 'service-error' // Hanging up because of an error setting up the service connection + | 'media-error' // Hanging up because of an error setting up the media connection + | 'error'; // Hanging up because of an unidentified error export interface IClientMediaCallData { callId: string; @@ -26,3 +39,7 @@ export interface IClientMediaCall extends Required { reject(): Promise; hangup(): Promise; } + +export function isCallRole(role: string): role is CallRole { + return ['caller', 'callee'].includes(role); +} diff --git a/packages/media-signaling/src/definition/call/index.ts b/packages/media-signaling/src/definition/call/index.ts index 97c80232026b7..1d67057e2d208 100644 --- a/packages/media-signaling/src/definition/call/index.ts +++ b/packages/media-signaling/src/definition/call/index.ts @@ -1,6 +1,2 @@ -export * from './CallContact'; -export * from './CallRole'; -export * from './CallState'; export * from './CallEvents'; -export * from './CallService'; export * from './IClientMediaCall'; diff --git a/packages/media-signaling/src/definition/index.ts b/packages/media-signaling/src/definition/index.ts index fa07addbb63e5..1c2932aa317b4 100644 --- a/packages/media-signaling/src/definition/index.ts +++ b/packages/media-signaling/src/definition/index.ts @@ -1,5 +1,5 @@ export * from './IWebRTCProcessor'; export * from './IServiceProcessorFactoryList'; export * from './MediaSignalTransport'; +export * from './MediaSignal'; export * from './call'; -export * from './signal'; diff --git a/packages/media-signaling/src/definition/signal/MediaSignal.ts b/packages/media-signaling/src/definition/signal/MediaSignal.ts deleted file mode 100644 index 4bb4bb6a6b3cb..0000000000000 --- a/packages/media-signaling/src/definition/signal/MediaSignal.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { MediaSignalDeliver } from './MediaSignalDeliver'; -import type { MediaSignalNotify } from './MediaSignalNotify'; -import type { MediaSignalRequest } from './MediaSignalRequest'; - -export type MediaSignalMap = { - request: MediaSignalRequest; - deliver: MediaSignalDeliver; - notify: MediaSignalNotify; -}; - -export type MediaSignalType = keyof MediaSignalMap; - -export type MediaSignal = MediaSignalMap[T]; - -export type ClientMediaSignal = Omit; - -export type MediaSignalBody = MediaSignal['body']; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts b/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts deleted file mode 100644 index 0974b720f4e8f..0000000000000 --- a/packages/media-signaling/src/definition/signal/MediaSignalDeliver.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type MediaSignalHeader } from './MediaSignalHeader'; - -export type DeliverSdpBody = { - deliver: 'sdp'; - sdp: RTCSessionDescriptionInit; - endOfCandidates: boolean; -}; - -export type DeliverIceCandidatesBody = { - deliver: 'ice-candidates'; - candidates: RTCIceCandidateInit[]; - endOfCandidates: boolean; -}; - -export type DeliverDTMFBody = { - deliver: 'dtmf'; - tone: string; - duration?: number; -}; - -export type DeliverBodyMap = { - 'sdp': DeliverSdpBody; - 'ice-candidates': DeliverIceCandidatesBody; - 'dtmf': DeliverDTMFBody; -}; - -export type DeliverType = keyof DeliverBodyMap; - -export type DeliverBody = DeliverBodyMap[T]; - -export type MediaSignalDeliver = MediaSignalHeader & { - type: 'deliver'; - body: DeliverBody; -}; - -export type DeliverParams = Omit, 'deliver'>; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts b/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts deleted file mode 100644 index 64fcee6d890eb..0000000000000 --- a/packages/media-signaling/src/definition/signal/MediaSignalHeader.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type MediaSignalHeaderParams = { - callId: string; - sessionId?: string; - version: number; - sequence: number; - - role: 'caller' | 'callee'; - - expectACK?: boolean; -}; - -export type MediaSignalHeader = MediaSignalHeaderParams & { - type: 'request' | 'deliver' | 'notify'; - body: Record; -}; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts b/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts deleted file mode 100644 index 4820cecb6ff87..0000000000000 --- a/packages/media-signaling/src/definition/signal/MediaSignalNotify.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type MediaSignalHeader } from './MediaSignalHeader'; - -export type NotifyAckBody = { - notify: 'ack'; -}; - -// export type NotifyInvalidBody = { -// notify: 'invalid'; -// }; - -// export type NotifyUnableBody = { -// notify: 'unable'; -// }; - -// export type NotifyEmptyBody = { -// notify: 'empty'; -// }; - -export type NotifyErrorBody = { - notify: 'error'; - errorCode: string; - errorText?: string; -}; - -export type NotifyNewBody = { - notify: 'new'; - service: 'webrtc'; - kind: 'direct'; -}; - -export type NotifyStateBody = { - notify: 'state'; - callState?: string; - serviceState?: string; - mediaState?: string; -}; - -export type NotifyUnavailableBody = { - notify: 'unavailable'; -}; - -export type NotifyAcceptBody = { - notify: 'accept'; -}; - -export type NotifyRejectBody = { - notify: 'reject'; -}; - -export type NotifyHangupBody = { - notify: 'hangup'; - reasonCode: string; - reasonText?: string; -}; - -export type NotifyNegotiationNeededBody = { - notify: 'negotiation-needed'; - reason?: string; -}; - -export type NotifyBodyMap = { - 'new': NotifyNewBody; - 'ack': NotifyAckBody; - // 'invalid': NotifyInvalidBody; - // 'unable': NotifyUnableBody; - // 'empty': NotifyEmptyBody; - 'error': NotifyErrorBody; - 'accept': NotifyAcceptBody; - 'reject': NotifyRejectBody; - 'hangup': NotifyHangupBody; - 'negotiation-needed': NotifyNegotiationNeededBody; - 'unavailable': NotifyUnavailableBody; - 'state': NotifyStateBody; -}; - -export type NotifyType = keyof NotifyBodyMap; - -export type NotifyBody = NotifyBodyMap[T]; - -export type MediaSignalNotify = MediaSignalHeader & { - type: 'notify'; - body: NotifyBody; -}; - -export type NotifyParams = Omit, 'notify'>; diff --git a/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts b/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts deleted file mode 100644 index aa9865520f8ce..0000000000000 --- a/packages/media-signaling/src/definition/signal/MediaSignalRequest.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type MediaSignalHeader } from './MediaSignalHeader'; - -export type RequestOfferBody = { - request: 'offer'; - iceRestart?: boolean; -}; - -export type RequestAnswerBody = { - request: 'answer'; - offer: RTCSessionDescriptionInit; -}; - -export type RequestSdpBody = { - request: 'sdp'; -}; - -export type RequestBodyMap = { - offer: RequestOfferBody; - answer: RequestAnswerBody; - sdp: RequestSdpBody; -}; - -export type RequestBody = RequestBodyMap[T]; - -export type MediaSignalRequest = MediaSignalHeader & { - type: 'request'; - body: RequestBody; -}; - -export type RequestParams = Omit, 'request'>; diff --git a/packages/media-signaling/src/definition/signal/index.ts b/packages/media-signaling/src/definition/signal/index.ts deleted file mode 100644 index 0246c84c75faf..0000000000000 --- a/packages/media-signaling/src/definition/signal/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './MediaSignal'; -export * from './MediaSignalDeliver'; -export * from './MediaSignalHeader'; -export * from './MediaSignalNotify'; -export * from './MediaSignalRequest'; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index e880821daed72..1723f442bfbed 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -2,8 +2,18 @@ import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalTransportWrapper } from './TransportWrapper'; import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; -import type { IClientMediaCall, IClientMediaCallData, CallEvents, CallContact, CallRole, CallState, CallService } from '../definition/call'; -import type { MediaSignalRequest, MediaSignal, DeliverParams, MediaSignalDeliver, MediaSignalNotify } from '../definition/signal'; +import type { MediaSignal, MediaSignalSDP, MediaSignalType } from '../definition/MediaSignal'; +import type { + IClientMediaCall, + IClientMediaCallData, + CallEvents, + CallContact, + CallRole, + CallState, + CallService, + CallHangupReason, +} from '../definition/call'; +import { signalTypeRequiresTargeting } from './utils/signalTypeRequiresTargeting'; export interface IClientMediaCallConfig { transporter: MediaSignalTransportWrapper; @@ -49,9 +59,7 @@ export class ClientMediaCall implements IClientMediaCall { private webrtcProcessor: IWebRTCProcessor; - private tricklingSignals = new Set(); - - private firstSignal: MediaSignalNotify<'new'>; + private firstSignal: MediaSignal<'new'>; private acceptedLocally: boolean; @@ -61,7 +69,7 @@ export class ClientMediaCall implements IClientMediaCall { constructor( config: IClientMediaCallConfig, - signal: MediaSignalNotify<'new'>, + signal: MediaSignal<'new'>, { ignored, contact }: Pick = {}, ) { this.emitter = new Emitter(); @@ -72,7 +80,7 @@ export class ClientMediaCall implements IClientMediaCall { this.webrtcProcessor = config.webrtcProcessor; this.callId = signal.callId; - this._role = signal.role; + this._role = signal.body.role; this._service = signal.body.service; this.acceptedLocally = false; @@ -100,19 +108,24 @@ export class ClientMediaCall implements IClientMediaCall { this.emitter.emit('contactUpdate'); } - public async processSignal(signal: MediaSignal) { + public async processSignal(signal: MediaSignal) { console.log('ClientMediaCall.processSignal', signal.type); + + if (!signal.sessionId && signalTypeRequiresTargeting(signal.type)) { + console.error(`Received an untargeted ${signal.type} signal.`); + return; + } + switch (signal.type) { - case 'request': - await this.processRequest(signal); - break; - case 'deliver': - await this.processDeliver(signal); - break; - case 'notify': - await this.processNotify(signal); - break; + case 'sdp': + return this.processRemoteSDP(signal as MediaSignal<'sdp'>); + case 'request-offer': + return this.processOfferRequest(signal as MediaSignal<'request-offer'>); + case 'notification': + return this.processNotification(signal as MediaSignal<'notification'>); } + + console.log('signal ignored, as its type is not handled by this agent', signal.type); } public async accept(): Promise { @@ -121,7 +134,7 @@ export class ClientMediaCall implements IClientMediaCall { throw new Error('call-not-pending-acceptance'); } this.acceptedLocally = true; - this.transporter.notifyServer(this.firstSignal, 'accept'); + this.transporter.answer(this.callId, 'accept'); } public async reject(): Promise { @@ -129,7 +142,7 @@ export class ClientMediaCall implements IClientMediaCall { if (!this.isPendingAcceptance()) { throw new Error('call-not-pending-acceptance'); } - this.transporter.notifyServer(this.firstSignal, 'reject'); + this.transporter.answer(this.callId, 'reject'); } public async hangup(): Promise { @@ -139,18 +152,18 @@ export class ClientMediaCall implements IClientMediaCall { } this.endedLocally = true; - await this.flagAsEnded(this.firstSignal, 'normal'); + await this.flagAsEnded('normal'); } public isPendingAcceptance(): boolean { return ['none', 'ringing'].includes(this._state); } - private initialize(signal: MediaSignalNotify<'new'>): void { + private initialize(signal: MediaSignal<'new'>): void { console.log('call.initialize'); // If it's flagged as ignored even before the initialization, tell the server we're unavailable if (this.ignored) { - return this.transporter.notifyServer(signal, 'unavailable'); + return this.transporter.answer(signal.callId, 'unavailable'); } // If the call is targeted, assume we already accepted it: @@ -160,7 +173,7 @@ export class ClientMediaCall implements IClientMediaCall { this._state = 'ringing'; // Send an ACK so the server knows that this session exists and is reachable - this.transporter.notifyServer(signal, 'ack'); + this.transporter.answer(signal.callId, 'ack'); } private changeState(newState: CallState): void { @@ -184,143 +197,78 @@ export class ClientMediaCall implements IClientMediaCall { } } - private async processRequest(signal: MediaSignalRequest) { - console.log('call.processRequest', signal.body.request); + private async processOfferRequest(signal: MediaSignal<'request-offer'>) { + console.log('call.processOfferRequest'); if (!signal.sessionId) { - console.error('Received an untargeted request.'); + console.error('Received an untargeted offer request.'); return; } - switch (signal.body.request) { - case 'offer': - return this.processOfferRequest(signal as MediaSignalRequest<'offer'>); - case 'answer': - return this.processAnswerRequest(signal as MediaSignalRequest<'answer'>); - case 'sdp': - return this.processSdpRequest(signal as MediaSignalRequest<'sdp'>); - } - } - - private async processOfferRequest(signal: MediaSignalRequest<'offer'>) { - console.log('call.processOfferRequest'); - let offer: DeliverParams<'sdp'> | null = null; + let offer: MediaSignalSDP | null = null; try { offer = await this.webrtcProcessor.createOffer(signal.body); } catch (e) { - this.transporter.sendError(signal, 'failed-to-create-offer'); + this.transporter.sendError(this.callId, 'failed-to-create-offer'); throw e; } if (!offer) { - this.transporter.sendError(signal, 'implementation-error'); + this.transporter.sendError(this.callId, 'implementation-error'); } - await this.deliverSdp(signal, offer); + await this.deliverSdp(offer); } - private async processAnswerRequest(signal: MediaSignalRequest<'answer'>) { + private async processAnswerRequest(signal: MediaSignal<'sdp'>) { console.log('Call.processAnswerRequest'); - let answer: DeliverParams<'sdp'> | null = null; + let answer: MediaSignalSDP | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal.body); } catch (e) { - this.transporter.sendError(signal, 'failed-to-create-answer'); + this.transporter.sendError(this.callId, 'failed-to-create-answer'); throw e; } if (!answer) { - this.transporter.sendError(signal, 'implementation-error'); + this.transporter.sendError(this.callId, 'implementation-error'); } - await this.deliverSdp(signal, answer); + await this.deliverSdp(answer); } - private async processSdpRequest(signal: MediaSignalRequest<'sdp'>) { - console.log('call.processSdpRequest'); - let sdp: DeliverParams<'sdp'> | null = null; - try { - sdp = await this.webrtcProcessor.collectLocalDescription(signal.body); - } catch (e) { - this.transporter.sendError(signal, 'failed-to-collect-description'); - throw e; + private async processRemoteSDP(signal: MediaSignal<'sdp'>) { + console.log('Call.processRemoteSDP'); + if (signal.body.sdp.type === 'offer') { + return this.processAnswerRequest(signal); } - if (!sdp) { - this.transporter.sendError(signal, 'implementation-error'); - } - - await this.deliverSdp(signal, sdp); + await this.webrtcProcessor.setRemoteDescription(signal.body); } - private async deliverSdp(requestSignal: MediaSignal, sdp: DeliverParams<'sdp'>) { + private async deliverSdp(sdp: MediaSignalSDP) { console.log('Call.deliverSdp'); - // If we're trickling ICE, keep the signal reference for upcoming candidates - if (!sdp.endOfCandidates) { - this.tricklingSignals.add(requestSignal); - } - return this.transporter.deliverToServer(requestSignal, 'sdp', sdp); + return this.transporter.sendToServer(this.callId, 'sdp', sdp); } - private async processDeliver(signal: MediaSignalDeliver) { - console.log('Call.processDeliver', signal.body.deliver); + private async processNotification(signal: MediaSignal<'notification'>) { + console.log('Call.processNotification', signal.body.notification); - if (!signal.sessionId) { - throw new Error('Delivery signals MUST target a specific session.'); - } - - switch (signal.body.deliver) { - case 'sdp': - await this.webrtcProcessor.setRemoteDescription(signal.body); - break; - case 'ice-candidates': - await this.webrtcProcessor.addIceCandidates(signal.body); - break; - case 'dtmf': - // #ToDo - break; - } - } - - private async processNotify(signal: MediaSignalNotify) { - console.log('Call.processNotify', signal.body.notify); - - switch (signal.body.notify) { - case 'new': - // New notifications should have alreayd been processed by the constructor - break; - case 'error': - break; - case 'ack': - break; - // case 'invalid': - // break; - // case 'unable': - // break; - // case 'empty': - // break; - case 'state': - break; - case 'unavailable': - break; - case 'accept': - await this.flagAsAccepted(signal); - break; - case 'reject': - break; + switch (signal.body.notification) { + case 'accepted': + return this.flagAsAccepted(); case 'hangup': - await this.flagAsEnded(signal, 'remote'); - break; - case 'negotiation-needed': - break; + return this.flagAsEnded('remote'); } + + console.log('notification ignored as its type is not handled by this agent', signal.body.notification); } - private async flagAsAccepted(signal: MediaSignalNotify): Promise { + private async flagAsAccepted(): Promise { console.log('flagAsAccepted'); if (!this.acceptedLocally) { - this.transporter.sendError(signal, 'not-accepted'); + this.transporter.sendError(this.callId, 'not-accepted'); throw new Error('Trying to activate a call that was not yet accepted locally.'); } @@ -328,16 +276,14 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('accepted'); } - private async flagAsEnded(signal: MediaSignal, reasonCode: string): Promise { + private async flagAsEnded(reasonCode: CallHangupReason): Promise { console.log('flagAsEnded'); if (this._state === 'hangup') { return; } - this.transporter.notifyServer(signal, 'hangup', { - reasonCode, - }); + this.transporter.hangup(this.callId, reasonCode); this.changeState('hangup'); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 748a4d7856c9b..6df280fdbb7a7 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -2,15 +2,13 @@ import { Emitter } from '@rocket.chat/emitter'; import { ClientMediaCall } from './Call'; import { MediaSignalTransportWrapper } from './TransportWrapper'; -import type { IWebRTCProcessor, MediaSignalNotify } from '../definition'; +import type { IWebRTCProcessor } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; -import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; -import type { IClientMediaCall, CallContact, CallState } from '../definition/call'; -import { isNotifyNew } from './utils/isNotifyNew'; +import type { MediaSignal } from '../definition/MediaSignal'; import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; -import { isCallRole } from '../definition/call/CallRole'; -import type { MediaSignal } from '../definition/signal/MediaSignal'; +import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; +import { type IClientMediaCall, type CallContact, type CallState, isCallRole } from '../definition/call'; const stateWeights: Record = { none: 0, @@ -130,8 +128,8 @@ export class MediaSignalingSession extends Emitter { return; } - if (isNotifyNew(signal)) { - await this.processNewCall(signal); + if (signal.type === 'new') { + await this.processNewCall(signal as MediaSignal<'new'>); return; } @@ -190,7 +188,7 @@ export class MediaSignalingSession extends Emitter { return this.ignoredCalls.has(callId); } - private async processNewCall(signal: MediaSignalNotify<'new'>) { + private async processNewCall(signal: MediaSignal<'new'>) { console.log('session.processNewCall'); // If we already know about this call, we don't need to process anything if (this.isCallKnown(signal.callId)) { @@ -198,7 +196,7 @@ export class MediaSignalingSession extends Emitter { } try { - if (!isCallRole(signal.role)) { + if (!isCallRole(signal.body.role)) { throw new Error('invalid-role'); } const webrtcProcessor = await this.createWebRtcProcessor(); @@ -224,7 +222,7 @@ export class MediaSignalingSession extends Emitter { this.failedCalls.add(signal.callId); const errorCode: string = (e && typeof e === 'object' && (e as any).name) || 'call-initialization-failed'; - this.transporter.sendError(signal, errorCode); + this.transporter.sendError(signal.callId, errorCode); throw e; } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index 2a6531edb53aa..9ee034f385767 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -1,65 +1,51 @@ +import type { + MediaSignal, + MediaSignalAnswer, + MediaSignalBody, + MediaSignalBodyAndType, + MediaSignalHangup, + MediaSignalNotification, + MediaSignalType, +} from '../definition/MediaSignal'; import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; -import type { ClientMediaSignal, MediaSignal } from '../definition/signal/MediaSignal'; -import type { DeliverBody, DeliverParams, DeliverType } from '../definition/signal/MediaSignalDeliver'; -import type { MediaSignalHeaderParams } from '../definition/signal/MediaSignalHeader'; -import type { NotifyBody, NotifyParams, NotifyType } from '../definition/signal/MediaSignalNotify'; export class MediaSignalTransportWrapper { - private readonly version = 1.0; - constructor( public readonly sessionId: string, private sendSignalFn: MediaSignalTransport, ) {} - public deliverToServer(requestSignal: MediaSignal, type: T, params: DeliverParams) { - return this.sendSignalToServer( - { - type: 'deliver', - body: { - deliver: type, - ...params, - } as DeliverBody, - }, - requestSignal, - ); - } - - public notifyServer(requestSignal: MediaSignal, type: T, params?: NotifyParams) { - return this.sendSignalToServer( - { - type: 'notify', - body: { - notify: type, - ...params, - } as NotifyBody, - }, - requestSignal, - ); + public sendToServer(callId: string, type: T, body: MediaSignalBody) { + return this.sendSignalToServer(callId, { + type, + body, + }); } - public sendError(requestSignal: MediaSignal, errorCode: string, errorText?: string) { - this.notifyServer(requestSignal, 'error', { + public sendError(callId: string, errorCode: string) { + this.sendToServer(callId, 'error', { errorCode, - ...(errorText && { errorText }), }); } - public sendSignalToServer(signal: ClientMediaSignal, requestSignal: MediaSignal) { - const header: MediaSignalHeaderParams = { - callId: requestSignal.callId, - sequence: requestSignal.sequence, - role: requestSignal.role, - version: this.version, - sessionId: this.sessionId, - }; + public answer(callId: string, answer: MediaSignalAnswer['answer']) { + return this.sendToServer(callId, 'answer', { answer }); + } - const newSignal = { - ...signal, - ...header, - } as T; + public hangup(callId: string, reason: MediaSignalHangup['reason']) { + return this.sendToServer(callId, 'hangup', { reason }); + } + + public notify(callId: string, notification: MediaSignalNotification['notification']) { + return this.sendToServer(callId, 'notification', { notification }); + } - return this.sendSignal(newSignal); + public sendSignalToServer(callId: string, signal: MediaSignalBodyAndType) { + return this.sendSignal({ + ...signal, + callId, + sessionId: this.sessionId, + }); } public sendSignal(signal: MediaSignal): void { diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 5bf548761df68..bac47acf05ebb 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -1,6 +1,6 @@ import { LocalStream } from './LocalStream'; import { RemoteStream } from './RemoteStream'; -import type { DeliverParams, IWebRTCProcessor, RequestParams, WebRTCProcessorConfig } from '../../../definition'; +import type { IWebRTCProcessor, MediaSignalRequestOffer, MediaSignalSDP, WebRTCProcessorConfig } from '../../../definition'; type IceGatheringData = { promise: Promise; @@ -24,17 +24,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private remoteMediaStream: MediaStream; - private localCandidates: Set; - - private remoteCandidates: Set; - private iceGatheringWaiters: Set; constructor(private readonly config: WebRTCProcessorConfig) { this.localMediaStream = new MediaStream(); this.remoteMediaStream = new MediaStream(); - this.localCandidates = new Set(); - this.remoteCandidates = new Set(); this.iceGatheringWaiters = new Set(); this.localStream = new LocalStream(this.localMediaStream); @@ -48,7 +42,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.remoteMediaStream; } - public async createOffer({ iceRestart }: RequestParams<'offer'>): Promise> { + public async createOffer({ iceRestart }: MediaSignalRequestOffer): Promise { console.log('processor.createOffer'); await this.initializeLocalMediaStream(); @@ -67,12 +61,16 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.getLocalDescription(); } - public async createAnswer({ offer }: RequestParams<'answer'>): Promise> { + public async createAnswer({ sdp }: MediaSignalSDP): Promise { + if (sdp.type !== 'offer') { + throw new Error('invalid-webrtc-offer'); + } + await this.initializeLocalMediaStream(); // #ToDo: direction changes - if (this.peer.remoteDescription?.sdp !== offer.sdp) { - this.peer.setRemoteDescription(offer); + if (this.peer.remoteDescription?.sdp !== sdp.sdp) { + this.peer.setRemoteDescription(sdp); } const answer = await this.peer.createAnswer(); @@ -81,13 +79,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.getLocalDescription(); } - public async collectLocalDescription(_params: RequestParams<'sdp'>): Promise> { - await this.initializeLocalMediaStream(); - - return this.getLocalDescription(); - } - - public async setRemoteDescription({ sdp }: DeliverParams<'sdp'>): Promise { + public async setRemoteDescription({ sdp }: MediaSignalSDP): Promise { console.log('setRemoteDescription'); await this.initializeLocalMediaStream(); @@ -98,25 +90,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.peer.setRemoteDescription(sdp); } - public async addIceCandidates({ candidates }: DeliverParams<'ice-candidates'>): Promise { - const results = await Promise.allSettled( - candidates.map(async (candidate) => { - await this.peer?.addIceCandidate(candidate); - }), - ); - - for (const result of results) { - if (result.status === 'rejected') { - throw result.reason; - } - } - } - protected async getuserMedia(constraints: MediaStreamConstraints) { return this.config.mediaStreamFactory(constraints); } - private async getLocalDescription(): Promise> { + private async getLocalDescription(): Promise { console.log('getLocalDescription'); await this.waitForIceGathering(); console.log('waited'); @@ -134,7 +112,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { console.log('return sdp'); return { sdp, - endOfCandidates: this.iceGatheringFinished, }; } @@ -185,9 +162,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private restartIce() { this.iceGatheringFinished = false; - this.localCandidates.clear(); - this.remoteCandidates.clear(); - this.clearIceGatheringWaiters(new Error('ice-restart')); this.peer.restartIce(); @@ -199,9 +173,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } console.log('onIceCandidate event', event.candidate); - if (event.candidate) { - this.localCandidates.add(event.candidate); - } + // if (event.candidate) { + // this.localCandidates.add(event.candidate); + // } } private onIceCandidateError(peer: RTCPeerConnection, _event: RTCPeerConnectionIceErrorEvent) { diff --git a/packages/media-signaling/src/lib/utils/isNotifyNew.ts b/packages/media-signaling/src/lib/utils/isNotifyNew.ts deleted file mode 100644 index 8883de4cd4429..0000000000000 --- a/packages/media-signaling/src/lib/utils/isNotifyNew.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MediaSignal, MediaSignalNotify } from '../../definition'; - -export function isNotifyNew(signal: MediaSignal): signal is MediaSignalNotify<'new'> { - return signal.type === 'notify' && signal.body.notify === 'new'; -} diff --git a/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts b/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts new file mode 100644 index 0000000000000..99f03704997cb --- /dev/null +++ b/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts @@ -0,0 +1,5 @@ +import type { MediaSignalType } from '../../definition'; + +export function signalTypeRequiresTargeting(signalType: MediaSignalType): boolean { + return ['request-offer', 'sdp'].includes(signalType); +} diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index 71377bb671a82..553a1a3eacae5 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -1,11 +1,11 @@ -import type { IMediaCallChannel, MediaCallParticipantIdentification, MediaCallChannelWebRTCSession } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; import type { UpdateResult } from 'mongodb'; -import type { IBaseModel } from './IBaseModel'; +import type { IBaseModel, InsertionModel } from './IBaseModel'; export interface IMediaCallChannelsModel extends IBaseModel { + createOrUpdateChannel(channel: InsertionModel): Promise; findOneByCallIdAndParticipant(callId: string, participant: MediaCallParticipantIdentification): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; - setLocalWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise; - setRemoteWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise; + setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index d0e403f128fa1..3386d51470bbc 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -1,10 +1,5 @@ -import type { - IMediaCallChannel, - RocketChatRecordDeleted, - MediaCallParticipantIdentification, - MediaCallChannelWebRTCSession, -} from '@rocket.chat/core-typings'; -import type { IMediaCallChannelsModel } from '@rocket.chat/model-typings'; +import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { IMediaCallChannelsModel, InsertionModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -44,15 +39,40 @@ export class MediaCallChannelsRaw extends BaseRaw implements return this.updateOne({ _id }, update, options); } - public async setState(_id: string, state: IMediaCallChannel['state']): Promise { - return this.updateOneById(_id, { $set: { state } }); + public createOrUpdateChannel(channel: InsertionModel): Promise { + const { acknowledged, localDescription, ...channelData } = channel; + + const dataToUpdate = { + // once acknowledged, a channel never unsets this flag + ...(acknowledged && { acknowledged: true }), + ...(localDescription && { localDescription }), + } as const; + + return this.findOneAndUpdate( + { + 'callId': channel.callId, + 'participant.type': channel.participant.type, + 'participant.id': channel.participant.id, + ...(channel.participant.type === 'user' && { 'participant.sessionId': channel.participant.sessionId }), + }, + { + $setOnInsert: { + ...channelData, + }, + ...(Object.keys(dataToUpdate).length > 0 && { $set: dataToUpdate }), + }, + { + upsert: true, + returnDocument: 'after', + }, + ); } - public async setLocalWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise { - return this.updateOneById(_id, { $set: { 'webrtc.local': session } }); + public async setState(_id: string, state: IMediaCallChannel['state']): Promise { + return this.updateOneById(_id, { $set: { state } }); } - public async setRemoteWebRTCSession(_id: string, session: MediaCallChannelWebRTCSession): Promise { - return this.updateOneById(_id, { $set: { 'webrtc.remote': session } }); + public async setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise { + return this.updateOneById(_id, { $set: { localDescription } }); } } diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts index 13f3a6452c8c1..1298ac859bbea 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -25,34 +25,7 @@ export const sdpSchema: JSONSchemaType = { required: ['type'], }; -export type IceCandidate = RTCIceCandidateInit; - -export const iceCandidateSchema: JSONSchemaType = { - type: 'object', - properties: { - candidate: { - type: 'string', - nullable: true, - }, - sdpMLineIndex: { - type: 'number', - nullable: true, - }, - sdpMid: { - type: 'string', - nullable: true, - }, - usernameFragment: { - type: 'string', - nullable: true, - }, - }, - additionalProperties: false, - nullable: false, - required: [], -}; - -const mandatoryHeader = { +const signalHeader = { callId: { type: 'string', nullable: false, @@ -61,24 +34,6 @@ const mandatoryHeader = { type: 'string', nullable: true, }, - version: { - type: 'number', - nullable: false, - }, - sequence: { - type: 'number', - nullable: false, - }, - role: { - type: 'string', - nullable: false, - }, -}; -const optionalHeader = { - expectACK: { - type: 'boolean', - nullable: true, - }, }; const mediaCallsSignalPropsSchema: JSONSchemaType = { @@ -93,204 +48,155 @@ const mediaCallsSignalPropsSchema: JSONSchemaType = { { type: 'object', properties: { - ...mandatoryHeader, - ...optionalHeader, + ...signalHeader, type: { - const: 'request', + const: 'new', }, body: { type: 'object', - discriminator: { propertyName: 'request' }, - oneOf: [ - { - properties: { - request: { const: 'offer' }, - iceRestart: { - type: 'boolean', - nullable: true, - }, - }, + properties: { + service: { + type: 'string', + nullable: false, }, - { - properties: { - request: { const: 'answer' }, - offer: sdpSchema, - }, + kind: { + type: 'string', + nullable: false, }, - { - properties: { - request: { const: 'sdp' }, - }, + role: { + type: 'string', + nullable: false, }, - ], - + }, additionalProperties: true, - required: ['request'], + required: ['service', 'kind', 'role'], }, }, additionalProperties: false, - required: [...Object.keys(mandatoryHeader), 'type', 'body'], + required: [...Object.keys(signalHeader), 'type', 'body'], }, { type: 'object', properties: { - ...mandatoryHeader, - ...optionalHeader, + ...signalHeader, type: { - const: 'deliver', + const: 'sdp', }, body: { type: 'object', - discriminator: { propertyName: 'deliver' }, - oneOf: [ - { - properties: { - deliver: { const: 'sdp' }, - sdp: sdpSchema, - endOfCandidates: { - type: 'boolean', - nullable: false, - }, - }, - required: ['deliver', 'sdp', 'endOfCandidates'], - }, - { - properties: { - deliver: { const: 'ice-candidates' }, - candidates: { - type: 'array', - items: iceCandidateSchema, - }, - endOfCandidates: { - type: 'boolean', - nullable: false, - }, - }, - required: ['deliver', 'candidates', 'endOfCandidates'], - }, - { - properties: { - deliver: { const: 'dtmf' }, - tone: { - type: 'string', - nullable: false, - }, - duration: { - type: 'number', - nullable: true, - }, - }, - required: ['deliver', 'tone'], - }, - ], - required: ['deliver'], + properties: { + sdp: sdpSchema, + }, + required: ['sdp'], }, }, additionalProperties: false, - required: [...Object.keys(mandatoryHeader), 'type', 'body'], + required: [...Object.keys(signalHeader), 'type', 'body'], }, { type: 'object', properties: { - ...mandatoryHeader, - ...optionalHeader, + ...signalHeader, type: { - const: 'notify', + const: 'request-offer', }, body: { type: 'object', - discriminator: { propertyName: 'notify' }, - oneOf: [ - { - properties: { - notify: { const: 'ack' }, - }, - }, - { - properties: { - notify: { const: 'error' }, - errorCode: { - type: 'string', - nullable: false, - }, - errorText: { - type: 'string', - nullable: true, - }, - }, - required: ['notify', 'errorCode'], - }, - { - properties: { - notify: { const: 'new' }, - service: { - const: 'webrtc', - }, - kind: { - const: 'direct', - }, - }, - required: ['notify', 'service', 'kind'], - }, - { - properties: { - notify: { const: 'state' }, - callState: { - type: 'string', - }, - serviceState: { - type: 'string', - }, - mediaState: { - type: 'string', - }, - }, - required: ['notify', 'callState', 'serviceState', 'mediaState'], + properties: { + iceRestart: { + type: 'boolean', + nullable: true, }, - { - properties: { - notify: { const: 'unavailable' }, - }, - }, - { - properties: { - notify: { const: 'accept' }, - }, + }, + required: [], + }, + }, + additionalProperties: false, + required: [...Object.keys(signalHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...signalHeader, + type: { + const: 'error', + }, + body: { + type: 'object', + properties: { + errorCode: { + type: 'string', + nullable: false, }, - { - properties: { - notify: { const: 'reject' }, - }, + }, + required: ['errorCode'], + }, + }, + additionalProperties: false, + required: [...Object.keys(signalHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...signalHeader, + type: { + const: 'answer', + }, + body: { + type: 'object', + properties: { + answer: { + type: 'string', + nullable: false, }, - { - properties: { - notify: { const: 'hangup' }, - reasonCode: { - type: 'string', - nullable: false, - }, - reasonText: { - type: 'string', - nullable: true, - }, - }, - required: ['notify', 'reasonCode'], + }, + required: ['answer'], + }, + }, + additionalProperties: false, + required: [...Object.keys(signalHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...signalHeader, + type: { + const: 'hangup', + }, + body: { + type: 'object', + properties: { + reason: { + type: 'string', + nullable: false, }, - { - properties: { - notify: { const: 'negotiation-needed' }, - reason: { - type: 'string', - nullable: true, - }, - }, + }, + required: ['reason'], + }, + }, + additionalProperties: false, + required: [...Object.keys(signalHeader), 'type', 'body'], + }, + { + type: 'object', + properties: { + ...signalHeader, + type: { + const: 'notification', + }, + body: { + type: 'object', + properties: { + notification: { + type: 'string', + nullable: false, }, - ], - required: ['notify'], + }, + required: ['notification'], }, }, additionalProperties: false, - required: [...Object.keys(mandatoryHeader), 'type', 'body'], + required: [...Object.keys(signalHeader), 'type', 'body'], }, ], }, diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx index c8fae738ba052..86dfa4962eab7 100644 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -5,7 +5,7 @@ import { VoipContext, isVoipContextReady } from '../contexts/VoipContext'; import MediaCallsClient from '../lib/MediaCallsClient'; type VoipAPI = { - makeCall(callee: string): void; + makeCall(calleeURI: string): void; endCall(): void; register(): Promise; unregister(): Promise; From 4d4b53c41b50e5d8ffd8bca619ac4bc04eca59df Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 31 Jul 2025 15:35:30 -0300 Subject: [PATCH 17/72] moving test code to a separate branch --- .../client/providers/MeteorProvider.tsx | 6 +- packages/ui-voip/package.json | 1 - .../ui-voip/src/hooks/useMediaCallsClient.tsx | 90 --- packages/ui-voip/src/hooks/useVoipAPI.tsx | 20 - packages/ui-voip/src/index.ts | 1 - packages/ui-voip/src/lib/MediaCallsClient.ts | 560 ------------------ packages/ui-voip/src/lib/getUserMedia.ts | 7 - .../src/providers/MediaCallsProvider.tsx | 188 ------ yarn.lock | 1 - 9 files changed, 3 insertions(+), 871 deletions(-) delete mode 100644 packages/ui-voip/src/hooks/useMediaCallsClient.tsx delete mode 100644 packages/ui-voip/src/lib/MediaCallsClient.ts delete mode 100644 packages/ui-voip/src/lib/getUserMedia.ts delete mode 100644 packages/ui-voip/src/providers/MediaCallsProvider.tsx diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 961f5cb1c5437..a8faabf0ff90f 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -1,5 +1,5 @@ import { ModalProvider } from '@rocket.chat/ui-client'; -import { MediaCallsProvider } from '@rocket.chat/ui-voip'; +import { VoipProvider } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import ActionManagerProvider from './ActionManagerProvider'; @@ -51,11 +51,11 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( - + {children} - + diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json index dde6865e16c3e..0083962895cf7 100644 --- a/packages/ui-voip/package.json +++ b/packages/ui-voip/package.json @@ -20,7 +20,6 @@ }, "dependencies": { "@rocket.chat/emitter": "~0.31.25", - "@rocket.chat/media-signaling": "workspace:^", "@tanstack/react-query": "~5.65.1", "react-i18next": "~13.2.2", "sip.js": "^0.21.2" diff --git a/packages/ui-voip/src/hooks/useMediaCallsClient.tsx b/packages/ui-voip/src/hooks/useMediaCallsClient.tsx deleted file mode 100644 index f63a9ec4947ba..0000000000000 --- a/packages/ui-voip/src/hooks/useMediaCallsClient.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { MediaSignal } from '@rocket.chat/media-signaling'; -import { useEndpoint, useStream, useUser } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; - -// import { useIceServers } from './useIceServers'; -import MediaCallsClient, { type MediaCallsClientConfig } from '../lib/MediaCallsClient'; - -type MediaCallsClientParams = { - enabled?: boolean; -}; - -type MediaCallsClientResult = { - mediaCallsClient: MediaCallsClient | null; - error: Error | null; -}; - -export const useMediaCallsClient = ({ enabled = true }: MediaCallsClientParams = {}): MediaCallsClientResult => { - const { _id: userId } = useUser() || {}; - const mediaCallsClientRef = useRef(null); - - const mediaCallsStart = useEndpoint('POST', '/v1/media-calls.start'); - const mediaCallsSignal = useEndpoint('POST', '/v1/media-calls.signal'); - const notifyUserStream = useStream('notify-user'); - - const startCallFn: MediaCallsClientConfig['startCallFn'] = async (params) => { - const { call } = await mediaCallsStart(params); - const { createdAt, endedAt, _updatedAt, ...callData } = call; - - return { - ...callData, - createdAt: new Date(createdAt), - _updatedAt: new Date(_updatedAt), - ...(endedAt && { endedAt: new Date(endedAt) }), - }; - }; - - const sendSignalFn: MediaCallsClientConfig['sendSignalFn'] = async (signal) => { - await mediaCallsSignal({ signal }); - }; - - // const iceServers = useIceServers(); - - const { data: mediaCallsClient, error } = useQuery({ - queryKey: ['media-calls-client', enabled, userId], - queryFn: async () => { - if (mediaCallsClientRef.current) { - mediaCallsClientRef.current.clear(); - } - - if (!userId) { - throw Error('error-user-not-found'); - } - - const config: MediaCallsClientConfig = { - userId, - startCallFn, - sendSignalFn, - }; - - const mediaCallsClient = await MediaCallsClient.create(config); - - return mediaCallsClient; - }, - initialData: null, - enabled, - }); - - const notifyNewMediaSignal = useEffectEvent((signal: MediaSignal) => { - console.log('new media signal', signal); - mediaCallsClient?.processSignal(signal); - }); - - useEffect(() => { - const unsubNotification = notifyUserStream(`${userId}/media-signal`, notifyNewMediaSignal); - - return () => { - unsubNotification(); - }; - }, [notifyNewMediaSignal, notifyUserStream, userId, mediaCallsClient]); - - useEffect(() => { - mediaCallsClientRef.current = mediaCallsClient; - - return () => mediaCallsClientRef.current?.clear(); - }, [mediaCallsClient]); - - return { mediaCallsClient, error }; -}; diff --git a/packages/ui-voip/src/hooks/useVoipAPI.tsx b/packages/ui-voip/src/hooks/useVoipAPI.tsx index 86dfa4962eab7..7d2332ac7322e 100644 --- a/packages/ui-voip/src/hooks/useVoipAPI.tsx +++ b/packages/ui-voip/src/hooks/useVoipAPI.tsx @@ -2,7 +2,6 @@ import { useContext, useMemo } from 'react'; import type { VoipContextReady } from '../contexts/VoipContext'; import { VoipContext, isVoipContextReady } from '../contexts/VoipContext'; -import MediaCallsClient from '../lib/MediaCallsClient'; type VoipAPI = { makeCall(calleeURI: string): void; @@ -42,25 +41,6 @@ export const useVoipAPI = (): VoipAPI => { const { voipClient, changeAudioInputDevice, changeAudioOutputDevice } = context; - // Workaround to use the MediaCallsClient with the VoipContext for now - if (voipClient && voipClient instanceof MediaCallsClient) { - const mediaCallsClient = voipClient as MediaCallsClient; - - return { - makeCall: (callee) => mediaCallsClient.call({ identifier: callee, identifierKind: 'extension' }), - endCall: () => mediaCallsClient.endCall(), - register: NOOP, - unregister: NOOP, - transferCall: mediaCallsClient.transfer, - openDialer: () => mediaCallsClient.notifyDialer({ open: true }), - closeDialer: () => mediaCallsClient.notifyDialer({ open: false }), - changeAudioInputDevice, - changeAudioOutputDevice, - onRegisteredOnce: NOOP, - onUnregisteredOnce: NOOP, - }; - } - return { makeCall: voipClient.call, endCall: voipClient.endCall, diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index e3818972df05e..ebf30176a7256 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -1,5 +1,4 @@ export { default as VoipProvider } from './providers/VoipProvider'; -export { default as MediaCallsProvider } from './providers/MediaCallsProvider'; export * from './definitions/VoipSession'; export * from './hooks'; export * from './components'; diff --git a/packages/ui-voip/src/lib/MediaCallsClient.ts b/packages/ui-voip/src/lib/MediaCallsClient.ts deleted file mode 100644 index e877e4fb32182..0000000000000 --- a/packages/ui-voip/src/lib/MediaCallsClient.ts +++ /dev/null @@ -1,560 +0,0 @@ -import type { SignalingSocketEvents, VoipEvents as CoreVoipEvents, IUser, IMediaCall } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import { IClientMediaCall, MediaSignal, MediaSignalingSession, MediaCallWebRTCProcessor } from '@rocket.chat/media-signaling'; - -import type { ContactInfo, VoipSession } from '../definitions'; -import RemoteStream from './RemoteStream'; -import { getUserMedia } from './getUserMedia'; - -export type VoipEvents = Omit & { - callestablished: ContactInfo; - incomingcall: ContactInfo; - outgoingcall: ContactInfo; - dialer: { open: boolean }; - incomingcallerror: string; -}; - -type SessionError = { - status: number | undefined; - reason: string; - contact: ContactInfo; -}; - -export type MediaCallsCallee = { - identifier: string; - identifierKind: 'user' | 'room' | 'extension'; -}; - -export type MediaCallsClientConfig = { - userId: IUser['_id']; - startCallFn: (params: { sessionId: string } & MediaCallsCallee) => Promise; - sendSignalFn: (signal: MediaSignal) => void; -}; - -class MediaCallsClient extends Emitter { - public networkEmitter: Emitter; - - private audioElement: HTMLAudioElement | null = null; - - private remoteStream: RemoteStream | undefined; - - private held = false; - - private muted = false; - - private online = true; - - private error: SessionError | null = null; - - private reconnecting = false; - - private startingNewCall = false; - - private session: MediaSignalingSession; - - constructor(private readonly config: MediaCallsClientConfig) { - super(); - - this.session = new MediaSignalingSession({ - userId: config.userId, - transport: (signal: MediaSignal) => config.sendSignalFn(signal), - processorFactories: { - webrtc: (config) => new MediaCallWebRTCProcessor(config), - }, - mediaStreamFactory: getUserMedia, - }); - - this.networkEmitter = new Emitter(); - - this.session.on('newCall', ({ call }) => this.onNewCall(call)); - this.session.on('acceptedCall', ({ call }) => this.onAcceptedCall(call)); - this.session.on('endedCall', ({ call }) => this.onEndedCall(call)); - } - - public async init() { - window.addEventListener('online', this.onNetworkRestored); - window.addEventListener('offline', this.onNetworkLost); - } - - static async create(config: MediaCallsClientConfig): Promise { - const mediaCalls = new MediaCallsClient(config); - // await mediaCalls.init(); - return mediaCalls; - } - - public isBusy(): boolean { - return this.startingNewCall || this.session.isBusy(); - } - - public async processSignal(signal: MediaSignal): Promise { - return this.session.processSignal(signal); - } - - public async call(callee: MediaCallsCallee): Promise { - console.log('MediaCallsClient.call', callee); - - if (this.isBusy()) { - throw new Error('Session already exists'); - } - - await this.startCall(callee); - } - - public async transfer(calleeURI: string): Promise { - if (!calleeURI) { - throw new Error('Invalid URI'); - } - - if (!this.isBusy()) { - throw new Error('No active call'); - } - - // #ToDo - } - - public async answer(): Promise { - console.log('answer'); - const call = this.session.getMainCall(); - if (!call) { - throw new Error('No call available to answer'); - } - - return call.accept(); - } - - public async reject(): Promise { - console.log('reject'); - - const call = this.session.getMainCall(); - if (!call) { - throw new Error('No call available to reject'); - } - - return call.reject(); - } - - public async endCall(): Promise { - console.log('endCall'); - - const call = this.session.getMainCall(); - if (!call) { - throw new Error('No call available to hangup'); - } - - return call.hangup(); - } - - public async setMute(_mute: boolean): Promise { - // - } - - public async setHold(_hold: boolean): Promise { - // - } - - public async sendDTMF(_tone: string): Promise { - // - } - - public async changeAudioInputDevice(_constraints: MediaStreamConstraints): Promise { - console.log('changeAudioInputDevice'); - - return true; - // if (!this.isBusy()) { - // console.warn('changeAudioInputDevice() : No session.'); - // return false; - // } - // const newStream = await LocalStream.requestNewStream(constraints, this.session); - // if (!newStream) { - // console.warn('changeAudioInputDevice() : Unable to get local stream.'); - // return false; - // } - // const { peerConnection } = this.sessionDescriptionHandler; - // if (!peerConnection) { - // console.warn('changeAudioInputDevice() : No peer connection.'); - // return false; - // } - // LocalStream.replaceTrack(peerConnection, newStream, 'audio'); - // return true; - } - - public switchAudioElement(audioElement: HTMLAudioElement | null): void { - console.log('switchAudioElement'); - - this.audioElement = audioElement; - - if (this.remoteStream) { - this.playRemoteStream(); - } - } - - public getContactInfo(): ContactInfo | null { - if (this.error) { - return this.error.contact; - } - - const call = this.session.getMainCall(); - if (!call) { - return null; - } - - return this.getCallContactInfo(call); - } - - public getReferredBy() { - return null; - // if (!(this.session instanceof Invitation)) { - // return null; - // } - // - // const referredBy = this.session.request.getHeader('Referred-By'); - // - // if (!referredBy) { - // return null; - // } - // - // const uri = UserAgent.makeURI(referredBy.slice(1, -1)); - // - // if (!uri) { - // return null; - // } - // - // return { - // id: uri.user ?? '', - // host: uri.host, - // }; - } - - public isRegistered(): boolean { - return true; - } - - public isReady(): boolean { - return true; - } - - public isCaller(): boolean { - return this.session.getMainCall()?.role === 'caller'; - } - - public isCallee(): boolean { - return this.session.getMainCall()?.role === 'callee'; - } - - public isIncoming(): boolean { - return this.getSessionType() === 'INCOMING'; - } - - public isOngoing(): boolean { - return this.getSessionType() === 'ONGOING'; - } - - public isOutgoing(): boolean { - return this.getSessionType() === 'OUTGOING'; - } - - public isInCall(): boolean { - return this.getSessionType() !== null; - } - - public isError(): boolean { - return !!this.error; - } - - public isReconnecting(): boolean { - return this.reconnecting; - } - - public isOnline(): boolean { - return this.online; - } - - public isMuted(): boolean { - return this.muted; - } - - public isHeld(): boolean { - return this.held; - } - - public getError() { - return this.error ?? null; - } - - public clearErrors = (): void => { - this.setError(null); - }; - - public getSessionType(): VoipSession['type'] | null { - const call = this.session.getMainCall(); - if (!call) { - return null; - } - - if (call.state === 'error') { - return 'ERROR'; - } - if (['active', 'accepted'].includes(call.state)) { - return 'ONGOING'; - } - if (call.state === 'hangup') { - return null; - } - - if (['none', 'ringing'].includes(call.state)) { - if (call.role === 'callee') { - return 'INCOMING'; - } - if (call.role === 'caller') { - return 'OUTGOING'; - } - } - - return null; - } - - public getSession(): VoipSession | null { - const type = this.getSessionType(); - - switch (type) { - case 'ERROR': { - const { contact, ...error } = this.getError() as SessionError; - return { - type: 'ERROR', - error, - contact, - end: this.clearErrors, - }; - } - case 'INCOMING': - case 'ONGOING': - case 'OUTGOING': - return { - type, - contact: this.getContactInfo() as ContactInfo, - transferedBy: this.getReferredBy(), - isMuted: this.isMuted(), - isHeld: this.isHeld(), - mute: (mute) => this.setMute(mute ?? true), - hold: (hold) => this.setHold(hold ?? true), - accept: () => this.answer(), - end: () => this.endCall(), - dtmf: (tone) => this.sendDTMF(tone), - }; - default: - return null; - } - } - - public getState() { - return { - isRegistered: this.isRegistered(), - isReady: this.isReady(), - isOnline: this.isOnline(), - isIncoming: this.isIncoming(), - isOngoing: this.isOngoing(), - isOutgoing: this.isOutgoing(), - isInCall: this.isBusy(), - isError: this.isError(), - isReconnecting: this.isReconnecting(), - }; - } - - public getAudioElement(): HTMLAudioElement | null { - return this.audioElement; - } - - public notifyDialer(value: { open: boolean }) { - this.emit('dialer', value); - } - - public clear(): void { - // window.removeEventListener('online', this.onNetworkRestored); - // window.removeEventListener('offline', this.onNetworkLost); - } - - private onNewCall(call: IClientMediaCall): void { - console.log('onNewCall'); - const contact = this.getCallContactInfo(call) as ContactInfo; - - if (call.role === 'callee') { - this.emit('incomingcall', contact); - } else if (call.role === 'caller') { - this.emit('outgoingcall', contact); - } - this.emit('stateChanged'); - } - - private onAcceptedCall(call: IClientMediaCall): void { - console.log('onAcceptedCall'); - - const remoteMediaStream = call.getRemoteMediaStream(); - this.remoteStream = new RemoteStream(remoteMediaStream); - this.playRemoteStream(); - - const contact = this.getCallContactInfo(call) as ContactInfo; - this.emit('callestablished', contact); - this.emit('stateChanged'); - } - - private onEndedCall(_call: IClientMediaCall): void { - console.log('onEndedCall'); - - this.remoteStream?.clear(); - this.emit('callterminated'); - this.emit('stateChanged'); - } - - private getCallContactInfo(call: IClientMediaCall): ContactInfo | null { - if (this.error) { - return this.error.contact; - } - - const contactData = this.session.getStoredCallContact(call.callId); - if (!contactData) { - return null; - } - - return { - id: contactData.id, - name: contactData.displayName, - host: '', - }; - } - - // private setContactInfo(contact: ContactInfo) { - // this.contactInfo = contact; - // this.emit('stateChanged'); - // } - - private async startCall(target: MediaCallsCallee): Promise { - console.log('startCall'); - this.startingNewCall = true; - try { - this.emit('stateChanged'); - - const call = await this.config.startCallFn({ sessionId: this.session.sessionId, ...target }); - - console.log('startCall', call); - const { _id: callId, callee } = call; - this.session.setCallContact(callId, callee); - - this.emit('stateChanged'); - } finally { - this.startingNewCall = false; - } - } - - private playRemoteStream() { - console.log('playRemoteStream'); - - if (!this.remoteStream) { - console.warn(`Attempted to play missing remote media.`); - return; - } - - if (!this.audioElement) { - console.error('Unable to play remote media: VoIPClient is missing an AudioElement reference to play it on.'); - return; - } - - this.remoteStream.init(this.audioElement); - this.remoteStream.play(); - } - - // private toggleMediaStreamTracks(type: 'sender' | 'receiver', enable: boolean): void { - // console.log('toggleMediaStreamTracks', type, enable); - // // const { peerConnection } = this.sessionDescriptionHandler; - - // // if (!peerConnection) { - // // throw new Error('Peer connection closed.'); - // // } - - // // const tracks = type === 'sender' ? peerConnection.getSenders() : peerConnection.getReceivers(); - - // // tracks?.forEach((sender) => { - // // if (sender.track) { - // // sender.track.enabled = enable; - // // } - // // }); - // } - - // private onInvitationCancel(): void { - // // const reason = getMainInviteRejectionReason(invitation, message); - // // if (reason) { - // // this.emit('incomingcallerror', reason); - // // } - // } - - // private async onIncomingCall(): Promise { - // // #ToDo: incoming call - - // this.emit('incomingcall', this.getContactInfo() as ContactInfo); - // this.emit('stateChanged'); - // } - - // private async onTransferedCall() { - // // await referral.accept(); - // // this.sendInvite(referral.makeInviter()); - // } - - // private async onMessageReceived(): Promise { - // // #ToDo: contact update messages - // } - - // private onSessionStablishing(): void { - // this.emit('outgoingcall', this.getContactInfo() as ContactInfo); - // } - - // private onSessionStablished(): void { - // this.setupRemoteMedia(); - // this.emit('callestablished', this.getContactInfo() as ContactInfo); - // this.emit('stateChanged'); - // } - - // private onInviteRejected(response: IncomingResponse): void { - // const { statusCode, reasonPhrase, to } = response.message; - - // if (!reasonPhrase || statusCode === 487) { - // return; - // } - - // this.setError({ - // status: statusCode, - // reason: reasonPhrase, - // contact: { id: to.uri.user ?? '', host: to.uri.host }, - // }); - - // this.emit('callfailed', response.message.reasonPhrase || 'unknown'); - // } - - // private onSessionTerminated(): void { - // this.muted = false; - // this.held = false; - // this.remoteStream?.clear(); - // this.emit('callterminated'); - // this.emit('stateChanged'); - // } - - private onNetworkRestored(): void { - this.online = true; - this.networkEmitter.emit('localnetworkonline'); - this.emit('stateChanged'); - - // this.attemptReconnection(); - } - - private onNetworkLost(): void { - this.online = false; - this.networkEmitter.emit('localnetworkoffline'); - this.emit('stateChanged'); - } - - private setError(error: SessionError | null) { - console.error(error); - this.error = error; - this.emit('stateChanged'); - } -} - -export default MediaCallsClient; diff --git a/packages/ui-voip/src/lib/getUserMedia.ts b/packages/ui-voip/src/lib/getUserMedia.ts deleted file mode 100644 index 59da4006b3172..0000000000000 --- a/packages/ui-voip/src/lib/getUserMedia.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function getUserMedia(constraints: MediaStreamConstraints): Promise { - if (navigator.mediaDevices === undefined) { - throw new Error('Media devices not available in insecure contexts.'); - } - - return navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices, constraints); -} diff --git a/packages/ui-voip/src/providers/MediaCallsProvider.tsx b/packages/ui-voip/src/providers/MediaCallsProvider.tsx deleted file mode 100644 index ba66afb9da43f..0000000000000 --- a/packages/ui-voip/src/providers/MediaCallsProvider.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import type { Device } from '@rocket.chat/ui-contexts'; -import { - useCustomSound, - usePermission, - useSetInputMediaDevice, - useSetOutputMediaDevice, - useSetting, - useToastMessageDispatch, -} from '@rocket.chat/ui-contexts'; -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; - -import { VoipPopupDraggable } from '../components'; -import VoipPopupPortal from '../components/VoipPopupPortal'; -import type { VoipContextValue } from '../contexts/VoipContext'; -import { VoipContext } from '../contexts/VoipContext'; -import { useMediaCallsClient } from '../hooks/useMediaCallsClient'; - -const MediaCallsProvider = ({ children }: { children: ReactNode }) => { - // Settings - const isVoipSettingEnabled = useSetting('VoIP_TeamCollab_Enabled', false); - const canViewVoipRegistrationInfo = usePermission('view-user-voip-extension'); - const isVoipEnabled = isVoipSettingEnabled && canViewVoipRegistrationInfo; - - // Hooks - const { t } = useTranslation(); - const { voipSounds } = useCustomSound(); - const { mediaCallsClient, error } = useMediaCallsClient({ - enabled: isVoipEnabled, - }); - const setOutputMediaDevice = useSetOutputMediaDevice(); - const setInputMediaDevice = useSetInputMediaDevice(); - const dispatchToastMessage = useToastMessageDispatch(); - - // Refs - const remoteAudioMediaRef = useCallback( - (node: HTMLMediaElement | null) => { - mediaCallsClient?.switchAudioElement(node); - }, - [mediaCallsClient], - ); - - useEffect(() => { - if (!mediaCallsClient) { - return; - } - - const onBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = true; - }; - - const onCallEstablished = async (): Promise => { - voipSounds.stopDialer(); - voipSounds.stopRinger(); - window.addEventListener('beforeunload', onBeforeUnload); - }; - - const onNetworkDisconnected = (): void => { - if (mediaCallsClient.isOngoing()) { - mediaCallsClient.endCall(); - } - }; - - const onOutgoingCallRinging = (): void => { - // mediaCallsClient 'outgoingcall' event is emitted when the call is establishing - // and that event is also emitted when the call is accepted - // to avoid disrupting the mediaCallsClient flow, we check if the call is outgoing here. - if (mediaCallsClient.isOutgoing()) { - voipSounds.playDialer(); - } - }; - - const onIncomingCallRinging = (): void => { - voipSounds.playRinger(); - }; - - const onCallTerminated = (): void => { - voipSounds.playCallEnded(); - voipSounds.stopDialer(); - voipSounds.stopRinger(); - window.removeEventListener('beforeunload', onBeforeUnload); - }; - - const onIncomingCallError = (reason: string) => { - console.error('incoming call canceled', reason); - if (reason === 'USER_NOT_REGISTERED') { - dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_user_not_registered') }); - return; - } - - dispatchToastMessage({ type: 'error', message: t('Incoming_voice_call_canceled_suddenly') }); - }; - - mediaCallsClient.on('incomingcall', onIncomingCallRinging); - mediaCallsClient.on('outgoingcall', onOutgoingCallRinging); - mediaCallsClient.on('callestablished', onCallEstablished); - mediaCallsClient.on('callterminated', onCallTerminated); - mediaCallsClient.on('incomingcallerror', onIncomingCallError); - mediaCallsClient.networkEmitter.on('disconnected', onNetworkDisconnected); - mediaCallsClient.networkEmitter.on('connectionerror', onNetworkDisconnected); - mediaCallsClient.networkEmitter.on('localnetworkoffline', onNetworkDisconnected); - - return (): void => { - voipSounds.stopCallEnded(); - mediaCallsClient.off('incomingcall', onIncomingCallRinging); - mediaCallsClient.off('outgoingcall', onOutgoingCallRinging); - mediaCallsClient.off('callestablished', onCallEstablished); - mediaCallsClient.off('callterminated', onCallTerminated); - mediaCallsClient.off('incomingcallerror', onIncomingCallError); - mediaCallsClient.networkEmitter.off('disconnected', onNetworkDisconnected); - mediaCallsClient.networkEmitter.off('connectionerror', onNetworkDisconnected); - mediaCallsClient.networkEmitter.off('localnetworkoffline', onNetworkDisconnected); - window.removeEventListener('beforeunload', onBeforeUnload); - }; - }, [dispatchToastMessage, t, mediaCallsClient, voipSounds]); - - const changeAudioOutputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { - const element = mediaCallsClient?.getAudioElement(); - if (!element) { - console.warn(`Failed to change audio output device: missing audio element reference.`); - return; - } - - setOutputMediaDevice({ outputDevice: selectedAudioDevice, HTMLAudioElement: element }); - }); - - const changeAudioInputDevice = useEffectEvent(async (selectedAudioDevice: Device): Promise => { - if (!mediaCallsClient) { - return; - } - - await mediaCallsClient.changeAudioInputDevice({ audio: { deviceId: { exact: selectedAudioDevice.id } } }); - setInputMediaDevice(selectedAudioDevice); - }); - - const contextValue = useMemo(() => { - if (!isVoipEnabled) { - return { - isEnabled: false, - voipClient: null, - error: null, - changeAudioInputDevice, - changeAudioOutputDevice, - }; - } - - if (!mediaCallsClient || error) { - return { - isEnabled: true, - voipClient: null, - error, - changeAudioInputDevice, - changeAudioOutputDevice, - }; - } - - return { - isEnabled: true, - voipClient: mediaCallsClient as any, - - changeAudioInputDevice, - changeAudioOutputDevice, - }; - }, [mediaCallsClient, isVoipEnabled, error, changeAudioInputDevice, changeAudioOutputDevice]); - - return ( - - {children} - {contextValue.isEnabled && - createPortal( - , - document.body, - )} - - - - - - ); -}; - -export default MediaCallsProvider; diff --git a/yarn.lock b/yarn.lock index 8189e83159fba..024615a6391b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9258,7 +9258,6 @@ __metadata: "@rocket.chat/fuselage-tokens": "npm:~0.33.2" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" - "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/mock-providers": "workspace:~" "@rocket.chat/styled": "npm:~0.32.0" "@rocket.chat/tsconfig": "workspace:*" From fc8fd1ae7fe29c3f9df7b9a7b3b51ac8f3472c6d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 31 Jul 2025 16:04:22 -0300 Subject: [PATCH 18/72] rebase conflict oopsie --- packages/rest-typings/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index a9614e579100e..1f2e18bffdaa9 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -30,8 +30,8 @@ import type { InvitesEndpoints } from './v1/invites'; import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MailerEndpoints } from './v1/mailer'; -import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MeEndpoints } from './v1/me'; +import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MiscEndpoints } from './v1/misc'; import type { ModerationEndpoints } from './v1/moderation'; import type { OAuthAppsEndpoint } from './v1/oauthapps'; From df797ecef0c22fd4e00edb6ad430452890010872 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 31 Jul 2025 23:55:46 -0300 Subject: [PATCH 19/72] rejection and handling of temporary data --- .../core-typings/src/mediaCalls/IMediaCall.ts | 1 - .../src/signals/processAnswer.ts | 23 +- .../src/signals/processSignal.ts | 5 + .../src/definition/call/IClientMediaCall.ts | 7 +- packages/media-signaling/src/lib/Call.ts | 273 ++++++++++++++---- packages/media-signaling/src/lib/Session.ts | 136 ++------- 6 files changed, 268 insertions(+), 177 deletions(-) diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index eb1f5cb5e5d28..6be1d21c57f05 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -28,7 +28,6 @@ export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; kind: 'direct'; - rid?: string; state: 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; createdBy: MediaCallActor; diff --git a/packages/media-signaling-server/src/signals/processAnswer.ts b/packages/media-signaling-server/src/signals/processAnswer.ts index f97bb964c5a64..78c388bacff4f 100644 --- a/packages/media-signaling-server/src/signals/processAnswer.ts +++ b/packages/media-signaling-server/src/signals/processAnswer.ts @@ -2,6 +2,7 @@ import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; import type { MediaSignalAnswer } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; +import { processHangup } from './processHangup'; import { acknowledgeCallee } from '../calls/acknowledgeCallee'; import { acknowledgeCaller } from '../calls/acknowledgeCaller'; import { processAcceptedCall } from '../calls/processAcceptedCall'; @@ -23,6 +24,25 @@ async function processAccept(call: IMediaCall, channel: ValidSignalChannel): Pro return processAcceptedCall(call._id); } +async function processReject(call: IMediaCall, channel: ValidSignalChannel): Promise { + if (channel.role !== 'callee') { + return; + } + + if (!['none', 'ringing'].includes(call.state)) { + console.log('cant reject an ongoing call.'); + return; + } + + return processHangup( + { + reason: 'rejected', + }, + call, + channel, + ); +} + async function processACK(call: IMediaCall, channel: ValidSignalChannel): Promise { if (channel.role === 'callee') { return acknowledgeCallee(call, channel); @@ -39,8 +59,9 @@ export async function processAnswer(params: MediaSignalAnswer, call: IMediaCall, case 'accept': return processAccept(call, channel); case 'unavailable': + // return processUnavailable(call, channel); break; case 'reject': - break; + return processReject(call, channel); } } diff --git a/packages/media-signaling-server/src/signals/processSignal.ts b/packages/media-signaling-server/src/signals/processSignal.ts index 58ab3b00e3bc6..c82fd63a55ab7 100644 --- a/packages/media-signaling-server/src/signals/processSignal.ts +++ b/packages/media-signaling-server/src/signals/processSignal.ts @@ -46,6 +46,11 @@ export async function processSignal(signal: MediaSignal, uid: IUser['_id']): Pro throw new Error('invalid-channel-data'); } + // The code will only reach this point if one of the three following conditions are true: + // 1. the signal came from the exact user session where the caller initiated the call + // 2. the signal came from the exact user session where the callee accepted the call + // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee + switch (signal.type) { case 'sdp': await processSDP(signal.body as MediaSignalSDP, call, channel); diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index d4f497bdea02a..b0b8c715ea4aa 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -8,12 +8,13 @@ export type CallRole = 'caller' | 'callee'; export type CallService = 'webrtc'; -export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup' | 'error'; +export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; export type CallHangupReason = | 'normal' // User explicitly hanged up | 'remote' // Server told the client to hang up - | 'unavailable' // The opposite actor is not available + | 'rejected' // The callee rejected the call + | 'unavailable' // The actor is not available | 'signaling-error' // Hanging up because of an error during the signal processing | 'service-error' // Hanging up because of an error setting up the service connection | 'media-error' // Hanging up because of an error setting up the media connection @@ -22,7 +23,7 @@ export type CallHangupReason = export interface IClientMediaCallData { callId: string; role: CallRole; - service: CallService; + service: CallService | null; state?: CallState; ignored?: boolean; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 1723f442bfbed..09a5fb74c5ec0 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -1,23 +1,17 @@ import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalTransportWrapper } from './TransportWrapper'; +import type { IServiceProcessorFactoryList } from '../definition'; import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; -import type { MediaSignal, MediaSignalSDP, MediaSignalType } from '../definition/MediaSignal'; -import type { - IClientMediaCall, - IClientMediaCallData, - CallEvents, - CallContact, - CallRole, - CallState, - CallService, - CallHangupReason, -} from '../definition/call'; +import type { MediaSignal, MediaSignalSDP } from '../definition/MediaSignal'; +import type { IClientMediaCall, CallEvents, CallContact, CallRole, CallState, CallService, CallHangupReason } from '../definition/call'; import { signalTypeRequiresTargeting } from './utils/signalTypeRequiresTargeting'; +import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; export interface IClientMediaCallConfig { transporter: MediaSignalTransportWrapper; - webrtcProcessor: IWebRTCProcessor; + processorFactories: IServiceProcessorFactoryList; + mediaStreamFactory: MediaStreamFactory; } export class ClientMediaCall implements IClientMediaCall { @@ -49,50 +43,93 @@ export class ClientMediaCall implements IClientMediaCall { return this._contact; } - private _service: CallService; + private _service: CallService | null; - public get service(): CallService { + public get service(): CallService | null { return this._service; } - private transporter: MediaSignalTransportWrapper; - - private webrtcProcessor: IWebRTCProcessor; - - private firstSignal: MediaSignal<'new'>; + protected webrtcProcessor: IWebRTCProcessor | null = null; private acceptedLocally: boolean; private endedLocally: boolean; + private hasRemoteData: boolean; + + private acknowledged: boolean; + + private earlySignals: Set; + // private timeoutHandler?: ReturnType; constructor( - config: IClientMediaCallConfig, - signal: MediaSignal<'new'>, - { ignored, contact }: Pick = {}, + private readonly config: IClientMediaCallConfig, + callId: string, ) { this.emitter = new Emitter(); - this.firstSignal = signal; + this.config.transporter = config.transporter; - this.transporter = config.transporter; - this.webrtcProcessor = config.webrtcProcessor; - - this.callId = signal.callId; - this._role = signal.body.role; - this._service = signal.body.service; + this.callId = callId; this.acceptedLocally = false; this.endedLocally = false; + this.hasRemoteData = false; + this.acknowledged = false; + + this.earlySignals = new Set(); + this._role = 'callee'; this._state = 'none'; - this._ignored = ignored || false; - this._contact = contact || null; + this._ignored = false; + this._contact = null; + this._service = null; + } - this.initialize(this.firstSignal); + public async initializeOutboundCall(contact: CallContact): Promise { + this._role = 'caller'; + this.acceptedLocally = true; + this._contact = contact; + } + + public async initializeRemoteCall(signal: MediaSignal<'new'>): Promise { + if (this.hasRemoteData) { + return; + } + + console.log('call.initializeRemoteCall', signal.callId); + + this.hasRemoteData = true; + this._service = signal.body.service; + this._role = signal.body.role; + + // If it's flagged as ignored even before the initialization, tell the server we're unavailable + if (this.ignored) { + return this.rejectAsUnavailable(); + } + + if (this._service === 'webrtc') { + try { + this.prepareWebRtcProcessor(); + } catch (e) { + await this.rejectAsUnavailable(); + throw e; + } + } + + // Send an ACK so the server knows that this session exists and is reachable + this.acknowledge(); + + await this.processEarlySignals(); } public getRemoteMediaStream(): MediaStream { + if (this.shouldIgnoreWebRTC()) { + throw new Error('getRemoteMediaStream is not available for this service'); + } + + this.prepareWebRtcProcessor(); + return this.webrtcProcessor.getRemoteMediaStream(); } @@ -108,9 +145,22 @@ export class ClientMediaCall implements IClientMediaCall { this.emitter.emit('contactUpdate'); } - public async processSignal(signal: MediaSignal) { + public async processSignal(signal: MediaSignal) { + if (this.isOver()) { + return; + } + console.log('ClientMediaCall.processSignal', signal.type); + if (signal.type === 'new') { + return this.initializeRemoteCall(signal as MediaSignal<'new'>); + } + + if (!this.hasRemoteData) { + this.earlySignals.add(signal); + return; + } + if (!signal.sessionId && signalTypeRequiresTargeting(signal.type)) { console.error(`Received an untargeted ${signal.type} signal.`); return; @@ -134,7 +184,7 @@ export class ClientMediaCall implements IClientMediaCall { throw new Error('call-not-pending-acceptance'); } this.acceptedLocally = true; - this.transporter.answer(this.callId, 'accept'); + this.config.transporter.answer(this.callId, 'accept'); } public async reject(): Promise { @@ -142,38 +192,34 @@ export class ClientMediaCall implements IClientMediaCall { if (!this.isPendingAcceptance()) { throw new Error('call-not-pending-acceptance'); } - this.transporter.answer(this.callId, 'reject'); + this.config.transporter.answer(this.callId, 'reject'); + this.changeState('hangup'); } - public async hangup(): Promise { + public async hangup(reason: CallHangupReason = 'normal'): Promise { console.log('call.hangup'); if (this.endedLocally || this._state === 'hangup') { return; } this.endedLocally = true; - await this.flagAsEnded('normal'); + this.flagAsEnded(reason); } public isPendingAcceptance(): boolean { - return ['none', 'ringing'].includes(this._state); - } - - private initialize(signal: MediaSignal<'new'>): void { - console.log('call.initialize'); - // If it's flagged as ignored even before the initialization, tell the server we're unavailable - if (this.ignored) { - return this.transporter.answer(signal.callId, 'unavailable'); + if (this._role !== 'callee') { + return false; } - // If the call is targeted, assume we already accepted it: - if (signal.sessionId) { - this.acceptedLocally = true; + if (this.acceptedLocally) { + return false; } - this._state = 'ringing'; - // Send an ACK so the server knows that this session exists and is reachable - this.transporter.answer(signal.callId, 'ack'); + return ['none', 'ringing'].includes(this._state); + } + + public isOver(): boolean { + return this.ignored || this._state === 'hangup'; } private changeState(newState: CallState): void { @@ -197,47 +243,71 @@ export class ClientMediaCall implements IClientMediaCall { } } - private async processOfferRequest(signal: MediaSignal<'request-offer'>) { + protected async processOfferRequest(signal: MediaSignal<'request-offer'>) { console.log('call.processOfferRequest'); if (!signal.sessionId) { console.error('Received an untargeted offer request.'); return; } + if (this.shouldIgnoreWebRTC()) { + this.config.transporter.sendError(this.callId, 'invalid-service'); + return; + } + + this.requireWebRTC(); + let offer: MediaSignalSDP | null = null; try { offer = await this.webrtcProcessor.createOffer(signal.body); } catch (e) { - this.transporter.sendError(this.callId, 'failed-to-create-offer'); + this.config.transporter.sendError(this.callId, 'failed-to-create-offer'); throw e; } if (!offer) { - this.transporter.sendError(this.callId, 'implementation-error'); + this.config.transporter.sendError(this.callId, 'implementation-error'); } await this.deliverSdp(offer); } - private async processAnswerRequest(signal: MediaSignal<'sdp'>) { + protected shouldIgnoreWebRTC(): boolean { + // Without the remote data we don't know if the call is using webrtc or not + return this.hasRemoteData && this._service !== 'webrtc'; + } + + protected async processAnswerRequest(signal: MediaSignal<'sdp'>): Promise { console.log('Call.processAnswerRequest'); + if (this.shouldIgnoreWebRTC()) { + return; + } + + this.requireWebRTC(); + let answer: MediaSignalSDP | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal.body); } catch (e) { - this.transporter.sendError(this.callId, 'failed-to-create-answer'); + this.config.transporter.sendError(this.callId, 'failed-to-create-answer'); throw e; } if (!answer) { - this.transporter.sendError(this.callId, 'implementation-error'); + this.config.transporter.sendError(this.callId, 'implementation-error'); } await this.deliverSdp(answer); } - private async processRemoteSDP(signal: MediaSignal<'sdp'>) { + protected async processRemoteSDP(signal: MediaSignal<'sdp'>): Promise { console.log('Call.processRemoteSDP'); + + if (this.shouldIgnoreWebRTC()) { + return; + } + this.requireWebRTC(); + if (signal.body.sdp.type === 'offer') { return this.processAnswerRequest(signal); } @@ -245,10 +315,50 @@ export class ClientMediaCall implements IClientMediaCall { await this.webrtcProcessor.setRemoteDescription(signal.body); } - private async deliverSdp(sdp: MediaSignalSDP) { + protected async deliverSdp(sdp: MediaSignalSDP) { console.log('Call.deliverSdp'); - return this.transporter.sendToServer(this.callId, 'sdp', sdp); + return this.config.transporter.sendToServer(this.callId, 'sdp', sdp); + } + + protected async rejectAsUnavailable(): Promise { + console.log('call.rejectAsUnavailable'); + + // If we have already told the server we accept this call, then we need to send a hangup to get out of it + if (this.acceptedLocally) { + return this.hangup('unavailable'); + } + + this.config.transporter.answer(this.callId, 'unavailable'); + this.changeState('hangup'); + } + + protected async processEarlySignals(): Promise { + console.log('call.processEarlySignals'); + const earlySignals = this.earlySignals.values().toArray(); + this.earlySignals.clear(); + + for await (const signal of earlySignals) { + try { + await this.processSignal(signal); + } catch (e) { + console.error('Error processing early signal', e); + } + } + } + + protected acknowledge(): void { + console.log('call.acknowledge'); + if (this.acknowledged) { + return; + } + + if (this._state === 'none') { + this._state = 'ringing'; + } + + this.acknowledged = true; + this.config.transporter.answer(this.callId, 'ack'); } private async processNotification(signal: MediaSignal<'notification'>) { @@ -268,7 +378,7 @@ export class ClientMediaCall implements IClientMediaCall { console.log('flagAsAccepted'); if (!this.acceptedLocally) { - this.transporter.sendError(this.callId, 'not-accepted'); + this.config.transporter.sendError(this.callId, 'not-accepted'); throw new Error('Trying to activate a call that was not yet accepted locally.'); } @@ -276,15 +386,52 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('accepted'); } - private async flagAsEnded(reasonCode: CallHangupReason): Promise { + private flagAsEnded(reasonCode: CallHangupReason): void { console.log('flagAsEnded'); if (this._state === 'hangup') { return; } - this.transporter.hangup(this.callId, reasonCode); + this.config.transporter.hangup(this.callId, reasonCode); this.changeState('hangup'); } + + private prepareWebRtcProcessor(): asserts this is ClientMediaCallWebRTC { + if (this.webrtcProcessor) { + return; + } + + console.log('session.createWebRtcProcessor'); + + const { + mediaStreamFactory, + processorFactories: { webrtc: webrtcFactory }, + } = this.config; + + if (!webrtcFactory) { + throw new Error('webrtc-not-implemented'); + } + + this.webrtcProcessor = webrtcFactory({ mediaStreamFactory }); + } + + private requireWebRTC(): asserts this is ClientMediaCallWebRTC { + try { + this.prepareWebRtcProcessor(); + } catch (e) { + this.config.transporter.sendError(this.callId, 'webrtc-not-implemented'); + throw e; + } + } +} + +export class ClientMediaCallWebRTC extends ClientMediaCall { + public webrtcProcessor: IWebRTCProcessor; + + constructor(config: IClientMediaCallConfig, callId: string) { + super(config, callId); + throw new Error('ClientMediaCallWebRTC is not meant to be constructed.'); + } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 6df280fdbb7a7..a829ccd03e7bf 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -2,21 +2,19 @@ import { Emitter } from '@rocket.chat/emitter'; import { ClientMediaCall } from './Call'; import { MediaSignalTransportWrapper } from './TransportWrapper'; -import type { IWebRTCProcessor } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; import type { MediaSignal } from '../definition/MediaSignal'; import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; -import { type IClientMediaCall, type CallContact, type CallState, isCallRole } from '../definition/call'; +import type { IClientMediaCall, CallState } from '../definition/call'; const stateWeights: Record = { none: 0, ringing: 1, accepted: 2, - error: 3, - active: 4, - hangup: 0, + active: 3, + hangup: -1, } as const; export type MediaSignalingEvents = { @@ -40,13 +38,8 @@ export class MediaSignalingSession extends Emitter { private knownCalls: Map; - // Store contact information for calls that are not yet known - private contactInformation: Map; - private ignoredCalls: Set; - private failedCalls: Set; - private transporter: MediaSignalTransportWrapper; public get sessionId(): string { @@ -62,9 +55,7 @@ export class MediaSignalingSession extends Emitter { this._userId = config.userId; this._sessionId = createRandomToken(8); this.knownCalls = new Map(); - this.contactInformation = new Map(); this.ignoredCalls = new Set(); - this.failedCalls = new Set(); this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport); } @@ -116,60 +107,18 @@ export class MediaSignalingSession extends Emitter { public async processSignal(signal: MediaSignal): Promise { console.log('session.processSignal', signal.type); - if (this.isSignalTargetingAnotherSession(signal)) { - return; - } - - if (this.isCallIgnored(signal.callId)) { - if (signal.sessionId) { - console.error('Received targeted signal for an ignored call.', signal); - } - - return; - } - - if (signal.type === 'new') { - await this.processNewCall(signal as MediaSignal<'new'>); + if (this.isSignalTargetingAnotherSession(signal) || this.isCallIgnored(signal.callId)) { return; } - const call = this.knownCalls.get(signal.callId); - if (!call) { - if (this.failedCalls.has(signal.callId)) { - // do something? - return; - } - - // #ToDo: Hold on to unexpected untargeted signals for a few seconds and process them if a notifyNew arrives - console.error('Unexpected Signal', signal); - throw new Error('Unexpected Signal received.'); - } - + const call = await this.getOrCreateCall(signal.callId); await call.processSignal(signal); } - public getStoredCallContact(callId: string): CallContact { - return { - ...this.contactInformation.get(callId), - ...this.knownCalls.get(callId)?.contact, - }; - } + public async registerOutboundCall(callId: string, contact: Record): Promise { + const call = await this.getOrCreateCall(callId); - public setCallContact(callId: string, contact: Record): void { - console.log('session.setCallContact'); - const oldContact = this.getStoredCallContact(callId); - const fullContact = { ...oldContact, ...contact }; - - const call = this.knownCalls.get(callId); - if (!call) { - this.contactInformation.set(callId, fullContact); - return; - } - - call.setContact(fullContact); - if (this.contactInformation.has(callId)) { - this.contactInformation.delete(callId); - } + return call.initializeOutboundCall(contact); } private isSignalTargetingAnotherSession(signal: MediaSignal): boolean { @@ -180,65 +129,34 @@ export class MediaSignalingSession extends Emitter { return signal.sessionId !== this._sessionId; } - private isCallKnown(callId: string): boolean { - return this.knownCalls.has(callId); - } - private isCallIgnored(callId: string): boolean { return this.ignoredCalls.has(callId); } - private async processNewCall(signal: MediaSignal<'new'>) { - console.log('session.processNewCall'); - // If we already know about this call, we don't need to process anything - if (this.isCallKnown(signal.callId)) { - return; + private async getOrCreateCall(callId: string): Promise { + const existingCall = this.knownCalls.get(callId); + if (existingCall) { + return existingCall; } - try { - if (!isCallRole(signal.body.role)) { - throw new Error('invalid-role'); - } - const webrtcProcessor = await this.createWebRtcProcessor(); - - // Contact will probaby already be stored if this call was requested by this same session - const contact = this.getStoredCallContact(signal.callId); - const ignored = this.isCallIgnored(signal.callId) || this.isBusy(); - - const config = { - transporter: this.transporter, - webrtcProcessor, - }; - - const call = new ClientMediaCall(config, signal, { contact, ignored }); - this.knownCalls.set(call.callId, call); - - call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); - call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); - call.emitter.on('ended', () => this.emit('endedCall', { call })); - - this.emit('newCall', { call }); - } catch (e) { - this.failedCalls.add(signal.callId); - const errorCode: string = (e && typeof e === 'object' && (e as any).name) || 'call-initialization-failed'; - - this.transporter.sendError(signal.callId, errorCode); - throw e; - } - } + const config = { + transporter: this.transporter, + processorFactories: this.config.processorFactories, + mediaStreamFactory: this.config.mediaStreamFactory, + }; - private async createWebRtcProcessor(): Promise { - console.log('session.createWebRtcProcessor'); + const call = new ClientMediaCall(config, callId); + this.knownCalls.set(callId, call); - const { - mediaStreamFactory, - processorFactories: { webrtc: webrtcFactory }, - } = this.config; + call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); + call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); + call.emitter.on('ended', () => { + this.ignoredCalls.add(call.callId); + this.emit('endedCall', { call }); + }); - if (!webrtcFactory) { - throw new Error('webrtc-not-implemented'); - } + this.emit('newCall', { call }); - return webrtcFactory({ mediaStreamFactory }); + return call; } } From 833987439a88b883a181094204d8700d95a49fbf Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 1 Aug 2025 01:56:43 -0300 Subject: [PATCH 20/72] client side timeouts --- packages/core-services/src/events/Events.ts | 2 +- .../src/definition/call/CallEvents.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 12 +- packages/media-signaling/src/lib/Call.ts | 151 ++++++++++++++++-- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index cd6d449c6ee96..9f58a5b710b51 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -36,10 +36,10 @@ import type { IOTRMessage, MessageAttachment, } from '@rocket.chat/core-typings'; +import type { MediaSignal } from '@rocket.chat/media-signaling'; import type * as UiKit from '@rocket.chat/ui-kit'; import type { AutoUpdateRecord } from '../types/IMeteor'; -import { MediaSignal } from '@rocket.chat/media-signaling'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 4fbfe961180a6..97af795633575 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -6,6 +6,7 @@ export type CallEvents = { // contactUpdate may be triggered even if nothing actually changed contactUpdate: void; + accepting: void; accepted: void; ended: void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index b0b8c715ea4aa..81ed5d9e76f11 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -8,13 +8,19 @@ export type CallRole = 'caller' | 'callee'; export type CallService = 'webrtc'; -export type CallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; +export type CallState = + | 'none' // trying to call with no idea if it'll reach anyone + | 'ringing' // call has been acknoledged by the callee's agent, but no response about them accepting it or not + | 'accepted' // call has been accepted and the webrtc offer is being exchanged + | 'active' // webrtc connection has been established + | 'hangup'; // call is over export type CallHangupReason = | 'normal' // User explicitly hanged up | 'remote' // Server told the client to hang up | 'rejected' // The callee rejected the call | 'unavailable' // The actor is not available + | 'timeout' // The call state hasn't progressed for too long | 'signaling-error' // Hanging up because of an error during the signal processing | 'service-error' // Hanging up because of an error setting up the service connection | 'media-error' // Hanging up because of an error setting up the media connection @@ -40,7 +46,3 @@ export interface IClientMediaCall extends Required { reject(): Promise; hangup(): Promise; } - -export function isCallRole(role: string): role is CallRole { - return ['caller', 'callee'].includes(role); -} diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 09a5fb74c5ec0..dd65f29f3ae44 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -14,6 +14,17 @@ export interface IClientMediaCallConfig { mediaStreamFactory: MediaStreamFactory; } +const TIMEOUT_TO_ACCEPT = 30000; +const TIMEOUT_TO_CONFIRM_ACCEPTANCE = 2000; +const TIMEOUT_TO_PROGRESS_SIGNALING = 10000; + +type ClientState = 'pending' | 'accepting' | 'accepted' | 'has-offer' | 'has-answer' | 'active' | 'hangup'; + +type StateTimeoutHandler = { + state: ClientState; + handler: ReturnType; +}; + export class ClientMediaCall implements IClientMediaCall { public readonly callId: string; @@ -57,11 +68,15 @@ export class ClientMediaCall implements IClientMediaCall { private hasRemoteData: boolean; + private hasLocalDescription: boolean; + + private hasRemoteDescription: boolean; + private acknowledged: boolean; private earlySignals: Set; - // private timeoutHandler?: ReturnType; + private stateTimeoutHandlers: Set; constructor( private readonly config: IClientMediaCallConfig, @@ -77,8 +92,11 @@ export class ClientMediaCall implements IClientMediaCall { this.endedLocally = false; this.hasRemoteData = false; this.acknowledged = false; + this.hasLocalDescription = false; + this.hasRemoteDescription = false; this.earlySignals = new Set(); + this.stateTimeoutHandlers = new Set(); this._role = 'callee'; this._state = 'none'; this._ignored = false; @@ -87,9 +105,17 @@ export class ClientMediaCall implements IClientMediaCall { } public async initializeOutboundCall(contact: CallContact): Promise { - this._role = 'caller'; + if (this.acceptedLocally) { + return; + } + + if (!this.hasRemoteData) { + this._role = 'caller'; + } this.acceptedLocally = true; this._contact = contact; + + this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); } public async initializeRemoteCall(signal: MediaSignal<'new'>): Promise { @@ -120,9 +146,37 @@ export class ClientMediaCall implements IClientMediaCall { // Send an ACK so the server knows that this session exists and is reachable this.acknowledge(); + if (this._role === 'callee' || !this.acceptedLocally) { + this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); + } + await this.processEarlySignals(); } + public getClientState(): ClientState { + switch (this._state) { + case 'none': + case 'ringing': + if (this.hasRemoteData && this._role === 'callee' && this.acceptedLocally) { + return 'accepting'; + } + return 'pending'; + case 'accepted': + if (this.hasLocalDescription && this.hasRemoteDescription) { + return 'has-answer'; + } + if (this.hasLocalDescription !== this.hasRemoteDescription) { + return 'has-offer'; + } + + return 'accepted'; + case 'active': + return 'active'; + case 'hangup': + return 'hangup'; + } + } + public getRemoteMediaStream(): MediaStream { if (this.shouldIgnoreWebRTC()) { throw new Error('getRemoteMediaStream is not available for this service'); @@ -180,18 +234,35 @@ export class ClientMediaCall implements IClientMediaCall { public async accept(): Promise { console.log('call.accept'); - if (!this.isPendingAcceptance()) { + if (!this.isPendingOurAcceptance()) { throw new Error('call-not-pending-acceptance'); } + + if (!this.hasRemoteData) { + throw new Error('missing-remote-data'); + } + this.acceptedLocally = true; this.config.transporter.answer(this.callId, 'accept'); + + if (this.getClientState() === 'accepting') { + this.updateStateTimeouts(); + this.addStateTimeout('accepting', TIMEOUT_TO_CONFIRM_ACCEPTANCE); + + this.emitter.emit('accepting'); + } } public async reject(): Promise { console.log('call.reject'); - if (!this.isPendingAcceptance()) { + if (!this.isPendingOurAcceptance()) { throw new Error('call-not-pending-acceptance'); } + + if (!this.hasRemoteData) { + throw new Error('missing-remote-data'); + } + this.config.transporter.answer(this.callId, 'reject'); this.changeState('hangup'); } @@ -207,6 +278,10 @@ export class ClientMediaCall implements IClientMediaCall { } public isPendingAcceptance(): boolean { + return ['none', 'ringing'].includes(this._state); + } + + public isPendingOurAcceptance(): boolean { if (this._role !== 'callee') { return false; } @@ -215,7 +290,7 @@ export class ClientMediaCall implements IClientMediaCall { return false; } - return ['none', 'ringing'].includes(this._state); + return this.isPendingAcceptance(); } public isOver(): boolean { @@ -231,6 +306,8 @@ export class ClientMediaCall implements IClientMediaCall { const oldState = this._state; this._state = newState; + this.updateStateTimeouts(); + this.emitter.emit('stateChange', oldState); switch (newState) { @@ -295,8 +372,10 @@ export class ClientMediaCall implements IClientMediaCall { if (!answer) { this.config.transporter.sendError(this.callId, 'implementation-error'); + return; } + this.hasRemoteDescription = true; await this.deliverSdp(answer); } @@ -313,10 +392,12 @@ export class ClientMediaCall implements IClientMediaCall { } await this.webrtcProcessor.setRemoteDescription(signal.body); + this.hasRemoteDescription = true; } protected async deliverSdp(sdp: MediaSignalSDP) { console.log('Call.deliverSdp'); + this.hasLocalDescription = true; return this.config.transporter.sendToServer(this.callId, 'sdp', sdp); } @@ -353,12 +434,12 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (this._state === 'none') { - this._state = 'ringing'; - } - this.acknowledged = true; this.config.transporter.answer(this.callId, 'ack'); + + if (this._state === 'none') { + this.changeState('ringing'); + } } private async processNotification(signal: MediaSignal<'notification'>) { @@ -378,26 +459,74 @@ export class ClientMediaCall implements IClientMediaCall { console.log('flagAsAccepted'); if (!this.acceptedLocally) { + // #ToDo: test this situation; remove exception, read this response on the server this.config.transporter.sendError(this.callId, 'not-accepted'); throw new Error('Trying to activate a call that was not yet accepted locally.'); } // Both sides of the call have accepted it, we can change the state now this.changeState('accepted'); + + this.addStateTimeout('accepted', TIMEOUT_TO_PROGRESS_SIGNALING); + this.addStateTimeout('has-offer', TIMEOUT_TO_PROGRESS_SIGNALING); } - private flagAsEnded(reasonCode: CallHangupReason): void { + private flagAsEnded(reason: CallHangupReason): void { console.log('flagAsEnded'); if (this._state === 'hangup') { return; } - this.config.transporter.hangup(this.callId, reasonCode); + this.config.transporter.hangup(this.callId, reason); this.changeState('hangup'); } + private addStateTimeout(state: ClientState, timeout: number, callback?: () => void): void { + if (this.getClientState() !== state) { + return; + } + + console.log(`adding a timeout of ${timeout / 1000} seconds to the state [${state}]`); + + const handler = { + state, + handler: setTimeout(() => { + if (this.stateTimeoutHandlers.has(handler)) { + this.stateTimeoutHandlers.delete(handler); + } + + if (state !== this.getClientState()) { + return; + } + + console.log(`reached timeout for the [${state}] state.`); + + if (callback) { + callback(); + } else { + void this.hangup('timeout'); + } + }, timeout), + }; + + this.stateTimeoutHandlers.add(handler); + } + + private updateStateTimeouts(): void { + const clientState = this.getClientState(); + + for (const handler of this.stateTimeoutHandlers.values()) { + if (handler.state === clientState) { + continue; + } + + clearTimeout(handler.handler); + this.stateTimeoutHandlers.delete(handler); + } + } + private prepareWebRtcProcessor(): asserts this is ClientMediaCallWebRTC { if (this.webrtcProcessor) { return; From eb4a7b71dc6eae53f23b9fae6f76936645276be3 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 6 Aug 2025 18:21:31 -0300 Subject: [PATCH 21/72] handle actor signals through agents and contracts --- apps/meteor/app/api/server/v1/mediacalls.ts | 5 +- .../server/services/media-call/service.ts | 31 +-- .../src/types/IMediaCallService.ts | 9 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 44 +-- .../src/mediaCalls/IMediaCallChannel.ts | 41 +-- .../src/agents/Manager.ts | 257 ++++++++++++++++++ .../src/agents/definition/IMediaCallAgent.ts | 84 ++++++ .../src/agents/users/Agent.ts | 64 +++++ .../src/agents/users/AgentFactory.ts | 70 +++++ .../src/agents/users/BasicAgent.ts | 48 ++++ .../src/agents/users/NewCallAgent.ts | 31 +++ .../src/agents/users/SignalProcessor.ts | 106 ++++++++ .../src/calls/acknowledgeCallee.ts | 14 - .../src/calls/acknowledgeCaller.ts | 15 - .../src/calls/createCall.ts | 60 ---- .../src/calls/processAcceptedCall.ts | 54 ---- .../src/calls/processEndedCall.ts | 12 - .../src/channels/createInitialChannel.ts | 35 --- .../src/channels/deliverChannelSDP.ts | 14 - .../src/channels/getActorChannel.ts | 15 - .../src/channels/getChannelByCallIdAndRole.ts | 21 -- .../src/channels/getOppositeChannel.ts | 27 -- .../src/channels/getRoleForActor.ts | 16 -- .../src/channels/requestChannelOffer.ts | 19 -- .../media-signaling-server/src/createCall.ts | 48 ++++ packages/media-signaling-server/src/index.ts | 8 +- .../src/{utils => }/logger.ts | 0 .../src/processSignal.ts | 27 ++ .../src/{signals => }/signalHandler.ts | 6 +- .../src/signals/getChannelForSignal.ts | 22 -- .../src/signals/isValidSignalChannel.ts | 5 - .../src/signals/isValidSignalRole.ts | 6 - .../src/signals/processAnswer.ts | 67 ----- .../src/signals/processHangup.ts | 21 -- .../src/signals/processSDP.ts | 20 -- .../src/signals/processSignal.ts | 72 ----- .../src/signals/sendNewSignals.ts | 42 --- .../src/signals/sendSignalToActor.ts | 20 -- .../src/signals/sendSignalToAllActors.ts | 57 ---- .../src/signals/sendSignalToChannel.ts | 23 -- .../src/signals/sendSignalToChannels.ts | 11 - .../src/signals/validateChannelForSignals.ts | 10 - .../src/utils/assertSignalHasSessionId.ts | 9 - .../src/utils/compareActorsIgnoringSession.ts | 5 - .../src/utils/compareParticipantAndActor.ts | 15 - .../src/utils/mapUserToContactInformation.ts | 13 - .../src/definition/IWebRTCProcessor.ts | 7 +- .../src/definition/MediaSignal.ts | 77 ++++-- .../src/definition/MediaSignalTransport.ts | 6 +- .../src/definition/call/CallEvents.ts | 2 + .../src/definition/call/IClientMediaCall.ts | 14 +- packages/media-signaling/src/lib/Call.ts | 100 ++++--- packages/media-signaling/src/lib/Session.ts | 87 +++--- .../src/lib/TransportWrapper.ts | 41 +-- .../src/lib/services/webrtc/Processor.ts | 10 +- .../src/lib/utils/mergeContacts.ts | 27 ++ .../lib/utils/signalTypeRequiresTargeting.ts | 5 - .../src/models/IMediaCallChannelsModel.ts | 4 +- .../src/models/IMediaCallsModel.ts | 7 +- .../models/src/models/MediaCallChannels.ts | 24 +- packages/models/src/models/MediaCalls.ts | 61 +---- .../v1/media-calls/MediaCallsSignalProps.ts | 167 +++--------- .../v1/media-calls/MediaCallsStartProps.ts | 8 +- 63 files changed, 1073 insertions(+), 1173 deletions(-) create mode 100644 packages/media-signaling-server/src/agents/Manager.ts create mode 100644 packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts create mode 100644 packages/media-signaling-server/src/agents/users/Agent.ts create mode 100644 packages/media-signaling-server/src/agents/users/AgentFactory.ts create mode 100644 packages/media-signaling-server/src/agents/users/BasicAgent.ts create mode 100644 packages/media-signaling-server/src/agents/users/NewCallAgent.ts create mode 100644 packages/media-signaling-server/src/agents/users/SignalProcessor.ts delete mode 100644 packages/media-signaling-server/src/calls/acknowledgeCallee.ts delete mode 100644 packages/media-signaling-server/src/calls/acknowledgeCaller.ts delete mode 100644 packages/media-signaling-server/src/calls/createCall.ts delete mode 100644 packages/media-signaling-server/src/calls/processAcceptedCall.ts delete mode 100644 packages/media-signaling-server/src/calls/processEndedCall.ts delete mode 100644 packages/media-signaling-server/src/channels/createInitialChannel.ts delete mode 100644 packages/media-signaling-server/src/channels/deliverChannelSDP.ts delete mode 100644 packages/media-signaling-server/src/channels/getActorChannel.ts delete mode 100644 packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts delete mode 100644 packages/media-signaling-server/src/channels/getOppositeChannel.ts delete mode 100644 packages/media-signaling-server/src/channels/getRoleForActor.ts delete mode 100644 packages/media-signaling-server/src/channels/requestChannelOffer.ts create mode 100644 packages/media-signaling-server/src/createCall.ts rename packages/media-signaling-server/src/{utils => }/logger.ts (100%) create mode 100644 packages/media-signaling-server/src/processSignal.ts rename packages/media-signaling-server/src/{signals => }/signalHandler.ts (52%) delete mode 100644 packages/media-signaling-server/src/signals/getChannelForSignal.ts delete mode 100644 packages/media-signaling-server/src/signals/isValidSignalChannel.ts delete mode 100644 packages/media-signaling-server/src/signals/isValidSignalRole.ts delete mode 100644 packages/media-signaling-server/src/signals/processAnswer.ts delete mode 100644 packages/media-signaling-server/src/signals/processHangup.ts delete mode 100644 packages/media-signaling-server/src/signals/processSDP.ts delete mode 100644 packages/media-signaling-server/src/signals/processSignal.ts delete mode 100644 packages/media-signaling-server/src/signals/sendNewSignals.ts delete mode 100644 packages/media-signaling-server/src/signals/sendSignalToActor.ts delete mode 100644 packages/media-signaling-server/src/signals/sendSignalToAllActors.ts delete mode 100644 packages/media-signaling-server/src/signals/sendSignalToChannel.ts delete mode 100644 packages/media-signaling-server/src/signals/sendSignalToChannels.ts delete mode 100644 packages/media-signaling-server/src/signals/validateChannelForSignals.ts delete mode 100644 packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts delete mode 100644 packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts delete mode 100644 packages/media-signaling-server/src/utils/compareParticipantAndActor.ts delete mode 100644 packages/media-signaling-server/src/utils/mapUserToContactInformation.ts create mode 100644 packages/media-signaling/src/lib/utils/mergeContacts.ts delete mode 100644 packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts diff --git a/apps/meteor/app/api/server/v1/mediacalls.ts b/apps/meteor/app/api/server/v1/mediacalls.ts index e8cb5c68f64c9..f8bd2c88eb0b8 100644 --- a/apps/meteor/app/api/server/v1/mediacalls.ts +++ b/apps/meteor/app/api/server/v1/mediacalls.ts @@ -9,7 +9,7 @@ API.v1.addRoute( { authRequired: true, validateParams: isMediaCallsStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, { async post() { - const { sessionId, identifier, identifierKind } = this.bodyParams; + const { contractId, identifier, identifierKind } = this.bodyParams; const { userId } = this; if (!userId) { @@ -22,10 +22,9 @@ API.v1.addRoute( // return API.v1.forbidden(); // } - const caller = { uid: userId, sessionId }; + const caller = { uid: userId, contractId }; const functions: Record Promise> = { - room: () => MediaCall.callRoom(caller, identifier), user: () => MediaCall.callUser(caller, identifier), extension: () => MediaCall.callExtension(caller, identifier), }; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 8ff05c37a4272..477e1ac6986bf 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,8 +1,8 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; -import type { IUser, IMediaCall, IRoom } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; +import type { MediaSignal, AgentMediaSignal } from '@rocket.chat/media-signaling'; import { processSignal, createCall, setSignalHandler } from '@rocket.chat/media-signaling-server'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; @@ -12,11 +12,11 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall setSignalHandler(this.sendSignal.bind(this)); } - public async processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { + public async processSignal(signal: AgentMediaSignal, uid: IUser['_id']): Promise { return processSignal(signal, uid); } - public async createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise { + public async createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise { const user = await Users.findOneActiveById(callee.uid, { projection: { _id: 1 } }); if (!user) { throw new Error('invalid-user'); @@ -26,26 +26,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall { type: 'user', id: caller.uid, - sessionId: caller.sessionId, + contractId: caller.contractId, }, { type: 'user', id: callee.uid }, ); } - public async callRoom(caller: { uid: IUser['_id']; sessionId: string }, roomId: IRoom['_id']): Promise { - const room = await Rooms.findOneById>(roomId, { projection: { t: 1, uids: 1 } }); - if (room?.t !== 'd') { - throw new Error('invalid-room'); - } - const calleeId = room.uids?.filter((uid) => uid !== caller.uid).pop(); - if (!calleeId) { - throw new Error('invalid-room'); - } - - return this.createInternalCall(caller, { uid: calleeId }); - } - - public async callExtension(caller: { uid: IUser['_id']; sessionId: string }, extension: string): Promise { + public async callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise { const user = await Users.findOneByFreeSwitchExtension(extension, { projection: { _id: 1 } }); if (!user) { throw new Error('invalid-user'); @@ -55,13 +42,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall { type: 'user', id: caller.uid, - sessionId: caller.sessionId, + contractId: caller.contractId, }, { type: 'user', id: user._id }, ); } - public async callUser(caller: { uid: IUser['_id']; sessionId: string }, userId: IUser['_id']): Promise { + public async callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise { // For now every call is between two users return this.createInternalCall(caller, { uid: userId }); } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 3645977f83de6..4c746ecc1515e 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,10 +1,9 @@ -import type { IMediaCall, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; import type { MediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(signal: MediaSignal, fromUid: IUser['_id']): Promise; - createInternalCall(caller: { uid: IUser['_id']; sessionId: string }, callee: { uid: IUser['_id'] }): Promise; - callRoom(caller: { uid: IUser['_id']; sessionId: string }, roomId: IRoom['_id']): Promise; - callExtension(caller: { uid: IUser['_id']; sessionId: string }, extension: string): Promise; - callUser(caller: { uid: IUser['_id']; sessionId: string }, userId: IUser['_id']): Promise; + createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; + callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; + callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 6be1d21c57f05..3fbd2314c64a7 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -1,28 +1,34 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; -import type { IUser } from '../IUser'; - -export type MediaCallActor = - | { - type: 'user'; - id: IUser['_id']; - sessionId?: string; - } - | { - type: 'sip'; - id: string; - } - | { - type: 'server'; - id: 'server'; - }; +import type { RequiredField } from '../utils'; + +export type MediaCallActorType = 'user' | 'sip'; + +export type MediaCallActor = { + type: T; + id: string; + contractId?: string; +}; + +export type MediaCallSignedActor = RequiredField; + +export type ServerActor = { + type: 'server'; + id: 'server'; + contractId: never; +}; export type MediaCallContactInformation = { displayName?: string; username?: string; sipExtension?: string; + + // avatarUrl?: string; + // host?: string; }; -export type MediaCallContact = MediaCallActor & MediaCallContactInformation; +export type MediaCallContact = T & MediaCallContactInformation; + +export type MediaCallSignedContact = MediaCallContact; export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; @@ -33,10 +39,10 @@ export interface IMediaCall extends IRocketChatRecord { createdBy: MediaCallActor; createdAt: Date; - caller: MediaCallContact; + caller: MediaCallSignedContact; callee: MediaCallContact; - endedBy?: MediaCallActor; + endedBy?: MediaCallActor | ServerActor; endedAt?: Date; hangupReason?: string; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index db5ee24ff9c49..b6b09b5e441b4 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -1,38 +1,16 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; -import type { IUser } from '../IUser'; +import type { MediaCallActorType, MediaCallContact } from './IMediaCall'; -export type MediaCallParticipantIdentification = { - type: 'user' | 'sip'; - id: string; - sessionId?: string; -}; - -export type MediaCallChannelUserRC = MediaCallParticipantIdentification & { - type: 'user'; - id: IUser['_id']; - username: string; - displayName?: string; - - sessionId: string; -}; - -export type MediaCallChannelUserSIP = MediaCallParticipantIdentification & { - type: 'sip'; - id: string; - sessionId?: never; +export interface IMediaCallChannel extends IRocketChatRecord { + callId: string; - displayName?: string; - // The SIP username usually matches the extension number - username: string; - host: string; -}; + contractId: string; -export type MediaCallParticipant = MediaCallChannelUserRC | MediaCallChannelUserSIP; + actorType: MediaCallActorType; -export interface IMediaCallChannel extends IRocketChatRecord { - callId: string; + actorId: string; - participant: MediaCallParticipant; + contact: Partial; role: 'caller' | 'callee'; @@ -49,8 +27,3 @@ export interface IMediaCallChannel extends IRocketChatRecord { acknowledged: boolean; } - -export type ValidSignalChannel = IMediaCallChannel & { - // role: 'caller' | 'callee'; - participant: MediaCallChannelUserRC; -}; diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/packages/media-signaling-server/src/agents/Manager.ts new file mode 100644 index 0000000000000..80ff314e24155 --- /dev/null +++ b/packages/media-signaling-server/src/agents/Manager.ts @@ -0,0 +1,257 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; +import type { CallHangupReason, CallRole, CallNotification } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; + +import type { IMediaCallAgent, IMediaCallAgentFactory, IMediaCallBasicAgent, INewMediaCallAgent } from './definition/IMediaCallAgent'; +import { UserAgentFactory } from './users/AgentFactory'; + +type FactoryFn = () => Promise; + +class MediaCallAgentManager { + public async getAgentFactoryForActor(actor: MediaCallActor): Promise { + console.log('AgentManager.getAgentFactoryForActor'); + if (actor.type === 'user') { + return UserAgentFactory.getAgentFactoryForActor(actor); + } + + if (actor.type === 'sip') { + return null; + } + + return null; + } + + public async getNewAgentForActor(actor: MediaCallActor, role: CallRole): Promise { + return this.getNewAgent(() => this.getAgentFactoryForActor(actor), role); + } + + public async getAgentForCallActor(call: IMediaCall, actor: MediaCallActor): Promise { + return this.getCallAgent(() => this.getAgentFactoryForActor(actor), call); + } + + public getRoleForCallActor(call: IMediaCall, actor: Pick): CallRole | null { + if (call.callee.type === actor.type && call.callee.id === actor.id) { + return 'callee'; + } + + if (call.caller.type === actor.type && call.caller.id === actor.id) { + return 'caller'; + } + + return null; + } + + public async getOrCreateContract( + callId: string, + agent: IMediaCallBasicAgent, + params?: Pick, + ): Promise { + console.log('AgentManager.getOrCreateContract'); + const { role, actorType, actorId, contractId } = agent; + if (!contractId) { + throw new Error('error-invalid-contract'); + } + + const contact = await agent.getContactInfo(); + + const newChannel: InsertionModel = { + callId, + state: 'none', + role, + acknowledged: Boolean(params?.acknowledged) ?? false, + contact, + contractId, + actorType, + actorId, + }; + + // Create this channel if it doesn't yet exist, or update ack if it does and needs it + const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); + if (!insertedChannel) { + throw new Error('failed-to-insert-channel'); + } + + // This shouldn't be possible unless something tried to switch the roles of the call's actors + if (insertedChannel.role !== role) { + throw new Error('invalid-channel-data'); + } + + return insertedChannel; + } + + public async hangupCall(agent: IMediaCallAgent, reason: CallHangupReason): Promise { + console.log('AgentManager.hangupCall'); + const endedBy = agent.actor; + + const stateResult = await MediaCalls.hangupCallById(agent.callId, { endedBy, reason }); + if (!stateResult.modifiedCount) { + return; + } + + agent.notify(agent.callId, 'hangup'); + const otherAgent = await this.getAnyOppositeAgent(agent); + // #ToDo: log something if the otherAgent is not found + if (otherAgent) { + otherAgent.notify(agent.callId, 'hangup'); + } + } + + public async acknowledgeCallee(agent: IMediaCallAgent): Promise { + console.log('AgentManager.acknowledgeCallee'); + if (agent.role !== 'callee') { + return; + } + + await MediaCalls.startRingingById(agent.callId); + } + + public async acceptCall(agent: IMediaCallAgent): Promise { + console.log('AgentManager.acceptCall'); + if (agent.role !== 'callee') { + return; + } + + const stateResult = await MediaCalls.acceptCallById(agent.callId, agent.contractId); + // If nothing changed, the call was no longer ringing + if (!stateResult.modifiedCount) { + // Notify something? + return; + } + + // #ToDo: notify client if this throws any error + return this.processAcceptedCall(agent); + } + + public async setLocalDescription(agent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + console.log('agentManager.setLocalDescription'); + const otherAgent = await this.getOppositeAgent(agent); + if (!otherAgent) { + console.log('otherAgent not found'); + return; + } + + otherAgent.setRemoteDescription(sdp); + } + + private async getOppositeAgentFactory(call: IMediaCall, agent: IMediaCallAgent): Promise { + console.log('AgentManager.getOppositeAgentFactory'); + switch (agent.role) { + case 'caller': + return this.getAgentFactoryForActor(call.callee); + case 'callee': + return this.getAgentFactoryForActor(call.caller); + default: + return null; + } + } + + // This will only return an agent if a contract for the other role has already been signed + private async getOppositeAgent(agent: IMediaCallAgent): Promise { + console.log('AgentManager.getOppositeAgent'); + const call = await MediaCalls.findOneById(agent.callId); + if (!call) { + return null; + } + + return this.getCallAgent(() => this.getOppositeAgentFactory(call, agent), call); + } + + private async getAnyOppositeAgent(agent: IMediaCallAgent): Promise { + console.log('AgentManager.getAnyOppositeAgent'); + const call = await MediaCalls.findOneById(agent.callId); + if (!call) { + return null; + } + + const factory = await this.getOppositeAgentFactory(call, agent); + if (!factory) { + return null; + } + + return factory.getCallAgent(call) || factory.getNewAgent(agent.oppositeRole); + } + + private async hangupByServer(callId: string, serverErrorCode: string, agentsToNotify?: IMediaCallBasicAgent[]): Promise { + console.log('AgentManager.hangupByServer'); + // , errorType?: 'signaling' | 'service' | 'media' + // const hangupReason = errorType ? `${errorType}-error` : 'error'; + + const endedBy = { type: 'server', id: 'server' } as unknown as MediaCallActor; + + const stateResult = await MediaCalls.hangupCallById(callId, { endedBy, reason: serverErrorCode }); + const result = Boolean(stateResult.modifiedCount); + + if (result && agentsToNotify) { + await Promise.allSettled(agentsToNotify.map((agent) => agent.notify(callId, 'hangup'))); + } + + return result; + } + + private hangupAndThrow(callId: string, error: string, agents?: IMediaCallBasicAgent[]): never { + console.log('AgentManager.hangupAndThrow'); + this.shieldPromise(this.hangupByServer(callId, error, agents)); + + throw new Error(error); + } + + private shieldPromise(promiseOrFn: Promise | (() => Promise)): void { + const promise = typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn; + void promise.catch(() => null); + } + + private async notifyAll(agents: IMediaCallBasicAgent[], callId: string, notification: CallNotification): Promise { + console.log('AgentManager.notifyAll'); + await Promise.all(agents.map((agent) => agent.notify(callId, notification))); + } + + private async processAcceptedCall(calleeAgent: IMediaCallAgent): Promise { + console.log('AgentManager.processAcceptedCall'); + const { callId } = calleeAgent; + + const call = await MediaCalls.findOneById(callId); + if (!call) { + throw new Error('error-failed-to-accept-call'); + } + + const callerAgent = await this.getAgentForCallActor(call, call.caller); + if (!callerAgent) { + this.hangupAndThrow(callId, 'unable-to-identify-caller-agent', [calleeAgent]); + } + + // We send the accepted notification immediately to report on the call state + await this.notifyAll([calleeAgent, callerAgent], callId, 'accepted'); + + const offer = await callerAgent.getLocalDescription(); + if (!offer) { + await callerAgent.requestOffer({}); + return; + } + + // #ToDo: handle cases where an actor's service role doesn't match the call role + if (offer.type !== 'offer') { + console.log('caller sdp is not an offer'); + // logger.error({ msg: 'The local description of a caller channel is not an offer', local: offer }); + throw new Error('unexpected-state'); + } + + calleeAgent.setRemoteDescription(offer); + } + + private async getNewAgent( + factoryFn: FactoryFn, + ...params: Parameters + ): Promise { + return (await factoryFn())?.getNewAgent(...params) ?? null; + } + + private async getCallAgent( + factoryFn: FactoryFn, + ...params: Parameters + ): Promise { + return (await factoryFn())?.getCallAgent(...params) ?? null; + } +} + +export const agentManager = new MediaCallAgentManager(); diff --git a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts new file mode 100644 index 0000000000000..71cc280fc8415 --- /dev/null +++ b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts @@ -0,0 +1,84 @@ +import type { IMediaCall, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { CallContact, CallRole, CallNotification } from '@rocket.chat/media-signaling'; + +// The basic agent may be instantiated to send notifications to actors with no need for a contract +export interface IMediaCallBasicAgent { + readonly actorType: MediaCallActorType; + readonly actorId: string; + // on new calls, contractId will only be defined for the caller; + // the callee only has a contractId when the agent is instantiated for a specific contract or when a callee contract is signed. + readonly contractId?: string; + readonly role: CallRole; + readonly oppositeRole: CallRole; + + isRepresentingActor(actor: MediaCallActor): boolean; + getContactInfo(): Promise; + notify(callId: string, notification: CallNotification): Promise; +} + +// The New Call Agent may be instantiated without a call; it is used to start new calls +export interface INewMediaCallAgent extends IMediaCallBasicAgent { + onNewCall(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise; +} + +// The full agent will always be instantiated for a specific call and contract; it is used to manage the call state +export interface IMediaCallAgent extends IMediaCallBasicAgent { + readonly callId: string; + readonly contractId: string; + readonly signed: boolean; + + readonly actor: Required; + + setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise; + getLocalDescription(): Promise; + requestOffer(params: { iceRestart?: boolean }): Promise; +} + +// The agent factory is an object with functions to instantiate the two agent types +export interface IMediaCallAgentFactory { + // getNewAgent may never return null; if the agent can't handle a call, the whole factory should be null instead. + getNewAgent(role: CallRole): INewMediaCallAgent; + // getCallAgent may return null if the actor is not part of the call, if it doesn't have a contract, or if a different contract is already signed + getCallAgent(call: IMediaCall): IMediaCallAgent | null; +} + +export abstract class MediaCallBasicAgent implements IMediaCallBasicAgent { + public readonly actorType: T['actorType']; + + public readonly actorId: T['actorId']; + + public readonly contractId: T['contractId']; + + public readonly role: T['role']; + + public get actor(): { type: T['actorType']; id: T['actorId']; contractId: T['contractId'] } { + return { + type: 'user', + id: this.actorId, + contractId: this.contractId, + }; + } + + public get oppositeRole(): T['oppositeRole'] { + return ({ callee: 'caller', caller: 'callee' } as const)[this.role]; + } + + constructor(data: MediaCallActor & { role: CallRole }) { + this.actorType = data.type; + this.actorId = data.id; + this.role = data.role; + this.contractId = data.contractId; + } + + public isRepresentingActor(actor: MediaCallActor): boolean { + return actor.type === this.actorType && actor.id === this.actorId; + } + + public isRepresentingContract(actor: MediaCallActor, contractId: string): boolean { + return this.isRepresentingActor(actor) && this.contractId === contractId; + } + + public abstract getContactInfo(): Promise; + + public abstract notify(callId: string, notification: CallNotification): Promise; +} diff --git a/packages/media-signaling-server/src/agents/users/Agent.ts b/packages/media-signaling-server/src/agents/users/Agent.ts new file mode 100644 index 0000000000000..bf2d5e142a731 --- /dev/null +++ b/packages/media-signaling-server/src/agents/users/Agent.ts @@ -0,0 +1,64 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { AgentMediaSignal, CallRole } from '@rocket.chat/media-signaling'; + +import { UserBasicAgent, type MinimalUserData } from './BasicAgent'; +import { UserAgentSignalProcessor } from './SignalProcessor'; +import { agentManager } from '../Manager'; +import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; + +export class UserMediaCallAgent extends UserBasicAgent implements IMediaCallAgent { + public readonly callId: string; + + public readonly contractId: string; + + protected _signed: boolean; + + protected signalProcessor: UserAgentSignalProcessor; + + public get signed(): boolean { + return this._signed; + } + + constructor(user: MinimalUserData, data: { role: CallRole; callId: string; contractId: string; contractSigned?: boolean }) { + const { callId, contractSigned, ...params } = data; + super(user, params); + this.contractId = data.contractId; + this.callId = callId; + this._signed = contractSigned ?? false; + + this.signalProcessor = new UserAgentSignalProcessor(this); + } + + public async processSignal(signal: AgentMediaSignal, call: IMediaCall): Promise { + return this.signalProcessor.processSignal(signal, call); + } + + public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { + console.log('UserAgent.setRemoteDescription'); + await this.sendSignal({ + callId: this.callId, + contractId: this.contractId, + type: 'sdp', + sdp, + }); + } + + public async getLocalDescription(): Promise { + console.log('UserAgent.getRemoteDescription'); + const channel = await agentManager.getOrCreateContract(this.callId, this); + + return channel?.localDescription ?? null; + } + + public async requestOffer(params: { iceRestart?: boolean }): Promise { + console.log('UserAgent.requestOffer'); + // #ToDo: this function may be called multiple times for the same call until an offer is provided; look into how to handle that + + await this.sendSignal({ + callId: this.callId, + contractId: this.contractId, + type: 'request-offer', + ...params, + }); + } +} diff --git a/packages/media-signaling-server/src/agents/users/AgentFactory.ts b/packages/media-signaling-server/src/agents/users/AgentFactory.ts new file mode 100644 index 0000000000000..d7c8e3fd5d9b3 --- /dev/null +++ b/packages/media-signaling-server/src/agents/users/AgentFactory.ts @@ -0,0 +1,70 @@ +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; +import { Users } from '@rocket.chat/models'; + +import type { MinimalUserData } from './BasicAgent'; +import { agentManager } from '../Manager'; +import { UserMediaCallAgent } from './Agent'; +import { UserNewCallAgent } from './NewCallAgent'; +import type { IMediaCallAgentFactory } from '../definition/IMediaCallAgent'; + +// Overriding the interface to use the same factory for the AgentManager class and the processSignal function +export interface IUserAgentFactory extends IMediaCallAgentFactory { + getNewAgent(role: CallRole): UserNewCallAgent; + getCallAgent(call: IMediaCall): UserMediaCallAgent | null; +} + +// Agent that handles all 'user' actors +export class UserAgentFactory { + public static async getAgentFactoryForUser(userId: string, contractId?: string): Promise { + const user = await Users.findOneActiveById(userId, { + projection: { username: 1, name: 1, freeSwitchExtension: 1 }, + }); + + if (!user?.username) { + console.log('invalid user or no username'); + return null; + } + + const userData = user as MinimalUserData; + + return { + getNewAgent(role: CallRole) { + return new UserNewCallAgent(userData, { role, contractId }); + }, + getCallAgent(call: IMediaCall) { + const { _id: callId } = call; + + if (!contractId) { + console.log('no contractId'); + return null; + } + + const role = agentManager.getRoleForCallActor(call, { type: 'user', id: userId }); + if (!role) { + console.log('no role'); + return null; + } + + const { [role]: callActor } = call; + const contractSigned = Boolean(callActor.contractId); + + // If the role is already signed with a different contract, do not create the agent + if (contractSigned && callActor.contractId !== contractId) { + console.log('signed by another contract'); + return null; + } + + return new UserMediaCallAgent(userData, { role, callId, contractId, contractSigned }); + }, + }; + } + + public static async getAgentFactoryForActor(actor: MediaCallActor): Promise { + if (actor.type !== 'user') { + return null; + } + + return this.getAgentFactoryForUser(actor.id, actor.contractId); + } +} diff --git a/packages/media-signaling-server/src/agents/users/BasicAgent.ts b/packages/media-signaling-server/src/agents/users/BasicAgent.ts new file mode 100644 index 0000000000000..a229480efe606 --- /dev/null +++ b/packages/media-signaling-server/src/agents/users/BasicAgent.ts @@ -0,0 +1,48 @@ +import type { IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { CallContact, CallRole, ServerMediaSignal, CallNotification } from '@rocket.chat/media-signaling'; + +import { sendSignal } from '../../signalHandler'; +import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; +import { MediaCallBasicAgent } from '../definition/IMediaCallAgent'; + +export type MinimalUserData = Pick & Pick, 'username'>; + +export class UserBasicAgent extends MediaCallBasicAgent { + constructor( + protected readonly user: MinimalUserData, + data: { role: CallRole; contractId?: string }, + ) { + super({ type: 'user', id: user._id, ...data }); + } + + public isRepresentingActor(actor: MediaCallActor): actor is MediaCallActor<'user'> { + return super.isRepresentingActor(actor); + } + + public async getContactInfo(): Promise { + const { _id: id, username, name: displayName, freeSwitchExtension: sipExtension } = this.user; + + return { + type: 'user', + id, + username, + displayName, + sipExtension, + // avatarUrl, + // host + }; + } + + public async notify(callId: string, notification: CallNotification): Promise { + console.log('UserBasicAgent.notify'); + return this.sendSignal({ + callId, + type: 'notification', + notification, + }); + } + + protected async sendSignal(signal: ServerMediaSignal): Promise { + sendSignal(this.user._id, signal); + } +} diff --git a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts new file mode 100644 index 0000000000000..e82b004d986ef --- /dev/null +++ b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts @@ -0,0 +1,31 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +import { UserBasicAgent } from './BasicAgent'; +import { agentManager } from '../Manager'; +import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; + +export class UserNewCallAgent extends UserBasicAgent implements INewMediaCallAgent { + public async onNewCall(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { + console.log('UserNewCallAgent.onNewCall'); + // If we have a contract id, ensure the contract is registered + if (this.contractId) { + await agentManager.getOrCreateContract(call._id, this); + } + + await this.sendNewSignal(call, otherAgent); + } + + private async sendNewSignal(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { + console.log('UserNewCallAgent.sendNewSignal'); + const contact = await otherAgent.getContactInfo(); + + return this.sendSignal({ + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: this.role, + contact, + }); + } +} diff --git a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts new file mode 100644 index 0000000000000..f88e42c9b32e9 --- /dev/null +++ b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts @@ -0,0 +1,106 @@ +import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { AgentMediaSignal, MediaSignalAnswer } from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import type { UserMediaCallAgent } from './Agent'; +import { agentManager } from '../Manager'; + +type ChannelFunctionParams = { + call: IMediaCall; + channel: IMediaCallChannel; +}; + +export class UserAgentSignalProcessor { + constructor(private readonly agent: UserMediaCallAgent) {} + + public async processSignal(signal: AgentMediaSignal, call: IMediaCall): Promise { + console.log('SignalProcessor.processSignal'); + + const channel = await agentManager.getOrCreateContract(call._id, this.agent, { acknowledged: true }); + if (!channel) { + throw new Error('invalid-call'); + } + + // The code will only reach this point if one of the three following conditions are true: + // 1. the signal came from the exact user session where the caller initiated the call + // 2. the signal came from the exact user session where the callee accepted the call + // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee + + const params = { channel, call }; + + switch (signal.type) { + case 'sdp': + await this.saveLocalDescription(params, signal.sdp); + break; + case 'error': + // #ToDo + break; + case 'answer': + await this.processAnswer(params, signal.answer); + break; + case 'hangup': + await agentManager.hangupCall(this.agent, signal.reason); + break; + } + } + + private async saveLocalDescription({ channel }: ChannelFunctionParams, sdp: RTCSessionDescriptionInit): Promise { + console.log('SignalProcessor.saveLocalDescription'); + await MediaCallChannels.setLocalDescription(channel._id, sdp); + + await agentManager.setLocalDescription(this.agent, sdp); + } + + private async processAnswer(params: ChannelFunctionParams, answer: MediaSignalAnswer['answer']): Promise { + console.log('processAnswer'); + const { call } = params; + + switch (answer) { + case 'ack': + return this.processACK(params); + case 'accept': + return agentManager.acceptCall(this.agent); + case 'unavailable': + // return processUnavailable(call, channel); + break; + case 'reject': + return this.processReject(call); + } + } + + private async processReject(call: IMediaCall): Promise { + console.log('SignalProcessor.processReject'); + if (this.agent.role !== 'callee') { + return; + } + + if (!['none', 'ringing'].includes(call.state)) { + console.log('cant reject an ongoing call.'); + return; + } + + return agentManager.hangupCall(this.agent, 'rejected'); + } + + private async processACK({ channel }: ChannelFunctionParams): Promise { + switch (this.agent.role) { + case 'callee': + // Change the call state from 'none' to 'ringing' when any callee session is found + await agentManager.acknowledgeCallee(this.agent); + break; + case 'caller': + // When the caller ack, we ask them to start creating an offer + await this.requestChannelOffer(channel, {}); + break; + } + } + + private async requestChannelOffer(channel: IMediaCallChannel, params: { iceRestart?: boolean }): Promise { + // If the channel already has a local Sdp, no need to request its offer unless we're restarting ICE + if (channel.localDescription?.sdp && !params.iceRestart) { + return; + } + + await this.agent.requestOffer(params); + } +} diff --git a/packages/media-signaling-server/src/calls/acknowledgeCallee.ts b/packages/media-signaling-server/src/calls/acknowledgeCallee.ts deleted file mode 100644 index 8be292a7dc6a3..0000000000000 --- a/packages/media-signaling-server/src/calls/acknowledgeCallee.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; - -import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; - -export async function acknowledgeCallee(call: IMediaCall, channel: ValidSignalChannel): Promise { - // If we're not the callee, or we are a different callee session than the one where the call was accepted, do nothing - if (!compareParticipantAndActor(channel.participant, call.callee)) { - return; - } - - // Change the call state from 'none' to 'ringing' when any callee session is found - await MediaCalls.startRingingById(call._id); -} diff --git a/packages/media-signaling-server/src/calls/acknowledgeCaller.ts b/packages/media-signaling-server/src/calls/acknowledgeCaller.ts deleted file mode 100644 index 621d32697b49f..0000000000000 --- a/packages/media-signaling-server/src/calls/acknowledgeCaller.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; - -import { requestChannelOffer } from '../channels/requestChannelOffer'; -import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; - -export async function acknowledgeCaller(call: IMediaCall, channel: ValidSignalChannel): Promise { - // If we're not the caller, or we are a different caller session than the one where the call was started, do nothing - if (!compareParticipantAndActor(channel.participant, call.caller)) { - return; - } - - if (call.caller.type === 'user' && call.caller.sessionId) { - await requestChannelOffer(channel, {}); - } -} diff --git a/packages/media-signaling-server/src/calls/createCall.ts b/packages/media-signaling-server/src/calls/createCall.ts deleted file mode 100644 index 2dbfd58e0d199..0000000000000 --- a/packages/media-signaling-server/src/calls/createCall.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; -import { MediaCalls, Users } from '@rocket.chat/models'; - -import { createInitialChannel } from '../channels/createInitialChannel'; -import { sendNewSignals } from '../signals/sendNewSignals'; -import { mapUserToContactInformation } from '../utils/mapUserToContactInformation'; - -export async function createCall(caller: MediaCallActor, callee: MediaCallActor): Promise { - if (caller.type !== 'user' || callee.type !== 'user') { - throw new Error('not-implemented'); - } - if (!caller.sessionId) { - throw new Error('not-implemented'); - } - - const callerUser = await Users.findOneById>(caller.id, { - projection: { username: 1, name: 1, freeSwitchExtension: 1 }, - }); - - if (!callerUser?.username) { - throw new Error('invalid-caller'); - } - - const calleeUser = await Users.findOneById>(callee.id, { - projection: { username: 1, name: 1, freeSwitchExtension: 1 }, - }); - if (!calleeUser?.username) { - throw new Error('invalid-callee'); - } - - const call: Omit = { - service: 'webrtc', - kind: 'direct', - state: 'none', - - createdBy: caller, - createdAt: new Date(), - - caller: { - ...caller, - ...mapUserToContactInformation(callerUser), - }, - callee: { - ...callee, - ...mapUserToContactInformation(calleeUser), - }, - }; - - const insertResult = await MediaCalls.insertOne(call); - if (!insertResult.insertedId) { - throw new Error('failed-to-create-call'); - } - - await Promise.allSettled([ - createInitialChannel(insertResult.insertedId, caller, { role: 'caller' }, callerUser), - createInitialChannel(insertResult.insertedId, callee, { role: 'callee' }, calleeUser), - ]); - - return sendNewSignals(insertResult.insertedId); -} diff --git a/packages/media-signaling-server/src/calls/processAcceptedCall.ts b/packages/media-signaling-server/src/calls/processAcceptedCall.ts deleted file mode 100644 index 7ea82d744782b..0000000000000 --- a/packages/media-signaling-server/src/calls/processAcceptedCall.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; - -import { deliverChannelSDP } from '../channels/deliverChannelSDP'; -import { getActorChannel } from '../channels/getActorChannel'; -// import { requestChannelOffer } from '../channels/requestChannelOffer'; -import { isValidSignalChannel } from '../signals/isValidSignalChannel'; -import { sendSignalToChannels } from '../signals/sendSignalToChannels'; -import { logger } from '../utils/logger'; - -export async function processAcceptedCall(callId: IMediaCall['_id']): Promise { - const call = await MediaCalls.findOneById(callId); - if (!call) { - throw new Error('error-failed-to-accept-call'); - } - - const callerChannel = await getActorChannel(call._id, call.caller); - if (!callerChannel) { - throw new Error('error-failed-to-locate-caller-channel'); - } - - const offer = callerChannel.localDescription; - - // If the caller doesn't have a webrtc offer yet, we need to wait for it - if (!offer) { - // await requestChannelOffer(callerChannel); - return; - } - - if (offer.type !== 'offer') { - logger.error({ msg: 'The local description of a caller channel is not an offer', local: offer }); - throw new Error('unexpected-state'); - } - - const calleeChannel = await getActorChannel(call._id, call.callee); - if (!calleeChannel) { - throw new Error('error-failed-to-locate-callee-channel'); - } - - await deliverChannelSDP(calleeChannel, { - sdp: offer, - }); - - const validChannels = [callerChannel, calleeChannel].filter((channel) => isValidSignalChannel(channel)); - - if (validChannels.length) { - await sendSignalToChannels(validChannels, { - type: 'notification', - body: { - notification: 'accepted', - }, - }); - } -} diff --git a/packages/media-signaling-server/src/calls/processEndedCall.ts b/packages/media-signaling-server/src/calls/processEndedCall.ts deleted file mode 100644 index 5e35bee7cd7c7..0000000000000 --- a/packages/media-signaling-server/src/calls/processEndedCall.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; - -import { sendSignalToAllActors } from '../signals/sendSignalToAllActors'; - -export async function processEndedCall(call: IMediaCall): Promise { - await sendSignalToAllActors(call, { - type: 'notification', - body: { - notification: 'hangup', - }, - }); -} diff --git a/packages/media-signaling-server/src/channels/createInitialChannel.ts b/packages/media-signaling-server/src/channels/createInitialChannel.ts deleted file mode 100644 index 3130278f9bba4..0000000000000 --- a/packages/media-signaling-server/src/channels/createInitialChannel.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AtLeast, IMediaCallChannel, IUser, MediaCallActor } from '@rocket.chat/core-typings'; -import type { InsertionModel } from '@rocket.chat/model-typings'; -import { MediaCallChannels } from '@rocket.chat/models'; - -export async function createInitialChannel( - callId: string, - actor: MediaCallActor, - channelData: AtLeast, 'role'>, - userData?: Pick, -): Promise { - // Rocket.Chat users will have a separate channel entry for each session involved in the call - // So if we don't have the sessionId of an user yet, do not initialize the channel - if (actor.type !== 'user' || !actor.sessionId) { - return; - } - - if (!userData?.username || !userData.name) { - return; - } - - await MediaCallChannels.insertOne({ - ...channelData, - callId, - participant: { - type: 'user', - id: userData._id, - username: userData.username, - displayName: userData.name, - sessionId: actor.sessionId, - }, - state: 'none', - joinedAt: new Date(), - acknowledged: false, - }); -} diff --git a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts b/packages/media-signaling-server/src/channels/deliverChannelSDP.ts deleted file mode 100644 index 784be33372722..0000000000000 --- a/packages/media-signaling-server/src/channels/deliverChannelSDP.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalSDP } from '@rocket.chat/media-signaling'; - -import { sendSignalToChannel } from '../signals/sendSignalToChannel'; -import { validateChannelForSignals } from '../signals/validateChannelForSignals'; - -export async function deliverChannelSDP(channel: IMediaCallChannel, body: MediaSignalSDP): Promise { - validateChannelForSignals(channel); - - await sendSignalToChannel(channel, { - type: 'sdp', - body, - }); -} diff --git a/packages/media-signaling-server/src/channels/getActorChannel.ts b/packages/media-signaling-server/src/channels/getActorChannel.ts deleted file mode 100644 index 1c36fb85360f1..0000000000000 --- a/packages/media-signaling-server/src/channels/getActorChannel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; -import { MediaCallChannels } from '@rocket.chat/models'; - -export async function getActorChannel(callId: IMediaCall['_id'], actor: MediaCallActor): Promise { - if (actor.type !== 'user') { - throw new Error('not-implemented'); - } - - // If there is no sessionId yet, we can't determine which channel is going to be used by this actor - if (!actor.sessionId) { - return null; - } - - return MediaCallChannels.findOneByCallIdAndParticipant(callId, actor); -} diff --git a/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts b/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts deleted file mode 100644 index a0a5b1a9f056e..0000000000000 --- a/packages/media-signaling-server/src/channels/getChannelByCallIdAndRole.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; - -import { getActorChannel } from './getActorChannel'; - -export async function getChannelByCallIdAndRole( - callId: IMediaCall['_id'], - role: T, -): Promise { - const call = await MediaCalls.findOneById>(callId, { projection: { [role]: 1 } }); - if (!call) { - return null; - } - - const { [role]: actor } = call; - if (!actor) { - return null; - } - - return getActorChannel(callId, actor); -} diff --git a/packages/media-signaling-server/src/channels/getOppositeChannel.ts b/packages/media-signaling-server/src/channels/getOppositeChannel.ts deleted file mode 100644 index 1093fc5c6ac31..0000000000000 --- a/packages/media-signaling-server/src/channels/getOppositeChannel.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; - -import { getActorChannel } from './getActorChannel'; -import { getChannelByCallIdAndRole } from './getChannelByCallIdAndRole'; -import { isValidSignalRole } from '../signals/isValidSignalRole'; - -export async function getOppositeChannel( - call: IMediaCall, - channel: IMediaCallChannel, - options?: { reloadCallIfNull?: boolean }, -): Promise { - if (!isValidSignalRole(channel.role)) { - return null; - } - - const { reloadCallIfNull = false } = options || {}; - - const oppositeRole = channel.role === 'caller' ? 'callee' : 'caller'; - const otherChannel = await getActorChannel(call._id, call[oppositeRole]); - if (otherChannel || !reloadCallIfNull) { - return otherChannel; - } - - // If no channel was found, using the data from the call object, reload the call and try again - // This is needed because the call might have been accepted by ther other participant at the same time as this request. - return getChannelByCallIdAndRole(call._id, oppositeRole); -} diff --git a/packages/media-signaling-server/src/channels/getRoleForActor.ts b/packages/media-signaling-server/src/channels/getRoleForActor.ts deleted file mode 100644 index 2f5528f6d7194..0000000000000 --- a/packages/media-signaling-server/src/channels/getRoleForActor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { CallRole } from '@rocket.chat/media-signaling'; - -import { compareActorsIgnoringSession } from '../utils/compareActorsIgnoringSession'; - -export function getRoleForActor(call: IMediaCall, actor: MediaCallActor): CallRole | null { - if (compareActorsIgnoringSession(call.caller, actor)) { - return 'caller'; - } - - if (compareActorsIgnoringSession(call.callee, actor)) { - return 'callee'; - } - - return null; -} diff --git a/packages/media-signaling-server/src/channels/requestChannelOffer.ts b/packages/media-signaling-server/src/channels/requestChannelOffer.ts deleted file mode 100644 index 1db77af53145d..0000000000000 --- a/packages/media-signaling-server/src/channels/requestChannelOffer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalRequestOffer } from '@rocket.chat/media-signaling'; - -import { sendSignalToChannel } from '../signals/sendSignalToChannel'; -import { validateChannelForSignals } from '../signals/validateChannelForSignals'; - -export async function requestChannelOffer(channel: IMediaCallChannel, params: MediaSignalRequestOffer): Promise { - // If the channel already has a local Sdp, no need to request its offer unless we're restarting ICE - if (channel.localDescription?.sdp && !params.iceRestart) { - return; - } - - validateChannelForSignals(channel); - - await sendSignalToChannel(channel, { - type: 'request-offer', - body: params, - }); -} diff --git a/packages/media-signaling-server/src/createCall.ts b/packages/media-signaling-server/src/createCall.ts new file mode 100644 index 0000000000000..a9fa5202210db --- /dev/null +++ b/packages/media-signaling-server/src/createCall.ts @@ -0,0 +1,48 @@ +import type { IMediaCall, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { agentManager } from './agents/Manager'; + +export async function createCall(caller: MediaCallSignedActor, callee: MediaCallActor): Promise { + // The caller must always have a contract to create the call + if (!caller.contractId) { + throw new Error('invalid-caller'); + } + + const callerAgent = await agentManager.getNewAgentForActor(caller, 'caller'); + if (!callerAgent) { + throw new Error('invalid-caller'); + } + + const calleeAgent = await agentManager.getNewAgentForActor(callee, 'callee'); + if (!calleeAgent) { + throw new Error('invalid-callee'); + } + + const call: Omit = { + service: 'webrtc', + kind: 'direct', + state: 'none', + + createdBy: caller, + createdAt: new Date(), + + caller, + callee, + }; + + const insertResult = await MediaCalls.insertOne(call); + if (!insertResult.insertedId) { + throw new Error('failed-to-create-call'); + } + + const newCall = await MediaCalls.findOneById(insertResult.insertedId); + if (!newCall) { + throw new Error('failed-to-create-call'); + } + + await callerAgent.onNewCall(newCall, calleeAgent); + await calleeAgent.onNewCall(newCall, callerAgent); + + return newCall; +} diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index 7ac6c799b805e..3fff0dbb09f21 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1,4 +1,4 @@ -export { createCall } from './calls/createCall'; -export { processSignal } from './signals/processSignal'; -export { setSignalHandler } from './signals/signalHandler'; -export { logger } from './utils/logger'; +export { createCall } from './createCall'; +export { processSignal } from './processSignal'; +export { setSignalHandler } from './signalHandler'; +export { logger } from './logger'; diff --git a/packages/media-signaling-server/src/utils/logger.ts b/packages/media-signaling-server/src/logger.ts similarity index 100% rename from packages/media-signaling-server/src/utils/logger.ts rename to packages/media-signaling-server/src/logger.ts diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts new file mode 100644 index 0000000000000..ef7b1958ae37c --- /dev/null +++ b/packages/media-signaling-server/src/processSignal.ts @@ -0,0 +1,27 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { AgentMediaSignal } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { UserAgentFactory } from './agents/users/AgentFactory'; + +export async function processSignal(signal: AgentMediaSignal, uid: IUser['_id']): Promise { + console.log('server.processSignal', signal, uid); + + try { + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + throw new Error('invalid-call'); + } + + const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); + const agent = factory?.getCallAgent(call); + if (!agent) { + throw new Error('invalid-call'); + } + + await agent.processSignal(signal, call); + } catch (e) { + console.log(e); + throw e; + } +} diff --git a/packages/media-signaling-server/src/signals/signalHandler.ts b/packages/media-signaling-server/src/signalHandler.ts similarity index 52% rename from packages/media-signaling-server/src/signals/signalHandler.ts rename to packages/media-signaling-server/src/signalHandler.ts index 9010f422814fd..46636f7c5e33d 100644 --- a/packages/media-signaling-server/src/signals/signalHandler.ts +++ b/packages/media-signaling-server/src/signalHandler.ts @@ -1,7 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { MediaSignal, ServerMediaSignal, ServerMediaSignalType } from '@rocket.chat/media-signaling'; -export type SignalHandler = (uid: IUser['_id'], signal: MediaSignal) => void; +export type SignalHandler = (uid: IUser['_id'], signal: ServerMediaSignal) => void; let handler: SignalHandler | null = null; @@ -9,7 +9,7 @@ export function setSignalHandler(handlerFn: SignalHandler): void { handler = handlerFn; } -export async function sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { +export async function sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { console.log('sendSignal', toUid, signal); if (!handler) { diff --git a/packages/media-signaling-server/src/signals/getChannelForSignal.ts b/packages/media-signaling-server/src/signals/getChannelForSignal.ts deleted file mode 100644 index 1a92e52120791..0000000000000 --- a/packages/media-signaling-server/src/signals/getChannelForSignal.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { InsertionModel } from '@rocket.chat/model-typings'; -import { MediaCallChannels } from '@rocket.chat/models'; - -export async function getChannelForSignal( - channelData: Pick, -): Promise { - console.log('getChannelForSignal', channelData.role); - const newChannel: InsertionModel = { - ...channelData, - state: 'none', - acknowledged: true, - }; - - // Create this channel if it doesn't yet exist, or update it with ack = true if it does - const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); - if (!insertedChannel) { - throw new Error('failed-to-insert-channel'); - } - - return insertedChannel as ValidSignalChannel; -} diff --git a/packages/media-signaling-server/src/signals/isValidSignalChannel.ts b/packages/media-signaling-server/src/signals/isValidSignalChannel.ts deleted file mode 100644 index fa31cb7e0a6c4..0000000000000 --- a/packages/media-signaling-server/src/signals/isValidSignalChannel.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; - -export function isValidSignalChannel(channel: IMediaCallChannel): channel is ValidSignalChannel { - return channel.participant.type === 'user'; -} diff --git a/packages/media-signaling-server/src/signals/isValidSignalRole.ts b/packages/media-signaling-server/src/signals/isValidSignalRole.ts deleted file mode 100644 index 4312e619d9fbe..0000000000000 --- a/packages/media-signaling-server/src/signals/isValidSignalRole.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { CallRole } from '@rocket.chat/media-signaling'; - -export function isValidSignalRole(channelRole: IMediaCallChannel['role']): channelRole is CallRole { - return ['caller', 'callee'].includes(channelRole); -} diff --git a/packages/media-signaling-server/src/signals/processAnswer.ts b/packages/media-signaling-server/src/signals/processAnswer.ts deleted file mode 100644 index 78c388bacff4f..0000000000000 --- a/packages/media-signaling-server/src/signals/processAnswer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalAnswer } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { processHangup } from './processHangup'; -import { acknowledgeCallee } from '../calls/acknowledgeCallee'; -import { acknowledgeCaller } from '../calls/acknowledgeCaller'; -import { processAcceptedCall } from '../calls/processAcceptedCall'; - -async function processAccept(call: IMediaCall, channel: ValidSignalChannel): Promise { - const result = await MediaCalls.setActorSessionIdByIdAndRole(call._id, channel.participant.sessionId, channel.role); - if (!result.modifiedCount) { - // If nothing changed, the call already had a sessionId for this actor - return; - } - - // With session decided for this actor, let's try to move the call state to 'accepted'. This will only work if the other actor also has an assigned session - const stateResult = await MediaCalls.acceptCallById(call._id); - if (!stateResult.modifiedCount) { - return; - } - - // #ToDo: notify client if this throws any error - return processAcceptedCall(call._id); -} - -async function processReject(call: IMediaCall, channel: ValidSignalChannel): Promise { - if (channel.role !== 'callee') { - return; - } - - if (!['none', 'ringing'].includes(call.state)) { - console.log('cant reject an ongoing call.'); - return; - } - - return processHangup( - { - reason: 'rejected', - }, - call, - channel, - ); -} - -async function processACK(call: IMediaCall, channel: ValidSignalChannel): Promise { - if (channel.role === 'callee') { - return acknowledgeCallee(call, channel); - } - return acknowledgeCaller(call, channel); -} - -export async function processAnswer(params: MediaSignalAnswer, call: IMediaCall, channel: ValidSignalChannel): Promise { - console.log('processAnswer'); - - switch (params.answer) { - case 'ack': - return processACK(call, channel); - case 'accept': - return processAccept(call, channel); - case 'unavailable': - // return processUnavailable(call, channel); - break; - case 'reject': - return processReject(call, channel); - } -} diff --git a/packages/media-signaling-server/src/signals/processHangup.ts b/packages/media-signaling-server/src/signals/processHangup.ts deleted file mode 100644 index 732c8f3c1cb2f..0000000000000 --- a/packages/media-signaling-server/src/signals/processHangup.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalHangup } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { processEndedCall } from '../calls/processEndedCall'; - -export async function processHangup(params: MediaSignalHangup, call: IMediaCall, channel: ValidSignalChannel): Promise { - console.log('processHangup'); - - const actor = call[channel.role]; - - const endedBy = { ...actor, ...(actor.type === 'user' && !actor.sessionId && { sessionId: channel.participant.sessionId }) }; - const { reason } = params; - - const stateResult = await MediaCalls.hangupCallById(call._id, { endedBy, reason }); - if (!stateResult.modifiedCount) { - return; - } - - return processEndedCall(call); -} diff --git a/packages/media-signaling-server/src/signals/processSDP.ts b/packages/media-signaling-server/src/signals/processSDP.ts deleted file mode 100644 index 3d5936f5a69b5..0000000000000 --- a/packages/media-signaling-server/src/signals/processSDP.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IMediaCall, ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalSDP } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; - -import { deliverChannelSDP } from '../channels/deliverChannelSDP'; -import { getOppositeChannel } from '../channels/getOppositeChannel'; - -export async function processSDP(params: MediaSignalSDP, call: IMediaCall, channel: ValidSignalChannel): Promise { - console.log('processSDP'); - - // Save the SDP for the local session of the channel - await MediaCallChannels.setLocalDescription(channel._id, params.sdp); - - // Find the opposite channel and send them the SDP as remote - const otherChannel = await getOppositeChannel(call, channel); - // otherChannel will only be defined if the other participant has already accepted the call - if (otherChannel) { - await deliverChannelSDP(otherChannel, params); - } -} diff --git a/packages/media-signaling-server/src/signals/processSignal.ts b/packages/media-signaling-server/src/signals/processSignal.ts deleted file mode 100644 index c82fd63a55ab7..0000000000000 --- a/packages/media-signaling-server/src/signals/processSignal.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { MediaSignal, MediaSignalAnswer, MediaSignalHangup, MediaSignalSDP } from '@rocket.chat/media-signaling'; -import { MediaCalls, Users } from '@rocket.chat/models'; - -import { getChannelForSignal } from './getChannelForSignal'; -import { processAnswer } from './processAnswer'; -import { processHangup } from './processHangup'; -import { processSDP } from './processSDP'; -import { getRoleForActor } from '../channels/getRoleForActor'; -import { assertSignalHasSessionId } from '../utils/assertSignalHasSessionId'; -import { compareParticipantAndActor } from '../utils/compareParticipantAndActor'; - -export async function processSignal(signal: MediaSignal, uid: IUser['_id']): Promise { - assertSignalHasSessionId(signal); - console.log('server.processSignal', signal, uid); - - try { - const call = await MediaCalls.findOneById(signal.callId); - if (!call) { - throw new Error('invalid-call'); - } - - const user = await Users.findOneById, '_id' | 'username' | 'name'>>(uid, { projection: { username: 1, name: 1 } }); - if (!user?.username || !user?.name) { - throw new Error('invalid-user'); - } - - const actor = { type: 'user', id: user._id, sessionId: signal.sessionId } as const; - const role = getRoleForActor(call, actor); - if (!role) { - throw new Error('invalid-role'); - } - - const channel = await getChannelForSignal({ - callId: call._id, - participant: { - ...actor, - username: user.username, - displayName: user.name, - }, - role, - }); - - // This shouldn't be possible unless something tried to switch the roles of the call's actors - if (channel.role !== role || !compareParticipantAndActor(channel.participant, call[channel.role])) { - throw new Error('invalid-channel-data'); - } - - // The code will only reach this point if one of the three following conditions are true: - // 1. the signal came from the exact user session where the caller initiated the call - // 2. the signal came from the exact user session where the callee accepted the call - // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee - - switch (signal.type) { - case 'sdp': - await processSDP(signal.body as MediaSignalSDP, call, channel); - break; - case 'error': - // #ToDo - break; - case 'answer': - await processAnswer(signal.body as MediaSignalAnswer, call, channel); - break; - case 'hangup': - await processHangup(signal.body as MediaSignalHangup, call, channel); - break; - } - } catch (e) { - console.log(e); - throw e; - } -} diff --git a/packages/media-signaling-server/src/signals/sendNewSignals.ts b/packages/media-signaling-server/src/signals/sendNewSignals.ts deleted file mode 100644 index bdc3bd634ac1f..0000000000000 --- a/packages/media-signaling-server/src/signals/sendNewSignals.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { MediaSignalNewCall } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { sendSignalToActor } from './sendSignalToActor'; - -export async function sendNewSignals(callId: string): Promise { - const call = await MediaCalls.getNewSequence(callId); - - console.log('sendNewSignals', call); - - if (!call) { - throw new Error('failed-to-create-call'); - } - - const signalBody: Omit = { - service: call.service, - kind: call.kind, - }; - - const header = { - callId: call._id, - type: 'new', - } as const; - - const roles = ['caller', 'callee'] as const; - - await Promise.all( - roles.map(async (role) => { - const actor = call[role]; - await sendSignalToActor(actor, { - ...header, - body: { - ...signalBody, - role, - }, - }); - }), - ); - - return call; -} diff --git a/packages/media-signaling-server/src/signals/sendSignalToActor.ts b/packages/media-signaling-server/src/signals/sendSignalToActor.ts deleted file mode 100644 index 9656a61ec0a06..0000000000000 --- a/packages/media-signaling-server/src/signals/sendSignalToActor.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; - -import { sendSignal } from './signalHandler'; -import { logger } from '../utils/logger'; - -export async function sendSignalToActor( - actor: MediaCallActor, - signal: { callId: IMediaCall['_id'] } & MediaSignalBodyAndType, -): Promise { - if (actor.type !== 'user') { - return; - } - - logger.debug({ msg: 'sending signal to actor', actor, signal }); - await sendSignal(actor.id, { - ...('sessionId' in actor && { sessionId: actor.sessionId }), - ...signal, - }); -} diff --git a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts b/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts deleted file mode 100644 index e49c9e50a18b2..0000000000000 --- a/packages/media-signaling-server/src/signals/sendSignalToAllActors.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { CallRole, MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; - -import { sendSignalToActor } from './sendSignalToActor'; - -type SendSignalToAllActorsOptions = { - // default false - onlyDefinedSessions?: boolean; - // default false - targetedSignal?: boolean; -}; - -function getActorData(call: IMediaCall, role: CallRole, options: SendSignalToAllActorsOptions): MediaCallActor | null { - const { onlyDefinedSessions = false, targetedSignal = false } = options || {}; - - const actor = call[role]; - if (actor?.type !== 'user') { - return null; - } - - if (!actor.sessionId && onlyDefinedSessions) { - return null; - } - - if (!targetedSignal) { - const { sessionId, ...actorData } = actor; - return actorData; - } - - return actor; -} - -export async function getAllActors(call: IMediaCall, options: SendSignalToAllActorsOptions = {}): Promise { - const subOptions: SendSignalToAllActorsOptions = { - onlyDefinedSessions: options.onlyDefinedSessions ?? false, - targetedSignal: options.targetedSignal ?? false, - }; - - return [getActorData(call, 'caller', subOptions), getActorData(call, 'callee', subOptions)].filter((data) => data) as MediaCallActor[]; -} - -export async function sendSignalToAllActors( - call: IMediaCall, - signal: MediaSignalBodyAndType, - options: SendSignalToAllActorsOptions = {}, -): Promise { - const actors = await getAllActors(call, options); - - await Promise.all( - actors.map(async (actor) => - sendSignalToActor(actor, { - callId: call._id, - ...signal, - }), - ), - ); -} diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts b/packages/media-signaling-server/src/signals/sendSignalToChannel.ts deleted file mode 100644 index 218be29e1c38c..0000000000000 --- a/packages/media-signaling-server/src/signals/sendSignalToChannel.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; - -import { isValidSignalRole } from './isValidSignalRole'; -import { sendSignal } from './signalHandler'; -import { logger } from '../utils/logger'; - -export async function sendSignalToChannel( - channel: ValidSignalChannel, - signal: MediaSignalBodyAndType, -): Promise { - if (!isValidSignalRole(channel.role)) { - logger.info({ msg: 'tried to send a signal to a channel with invalid role', channel, signal }); - return; - } - - logger.debug({ msg: 'sending signal to channel', channel, signal }); - await sendSignal(channel.participant.id, { - callId: channel.callId, - sessionId: channel.participant.sessionId, - ...signal, - }); -} diff --git a/packages/media-signaling-server/src/signals/sendSignalToChannels.ts b/packages/media-signaling-server/src/signals/sendSignalToChannels.ts deleted file mode 100644 index 1beb2cb452723..0000000000000 --- a/packages/media-signaling-server/src/signals/sendSignalToChannels.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ValidSignalChannel } from '@rocket.chat/core-typings'; -import type { MediaSignalBodyAndType, MediaSignalType } from '@rocket.chat/media-signaling'; - -import { sendSignalToChannel } from './sendSignalToChannel'; - -export async function sendSignalToChannels( - channel: ValidSignalChannel[], - signal: MediaSignalBodyAndType, -): Promise { - await Promise.all(channel.map((channel) => sendSignalToChannel(channel, signal))); -} diff --git a/packages/media-signaling-server/src/signals/validateChannelForSignals.ts b/packages/media-signaling-server/src/signals/validateChannelForSignals.ts deleted file mode 100644 index 5da1c1183537c..0000000000000 --- a/packages/media-signaling-server/src/signals/validateChannelForSignals.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { IMediaCallChannel, ValidSignalChannel } from '@rocket.chat/core-typings'; - -export function validateChannelForSignals(channel: IMediaCallChannel): asserts channel is ValidSignalChannel { - if (channel.participant.type !== 'user') { - throw new Error('not-implemented'); - } - if (!channel.participant.sessionId) { - throw new Error('SDP may only be sent to specific user sessions.'); - } -} diff --git a/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts b/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts deleted file mode 100644 index ef3354dff2f63..0000000000000 --- a/packages/media-signaling-server/src/utils/assertSignalHasSessionId.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MediaSignal } from '@rocket.chat/media-signaling'; - -export function assertSignalHasSessionId( - signal: MediaSignal, -): asserts signal is MediaSignal & { sessionId: Required['sessionId'] } { - if (!signal.sessionId) { - throw new Error('invalid-signal-session'); - } -} diff --git a/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts b/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts deleted file mode 100644 index 425a203e6fd1e..0000000000000 --- a/packages/media-signaling-server/src/utils/compareActorsIgnoringSession.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MediaCallActor } from '@rocket.chat/core-typings'; - -export function compareActorsIgnoringSession(actor1: MediaCallActor, actor2: MediaCallActor): boolean { - return actor1.type === actor2.type && actor1.id === actor2.id; -} diff --git a/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts b/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts deleted file mode 100644 index 5095e8343fec5..0000000000000 --- a/packages/media-signaling-server/src/utils/compareParticipantAndActor.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { MediaCallActor, MediaCallChannelUserRC, MediaCallParticipant } from '@rocket.chat/core-typings'; - -export function compareParticipantAndActor(participant: MediaCallParticipant, actor: MediaCallActor): boolean { - if (participant.type !== actor.type || participant.id !== actor.id) { - return false; - } - - if (actor.type === 'user') { - const user = participant as MediaCallChannelUserRC; - - return !actor.sessionId || actor.sessionId === user.sessionId; - } - - return true; -} diff --git a/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts b/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts deleted file mode 100644 index a18fd2d4b2d98..0000000000000 --- a/packages/media-signaling-server/src/utils/mapUserToContactInformation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AtLeast, IUser, MediaCallContactInformation } from '@rocket.chat/core-typings'; - -export function mapUserToContactInformation({ - name, - username, - freeSwitchExtension, -}: AtLeast): MediaCallContactInformation { - return { - ...(name && { name }), - ...(username && { username }), - ...(freeSwitchExtension && { number: freeSwitchExtension }), - }; -} diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/IWebRTCProcessor.ts index ab067b389c1b5..3eef450545e1d 100644 --- a/packages/media-signaling/src/definition/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/IWebRTCProcessor.ts @@ -1,10 +1,9 @@ -import type { MediaSignalRequestOffer, MediaSignalSDP } from './MediaSignal'; import type { MediaStreamFactory } from './MediaStreamFactory'; export interface IWebRTCProcessor { - createOffer(params: MediaSignalRequestOffer): Promise; - createAnswer(params: MediaSignalSDP): Promise; - setRemoteDescription(params: MediaSignalSDP): Promise; + createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; + createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; + setRemoteDescription(params: { sdp: RTCSessionDescriptionInit }): Promise; getRemoteMediaStream(): MediaStream; } diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/MediaSignal.ts index 11eab9097e815..7c8bba61a4b67 100644 --- a/packages/media-signaling/src/definition/MediaSignal.ts +++ b/packages/media-signaling/src/definition/MediaSignal.ts @@ -1,36 +1,50 @@ -import type { CallHangupReason, CallRole, CallService } from './call'; - -export type MediaSignalHeader = { - callId: string; - sessionId?: string; -}; +import type { CallContact, CallHangupReason, CallNotification, CallRole, CallService } from './call'; // Sent by the server to notify an agent that there's a new call for their actor export type MediaSignalNewCall = { + callId: string; + type: 'new'; + service: CallService; kind: 'direct'; role: CallRole; + contact: CallContact; }; // Sent by both the agent and the server to deliver an SDP to the other // the SDP might be an offer or an answer. If it's an offer, the sender will be waiting for an answer from the receiver export type MediaSignalSDP = { + callId: string; + contractId: string; + type: 'sdp'; + sdp: RTCSessionDescriptionInit; - // endOfCandidates: boolean; }; // Sent by the server to request a WebRTC offer from an agent export type MediaSignalRequestOffer = { + callId: string; + contractId: string; + type: 'request-offer'; + iceRestart?: boolean; }; // Sent by an agent to notify the server that something failed export type MediaSignalError = { + callId: string; + contractId: string; + type: 'error'; + errorCode: string; }; // Sent by an agent to notify the server that the user accepted or rejected a call, or simply to report a status before for that user export type MediaSignalAnswer = { + callId: string; + type: 'answer'; + contractId: string; + answer: | 'accept' // the user has accepted the call | 'reject' // the user has rejected the call @@ -40,30 +54,47 @@ export type MediaSignalAnswer = { // Sent by an agent to notify the server that it had to hangup from a call. The reason specifies if its a clean hangup or an error export type MediaSignalHangup = { + callId: string; + contractId: string; + type: 'hangup'; + reason: CallHangupReason; }; // Simple notifications sent by the server to the agents export type MediaSignalNotification = { - notification: - | 'accepted' // notify that the call has been accepted by both actors - | 'hangup'; // notify that the call is over; -}; + callId: string; + contractId?: string; + type: 'notification'; -export type MediaSignalMap = { - 'new': MediaSignalNewCall; - 'sdp': MediaSignalSDP; - 'request-offer': MediaSignalRequestOffer; - 'error': MediaSignalError; - 'answer': MediaSignalAnswer; - 'hangup': MediaSignalHangup; - 'notification': MediaSignalNotification; + notification: CallNotification; }; -export type MediaSignalType = keyof MediaSignalMap; +type ExtractMediaSignal = T extends { type: K } ? T : never; + +export type AnyMediaSignal = + | MediaSignalError + | MediaSignalAnswer + | MediaSignalHangup + | MediaSignalSDP + | MediaSignalNewCall + | MediaSignalRequestOffer + | MediaSignalNotification; + +export type MediaSignalType = AnyMediaSignal['type']; + +export type MediaSignal = ExtractMediaSignal; + +export type AgentMediaSignal = MediaSignal<'error' | 'answer' | 'hangup' | 'sdp'>; + +export type ServerMediaSignal = MediaSignal<'sdp' | 'new' | 'request-offer' | 'notification'>; + +export type AgentMediaSignalType = AgentMediaSignal['type']; + +export type ServerMediaSignalType = ServerMediaSignal['type']; -export type MediaSignalBody = MediaSignalMap[T]; +type RemoveContractId = T extends { contractId: string } ? Omit : T; -export type MediaSignalBodyAndType = { type: T; body: MediaSignalBody }; +export type MediaSignalBody = Omit>, 'callId' | 'type'>; -export type MediaSignal = MediaSignalHeader & MediaSignalBodyAndType; +export type MediaSignalBodyAndType = Omit>, 'callId'>; diff --git a/packages/media-signaling/src/definition/MediaSignalTransport.ts b/packages/media-signaling/src/definition/MediaSignalTransport.ts index 42010ded144ef..417835a9e16b5 100644 --- a/packages/media-signaling/src/definition/MediaSignalTransport.ts +++ b/packages/media-signaling/src/definition/MediaSignalTransport.ts @@ -1,3 +1,5 @@ -import type { MediaSignal } from './MediaSignal'; +import type { AgentMediaSignal, ServerMediaSignal } from './MediaSignal'; -export type MediaSignalTransport = (signal: MediaSignal) => void; +export type MediaSignalAgentTransport = (signal: AgentMediaSignal) => void; + +export type MediaSignalServerTransport = (signal: ServerMediaSignal) => void; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 97af795633575..a534a6d96beb0 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -6,6 +6,8 @@ export type CallEvents = { // contactUpdate may be triggered even if nothing actually changed contactUpdate: void; + initialized: void; + accepting: void; accepted: void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 81ed5d9e76f11..3f5918afb95a6 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -2,7 +2,15 @@ import type { Emitter } from '@rocket.chat/emitter'; import type { CallEvents } from './CallEvents'; -export type CallContact = Record | null; +export type CallContact = { + type?: 'user' | 'sip'; + id?: string; + displayName?: string; + username?: string; + sipExtension?: string; + avatarUrl?: string; + host?: string; +}; export type CallRole = 'caller' | 'callee'; @@ -26,6 +34,10 @@ export type CallHangupReason = | 'media-error' // Hanging up because of an error setting up the media connection | 'error'; // Hanging up because of an unidentified error +export type CallNotification = + | 'accepted' // notify that the call has been accepted by both actors + | 'hangup'; // notify that the call is over; + export interface IClientMediaCallData { callId: string; role: CallRole; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index dd65f29f3ae44..1b426d12a3032 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -3,9 +3,9 @@ import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalTransportWrapper } from './TransportWrapper'; import type { IServiceProcessorFactoryList } from '../definition'; import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; -import type { MediaSignal, MediaSignalSDP } from '../definition/MediaSignal'; +import type { MediaSignal } from '../definition/MediaSignal'; import type { IClientMediaCall, CallEvents, CallContact, CallRole, CallState, CallService, CallHangupReason } from '../definition/call'; -import { signalTypeRequiresTargeting } from './utils/signalTypeRequiresTargeting'; +import { mergeContacts } from './utils/mergeContacts'; import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; export interface IClientMediaCallConfig { @@ -48,10 +48,10 @@ export class ClientMediaCall implements IClientMediaCall { return this._ignored; } - private _contact: CallContact; + private _contact: CallContact | null; public get contact(): CallContact { - return this._contact; + return this._contact || {}; } private _service: CallService | null; @@ -72,6 +72,8 @@ export class ClientMediaCall implements IClientMediaCall { private hasRemoteDescription: boolean; + private initialized: boolean; + private acknowledged: boolean; private earlySignals: Set; @@ -91,6 +93,7 @@ export class ClientMediaCall implements IClientMediaCall { this.acceptedLocally = false; this.endedLocally = false; this.hasRemoteData = false; + this.initialized = false; this.acknowledged = false; this.hasLocalDescription = false; this.hasRemoteDescription = false; @@ -104,30 +107,46 @@ export class ClientMediaCall implements IClientMediaCall { this._service = null; } + // Initialize an outbound call with basic contact information until we receive the full call details from the server; + // this gets executed once for outbound calls initiated in this session. public async initializeOutboundCall(contact: CallContact): Promise { if (this.acceptedLocally) { return; } - if (!this.hasRemoteData) { + const wasInitialized = this.initialized; + + this.initialized = true; + this.acceptedLocally = true; + if (this.hasRemoteData) { + this.changeContact(contact, { prioritizeExisting: true }); + } else { this._role = 'caller'; + this._contact = contact; } - this.acceptedLocally = true; - this._contact = contact; this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); + + if (!wasInitialized) { + this.emitter.emit('initialized'); + } } + // initialize a call with the data received from the server on a 'new' signal; this gets executed once for every call public async initializeRemoteCall(signal: MediaSignal<'new'>): Promise { if (this.hasRemoteData) { return; } console.log('call.initializeRemoteCall', signal.callId); + const wasInitialized = this.initialized; + this.initialized = true; this.hasRemoteData = true; - this._service = signal.body.service; - this._role = signal.body.role; + this._service = signal.service; + this._role = signal.role; + + this.changeContact(signal.contact); // If it's flagged as ignored even before the initialization, tell the server we're unavailable if (this.ignored) { @@ -150,6 +169,10 @@ export class ClientMediaCall implements IClientMediaCall { this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); } + if (!wasInitialized) { + this.emitter.emit('initialized'); + } + await this.processEarlySignals(); } @@ -187,18 +210,6 @@ export class ClientMediaCall implements IClientMediaCall { return this.webrtcProcessor.getRemoteMediaStream(); } - public setContact(contact: CallContact): void { - if (!contact) { - return; - } - - this._contact = { - ...this._contact, - ...contact, - }; - this.emitter.emit('contactUpdate'); - } - public async processSignal(signal: MediaSignal) { if (this.isOver()) { return; @@ -215,18 +226,13 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (!signal.sessionId && signalTypeRequiresTargeting(signal.type)) { - console.error(`Received an untargeted ${signal.type} signal.`); - return; - } - switch (signal.type) { case 'sdp': - return this.processRemoteSDP(signal as MediaSignal<'sdp'>); + return this.processRemoteSDP(signal); case 'request-offer': - return this.processOfferRequest(signal as MediaSignal<'request-offer'>); + return this.processOfferRequest(signal); case 'notification': - return this.processNotification(signal as MediaSignal<'notification'>); + return this.processNotification(signal); } console.log('signal ignored, as its type is not handled by this agent', signal.type); @@ -320,9 +326,19 @@ export class ClientMediaCall implements IClientMediaCall { } } + private changeContact(contact: CallContact | null, { prioritizeExisting }: { prioritizeExisting?: boolean } = {}): void { + const oldContct = prioritizeExisting ? contact : this._contact; + const newContact = prioritizeExisting ? this._contact : contact; + + this._contact = mergeContacts(oldContct, newContact); + if (this._contact) { + this.emitter.emit('contactUpdate'); + } + } + protected async processOfferRequest(signal: MediaSignal<'request-offer'>) { console.log('call.processOfferRequest'); - if (!signal.sessionId) { + if (!signal.contractId) { console.error('Received an untargeted offer request.'); return; } @@ -334,9 +350,9 @@ export class ClientMediaCall implements IClientMediaCall { this.requireWebRTC(); - let offer: MediaSignalSDP | null = null; + let offer: { sdp: RTCSessionDescriptionInit } | null = null; try { - offer = await this.webrtcProcessor.createOffer(signal.body); + offer = await this.webrtcProcessor.createOffer(signal); } catch (e) { this.config.transporter.sendError(this.callId, 'failed-to-create-offer'); throw e; @@ -362,9 +378,9 @@ export class ClientMediaCall implements IClientMediaCall { this.requireWebRTC(); - let answer: MediaSignalSDP | null = null; + let answer: { sdp: RTCSessionDescriptionInit } | null = null; try { - answer = await this.webrtcProcessor.createAnswer(signal.body); + answer = await this.webrtcProcessor.createAnswer(signal); } catch (e) { this.config.transporter.sendError(this.callId, 'failed-to-create-answer'); throw e; @@ -381,21 +397,25 @@ export class ClientMediaCall implements IClientMediaCall { protected async processRemoteSDP(signal: MediaSignal<'sdp'>): Promise { console.log('Call.processRemoteSDP'); + if (!signal.contractId) { + console.error('Received untargeted SDP signal'); + return; + } if (this.shouldIgnoreWebRTC()) { return; } this.requireWebRTC(); - if (signal.body.sdp.type === 'offer') { + if (signal.sdp.type === 'offer') { return this.processAnswerRequest(signal); } - await this.webrtcProcessor.setRemoteDescription(signal.body); + await this.webrtcProcessor.setRemoteDescription(signal); this.hasRemoteDescription = true; } - protected async deliverSdp(sdp: MediaSignalSDP) { + protected async deliverSdp(sdp: { sdp: RTCSessionDescriptionInit }) { console.log('Call.deliverSdp'); this.hasLocalDescription = true; @@ -443,16 +463,16 @@ export class ClientMediaCall implements IClientMediaCall { } private async processNotification(signal: MediaSignal<'notification'>) { - console.log('Call.processNotification', signal.body.notification); + console.log('Call.processNotification', signal.notification); - switch (signal.body.notification) { + switch (signal.notification) { case 'accepted': return this.flagAsAccepted(); case 'hangup': return this.flagAsEnded('remote'); } - console.log('notification ignored as its type is not handled by this agent', signal.body.notification); + console.log('notification ignored as its type is not handled by this agent', signal.notification); } private async flagAsAccepted(): Promise { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index a829ccd03e7bf..caf6d4c561dba 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -5,19 +5,12 @@ import { MediaSignalTransportWrapper } from './TransportWrapper'; import { createRandomToken } from './utils/createRandomToken'; import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; import type { MediaSignal } from '../definition/MediaSignal'; -import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; +import type { MediaSignalAgentTransport } from '../definition/MediaSignalTransport'; import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; import type { IClientMediaCall, CallState } from '../definition/call'; -const stateWeights: Record = { - none: 0, - ringing: 1, - accepted: 2, - active: 3, - hangup: -1, -} as const; - export type MediaSignalingEvents = { + callContactUpdate: { call: IClientMediaCall }; callStateChange: { call: IClientMediaCall; oldState: CallState }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; @@ -28,7 +21,7 @@ export type MediaSignalingSessionConfig = { userId: string; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; - transport: MediaSignalTransport; + transport: MediaSignalAgentTransport; }; export class MediaSignalingSession extends Emitter { @@ -61,48 +54,36 @@ export class MediaSignalingSession extends Emitter { } public isBusy(): boolean { - return this.hasAnyCallState(['ringing', 'accepted', 'active']); + const call = this.getMainCall(); + if (!call) { + return false; + } + + return ['accepted', 'active'].includes(call.state); } public getCallData(callId: string): IClientMediaCall | null { return this.knownCalls.get(callId) || null; } - public getAllCallStates(): CallState[] { - return this.knownCalls - .values() - .map(({ state }) => state) - .toArray(); - } + public getMainCall(): IClientMediaCall | null { + let ringingCall: IClientMediaCall | null = null; + let pendingCall: IClientMediaCall | null = null; - public hasAnyCallState(states: CallState[]): boolean { - const knownStates = this.getAllCallStates(); - for (const state of knownStates) { - if (states.includes(state)) { - return true; + for (const call of this.knownCalls.values()) { + if (['accepted', 'active'].includes(call.state)) { + return call; + } + if (call.state === 'ringing' && !ringingCall) { + ringingCall = call; + continue; + } + if (call.state === 'none') { + pendingCall = call; + continue; } } - return false; - } - - public getSortedCalls(): IClientMediaCall[] { - return this.knownCalls - .values() - .toArray() - .sort((call1, call2) => { - const call1Weight = stateWeights[call1.state] || 0; - const call2Weight = stateWeights[call2.state] || 0; - - return call2Weight - call1Weight; - }); - } - - public getMainCall(): IClientMediaCall | null { - const call = this.getSortedCalls().pop(); - if (call) { - return this.getCallData(call.callId); - } - return null; + return ringingCall || pendingCall; } public async processSignal(signal: MediaSignal): Promise { @@ -122,17 +103,25 @@ export class MediaSignalingSession extends Emitter { } private isSignalTargetingAnotherSession(signal: MediaSignal): boolean { - if (!signal.sessionId) { - return false; + if ('sessionId' in signal && signal.sessionId && signal.sessionId !== this._sessionId) { + return true; } - return signal.sessionId !== this._sessionId; + return false; } private isCallIgnored(callId: string): boolean { return this.ignoredCalls.has(callId); } + private ignoreCall(callId: string) { + this.ignoredCalls.add(callId); + + if (this.knownCalls.has(callId)) { + this.knownCalls.delete(callId); + } + } + private async getOrCreateCall(callId: string): Promise { const existingCall = this.knownCalls.get(callId); if (existingCall) { @@ -148,15 +137,15 @@ export class MediaSignalingSession extends Emitter { const call = new ClientMediaCall(config, callId); this.knownCalls.set(callId, call); + call.emitter.on('contactUpdate', () => this.emit('callContactUpdate', { call })); call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); + call.emitter.on('initialized', () => this.emit('newCall', { call })); call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); call.emitter.on('ended', () => { - this.ignoredCalls.add(call.callId); + this.ignoreCall(call.callId); this.emit('endedCall', { call }); }); - this.emit('newCall', { call }); - return call; } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index 9ee034f385767..48c2ff4937305 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -1,31 +1,30 @@ import type { MediaSignal, MediaSignalAnswer, - MediaSignalBody, - MediaSignalBodyAndType, MediaSignalHangup, - MediaSignalNotification, - MediaSignalType, + AgentMediaSignalType, + MediaSignalBody, + AgentMediaSignal, } from '../definition/MediaSignal'; -import type { MediaSignalTransport } from '../definition/MediaSignalTransport'; +import type { MediaSignalAgentTransport } from '../definition/MediaSignalTransport'; export class MediaSignalTransportWrapper { constructor( - public readonly sessionId: string, - private sendSignalFn: MediaSignalTransport, + public readonly contractId: string, + private sendSignalFn: MediaSignalAgentTransport, ) {} - public sendToServer(callId: string, type: T, body: MediaSignalBody) { - return this.sendSignalToServer(callId, { + public sendToServer(callId: string, type: T, signal: MediaSignalBody) { + return this.sendSignal({ + callId, + contractId: this.contractId, type, - body, - }); + ...signal, + } as MediaSignal); } public sendError(callId: string, errorCode: string) { - this.sendToServer(callId, 'error', { - errorCode, - }); + this.sendToServer(callId, 'error', { errorCode }); } public answer(callId: string, answer: MediaSignalAnswer['answer']) { @@ -36,19 +35,7 @@ export class MediaSignalTransportWrapper { return this.sendToServer(callId, 'hangup', { reason }); } - public notify(callId: string, notification: MediaSignalNotification['notification']) { - return this.sendToServer(callId, 'notification', { notification }); - } - - public sendSignalToServer(callId: string, signal: MediaSignalBodyAndType) { - return this.sendSignal({ - ...signal, - callId, - sessionId: this.sessionId, - }); - } - - public sendSignal(signal: MediaSignal): void { + public sendSignal(signal: AgentMediaSignal): void { console.log('MediaSignalTransport.sendSignal', signal); this.sendSignalFn(signal); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index bac47acf05ebb..a444ab0bd5689 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -1,6 +1,6 @@ import { LocalStream } from './LocalStream'; import { RemoteStream } from './RemoteStream'; -import type { IWebRTCProcessor, MediaSignalRequestOffer, MediaSignalSDP, WebRTCProcessorConfig } from '../../../definition'; +import type { IWebRTCProcessor, WebRTCProcessorConfig } from '../../../definition'; type IceGatheringData = { promise: Promise; @@ -42,7 +42,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.remoteMediaStream; } - public async createOffer({ iceRestart }: MediaSignalRequestOffer): Promise { + public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }> { console.log('processor.createOffer'); await this.initializeLocalMediaStream(); @@ -61,7 +61,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.getLocalDescription(); } - public async createAnswer({ sdp }: MediaSignalSDP): Promise { + public async createAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }> { if (sdp.type !== 'offer') { throw new Error('invalid-webrtc-offer'); } @@ -79,7 +79,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.getLocalDescription(); } - public async setRemoteDescription({ sdp }: MediaSignalSDP): Promise { + public async setRemoteDescription({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { console.log('setRemoteDescription'); await this.initializeLocalMediaStream(); @@ -94,7 +94,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.config.mediaStreamFactory(constraints); } - private async getLocalDescription(): Promise { + private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { console.log('getLocalDescription'); await this.waitForIceGathering(); console.log('waited'); diff --git a/packages/media-signaling/src/lib/utils/mergeContacts.ts b/packages/media-signaling/src/lib/utils/mergeContacts.ts new file mode 100644 index 0000000000000..b8079e46827e2 --- /dev/null +++ b/packages/media-signaling/src/lib/utils/mergeContacts.ts @@ -0,0 +1,27 @@ +import type { CallContact } from '../../definition'; + +export function mergeContacts(oldContact: CallContact | null, newContact: CallContact | null): CallContact | null { + if (!oldContact || !newContact) { + return newContact || oldContact; + } + + if (!Object.keys(newContact).length) { + return oldContact; + } + if (!Object.keys(oldContact).length) { + return newContact; + } + + const keys: (keyof CallContact)[] = ['type', 'id', 'username', 'sipExtension']; + + for (const key of keys) { + if (oldContact[key] && newContact[key] && oldContact[key] !== newContact[key]) { + return newContact; + } + } + + return { + ...oldContact, + ...newContact, + }; +} diff --git a/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts b/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts deleted file mode 100644 index 99f03704997cb..0000000000000 --- a/packages/media-signaling/src/lib/utils/signalTypeRequiresTargeting.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { MediaSignalType } from '../../definition'; - -export function signalTypeRequiresTargeting(signalType: MediaSignalType): boolean { - return ['request-offer', 'sdp'].includes(signalType); -} diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index 553a1a3eacae5..ff489adcaf8f8 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -1,11 +1,11 @@ -import type { IMediaCallChannel, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; import type { UpdateResult } from 'mongodb'; import type { IBaseModel, InsertionModel } from './IBaseModel'; export interface IMediaCallChannelsModel extends IBaseModel { createOrUpdateChannel(channel: InsertionModel): Promise; - findOneByCallIdAndParticipant(callId: string, participant: MediaCallParticipantIdentification): Promise; + findOneByCallIdAndActor(callId: string, actor: Required): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 524aec9fb1e75..af6ba18111c32 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -5,12 +5,7 @@ import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise; - setStateById(callId: string, state: IMediaCall['state']): Promise; startRingingById(callId: string): Promise; - getNewSequence(callId: string): Promise; - acceptCallById(callId: string): Promise; - setCallerSessionIdById(callId: string, callerSessionId: string): Promise; - setCalleeSessionIdById(callId: string, calleeSessionId: string): Promise; - setActorSessionIdByIdAndRole(callId: string, sessionId: string, role: 'caller' | 'callee'): Promise; + acceptCallById(callId: string, calleeContractId: string): Promise; hangupCallById(callId: string, params: { endedBy?: MediaCallActor; reason?: string } | undefined): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index 3386d51470bbc..f6cdfa274a242 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -1,4 +1,4 @@ -import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallParticipantIdentification } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallActor } from '@rocket.chat/core-typings'; import type { IMediaCallChannelsModel, InsertionModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; @@ -12,22 +12,18 @@ export class MediaCallChannelsRaw extends BaseRaw implements protected modelIndexes(): IndexDescription[] { return [ { - key: { 'callId': 1, 'participant.type': 1, 'participant.id': 1, 'participant.sessionId': 1 }, + key: { callId: 1, actorType: 1, actorId: 1, contractId: 1 }, unique: true, - sparse: true, }, ]; } - public async findOneByCallIdAndParticipant( - callId: string, - participant: MediaCallParticipantIdentification, - ): Promise { + public async findOneByCallIdAndActor(callId: string, actor: Required): Promise { return this.findOne({ callId, - 'participant.type': participant.type, - 'participant.id': participant.id, - ...(participant.type === 'user' && { 'participant.sessionId': participant.sessionId }), + actorType: actor.type, + actorId: actor.id, + contractId: actor.contractId, }); } @@ -50,10 +46,10 @@ export class MediaCallChannelsRaw extends BaseRaw implements return this.findOneAndUpdate( { - 'callId': channel.callId, - 'participant.type': channel.participant.type, - 'participant.id': channel.participant.id, - ...(channel.participant.type === 'user' && { 'participant.sessionId': channel.participant.sessionId }), + callId: channel.callId, + actorType: channel.actorType, + actorId: channel.actorId, + contractId: channel.contractId, }, { $setOnInsert: { diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 3dc640a3f3458..6a17f5d792811 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -32,12 +32,6 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }); } - public async setStateById(callId: string, state: IMediaCall['state']): Promise { - return this.updateOneById(callId, { - $set: { state }, - }); - } - public async startRingingById(callId: string): Promise { return this.updateOne( { @@ -48,43 +42,14 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async acceptCallById(callId: string): Promise { + public async acceptCallById(callId: string, calleeContractId: string): Promise { return this.updateOne( { '_id': callId, 'state': { $in: ['none', 'ringing'] }, - 'caller.type': { $exists: true }, - 'callee.type': { $exists: true }, - '$and': [ - { - $or: [ - { - 'caller.type': 'user', - 'caller.sessionId': { $exists: true }, - }, - { - 'caller.type': { - $ne: 'user', - }, - }, - ], - }, - { - $or: [ - { - 'callee.type': 'user', - 'callee.sessionId': { $exists: true }, - }, - { - 'callee.type': { - $ne: 'user', - }, - }, - ], - }, - ], + 'callee.contractId': { $exists: false }, }, - { $set: { state: 'accepted' } }, + { $set: { 'state': 'accepted', 'callee.contractId': calleeContractId } }, ); } @@ -106,24 +71,4 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, ); } - - public async setCallerSessionIdById(callId: string, callerSessionId: string): Promise { - return this.updateOne({ '_id': callId, 'caller.sessionId': { $exists: false } }, { $set: { 'caller.sessionId': callerSessionId } }); - } - - public async setCalleeSessionIdById(callId: string, calleeSessionId: string): Promise { - return this.updateOne({ '_id': callId, 'callee.sessionId': { $exists: false } }, { $set: { 'callee.sessionId': calleeSessionId } }); - } - - public async setActorSessionIdByIdAndRole(callId: string, sessionId: string, role: 'caller' | 'callee'): Promise { - if (role === 'caller') { - return this.setCallerSessionIdById(callId, sessionId); - } - - return this.setCalleeSessionIdById(callId, sessionId); - } - - public async getNewSequence(callId: string): Promise { - return this.findOneAndUpdate({ _id: callId }, { $inc: { sequence: 1 } }, { returnDocument: 'after' }); - } } diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts index 1298ac859bbea..6a6b91c72d480 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -1,10 +1,10 @@ -import { type MediaSignal } from '@rocket.chat/media-signaling'; +import { type AgentMediaSignal } from '@rocket.chat/media-signaling'; import type { JSONSchemaType } from 'ajv'; import Ajv from 'ajv'; const ajv = new Ajv({ discriminator: true }); -export type MediaCallsSignalProps = { signal: MediaSignal }; +export type MediaCallsSignalProps = { signal: AgentMediaSignal }; export type SDP = RTCSessionDescriptionInit; @@ -25,17 +25,6 @@ export const sdpSchema: JSONSchemaType = { required: ['type'], }; -const signalHeader = { - callId: { - type: 'string', - nullable: false, - }, - sessionId: { - type: 'string', - nullable: true, - }, -}; - const mediaCallsSignalPropsSchema: JSONSchemaType = { type: 'object', properties: { @@ -48,161 +37,93 @@ const mediaCallsSignalPropsSchema: JSONSchemaType = { { type: 'object', properties: { - ...signalHeader, - type: { - const: 'new', + callId: { + type: 'string', + nullable: false, }, - body: { - type: 'object', - properties: { - service: { - type: 'string', - nullable: false, - }, - kind: { - type: 'string', - nullable: false, - }, - role: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: true, - required: ['service', 'kind', 'role'], + contractId: { + type: 'string', + nullable: true, }, - }, - additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], - }, - { - type: 'object', - properties: { - ...signalHeader, type: { const: 'sdp', }, - body: { - type: 'object', - properties: { - sdp: sdpSchema, - }, - required: ['sdp'], - }, + sdp: sdpSchema, }, additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], + required: ['callId', 'contractId', 'type', 'sdp'], }, { type: 'object', properties: { - ...signalHeader, - type: { - const: 'request-offer', + callId: { + type: 'string', + nullable: false, }, - body: { - type: 'object', - properties: { - iceRestart: { - type: 'boolean', - nullable: true, - }, - }, - required: [], + contractId: { + type: 'string', + nullable: true, }, - }, - additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], - }, - { - type: 'object', - properties: { - ...signalHeader, type: { const: 'error', }, - body: { - type: 'object', - properties: { - errorCode: { - type: 'string', - nullable: false, - }, - }, - required: ['errorCode'], + errorCode: { + type: 'string', + nullable: false, }, }, additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], + required: ['callId', 'contractId', 'type', 'errorCode'], }, { type: 'object', properties: { - ...signalHeader, + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: true, + }, type: { const: 'answer', }, - body: { - type: 'object', - properties: { - answer: { - type: 'string', - nullable: false, - }, - }, - required: ['answer'], + answer: { + type: 'string', + nullable: false, }, }, additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], + required: ['callId', 'contractId', 'type', 'answer'], }, { type: 'object', properties: { - ...signalHeader, - type: { - const: 'hangup', + callId: { + type: 'string', + nullable: false, }, - body: { - type: 'object', - properties: { - reason: { - type: 'string', - nullable: false, - }, - }, - required: ['reason'], + contractId: { + type: 'string', + nullable: true, }, - }, - additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], - }, - { - type: 'object', - properties: { - ...signalHeader, type: { - const: 'notification', + const: 'hangup', }, - body: { - type: 'object', - properties: { - notification: { - type: 'string', - nullable: false, - }, - }, - required: ['notification'], + reason: { + type: 'string', + nullable: false, }, }, additionalProperties: false, - required: [...Object.keys(signalHeader), 'type', 'body'], + required: ['callId', 'contractId', 'type', 'reason'], }, ], }, }, required: ['signal'], - additionalProperties: true, + additionalProperties: false, }; export const isMediaCallsSignalProps = ajv.compile(mediaCallsSignalPropsSchema); diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts index 02e9d0564fbd4..821e6eb043a5d 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts @@ -3,12 +3,12 @@ import Ajv from 'ajv'; const ajv = new Ajv(); -export type MediaCallsStartProps = { sessionId: string; identifier: string; identifierKind: 'room' | 'user' | 'extension' }; +export type MediaCallsStartProps = { contractId: string; identifier: string; identifierKind: 'user' | 'extension' }; const mediaCallsStartPropsSchema: JSONSchemaType = { type: 'object', properties: { - sessionId: { + contractId: { type: 'string', nullable: false, }, @@ -21,8 +21,8 @@ const mediaCallsStartPropsSchema: JSONSchemaType = { nullable: false, }, }, - required: ['sessionId', 'identifier', 'identifierKind'], - additionalProperties: true, + required: ['contractId', 'identifier', 'identifierKind'], + additionalProperties: false, }; export const isMediaCallsStartProps = ajv.compile(mediaCallsStartPropsSchema); From 566f9a78eba9ddf350dc92047504acfd137d9f55 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 6 Aug 2025 23:24:57 -0300 Subject: [PATCH 22/72] use a signal to request calls too --- apps/meteor/app/api/server/v1/mediacalls.ts | 48 +-------- .../modules/listeners/listeners.module.ts | 4 +- .../server/services/media-call/service.ts | 6 +- packages/core-services/src/events/Events.ts | 4 +- .../src/types/IMediaCallService.ts | 4 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 2 + packages/ddp-client/src/types/streams.ts | 4 +- .../src/agents/definition/IMediaCallAgent.ts | 8 +- .../src/agents/users/Agent.ts | 10 +- .../src/agents/users/NewCallAgent.ts | 2 + .../src/agents/users/SignalProcessor.ts | 8 +- .../media-signaling-server/src/createCall.ts | 4 +- .../src/mutateCallee.ts | 16 +++ .../src/processSignal.ts | 18 +++- .../src/signalHandler.ts | 4 +- .../src/definition/MediaSignal.ts | 100 ------------------ .../src/definition/MediaSignalTransport.ts | 5 - .../src/definition/call/IClientMediaCall.ts | 10 +- .../media-signaling/src/definition/index.ts | 6 +- .../IServiceProcessorFactoryList.ts | 0 .../{ => services}/IWebRTCProcessor.ts | 0 .../{ => services}/MediaStreamFactory.ts | 0 .../src/definition/services/index.ts | 3 + .../src/definition/signals/Transport.ts | 1 + .../definition/signals/client/MediaSignal.ts | 67 ++++++++++++ .../src/definition/signals/index.ts | 3 + .../definition/signals/server/MediaSignal.ts | 49 +++++++++ packages/media-signaling/src/lib/Call.ts | 85 +++++++++++---- packages/media-signaling/src/lib/Session.ts | 64 ++++++++--- .../src/lib/TransportWrapper.ts | 27 +++-- .../v1/media-calls/MediaCallsSignalProps.ts | 46 +++++++- .../v1/media-calls/MediaCallsStartProps.ts | 28 ----- .../rest-typings/src/v1/media-calls/index.ts | 7 -- 33 files changed, 369 insertions(+), 274 deletions(-) create mode 100644 packages/media-signaling-server/src/mutateCallee.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignal.ts delete mode 100644 packages/media-signaling/src/definition/MediaSignalTransport.ts rename packages/media-signaling/src/definition/{ => services}/IServiceProcessorFactoryList.ts (100%) rename packages/media-signaling/src/definition/{ => services}/IWebRTCProcessor.ts (100%) rename packages/media-signaling/src/definition/{ => services}/MediaStreamFactory.ts (100%) create mode 100644 packages/media-signaling/src/definition/services/index.ts create mode 100644 packages/media-signaling/src/definition/signals/Transport.ts create mode 100644 packages/media-signaling/src/definition/signals/client/MediaSignal.ts create mode 100644 packages/media-signaling/src/definition/signals/index.ts create mode 100644 packages/media-signaling/src/definition/signals/server/MediaSignal.ts delete mode 100644 packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts diff --git a/apps/meteor/app/api/server/v1/mediacalls.ts b/apps/meteor/app/api/server/v1/mediacalls.ts index f8bd2c88eb0b8..b63c36bef5785 100644 --- a/apps/meteor/app/api/server/v1/mediacalls.ts +++ b/apps/meteor/app/api/server/v1/mediacalls.ts @@ -1,52 +1,8 @@ import { MediaCall } from '@rocket.chat/core-services'; -import type { IMediaCall } from '@rocket.chat/core-typings'; -import { isMediaCallsStartProps, isMediaCallsSignalProps } from '@rocket.chat/rest-typings'; +import { isMediaCallsSignalProps } from '@rocket.chat/rest-typings'; import { API } from '../api'; -API.v1.addRoute( - 'media-calls.start', - { authRequired: true, validateParams: isMediaCallsStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, - { - async post() { - const { contractId, identifier, identifierKind } = this.bodyParams; - const { userId } = this; - - if (!userId) { - return API.v1.failure('invalid-params'); - } - - // #ToDo: Validate access and permissions - - // if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { - // return API.v1.forbidden(); - // } - - const caller = { uid: userId, contractId }; - - const functions: Record Promise> = { - user: () => MediaCall.callUser(caller, identifier), - extension: () => MediaCall.callExtension(caller, identifier), - }; - const startFn = functions[identifierKind]; - - if (!startFn) { - return API.v1.failure('invalid-params'); - } - - try { - const call = await startFn(); - - return API.v1.success({ - call, - }); - } catch (e) { - return API.v1.failure('error'); - } - }, - }, -); - API.v1.addRoute( 'media-calls.signal', { authRequired: true, validateParams: isMediaCallsSignalProps, rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 } }, @@ -60,7 +16,7 @@ API.v1.addRoute( // } try { - await MediaCall.processSignal(signal, userId); + await MediaCall.processSignal(userId, signal); return API.v1.success(); } catch (e) { diff --git a/apps/meteor/server/modules/listeners/listeners.module.ts b/apps/meteor/server/modules/listeners/listeners.module.ts index 23749eec09463..c37b22e0b1aef 100644 --- a/apps/meteor/server/modules/listeners/listeners.module.ts +++ b/apps/meteor/server/modules/listeners/listeners.module.ts @@ -5,7 +5,7 @@ import { EnterpriseSettings } from '@rocket.chat/core-services'; import { isSettingColor, isSettingEnterprise, UserStatus } from '@rocket.chat/core-typings'; import type { IUser, IRoom, IRole, VideoConference, ISetting, IOmnichannelRoom, IMessage, IOTRMessage } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import { parse } from '@rocket.chat/message-parser'; import { settings } from '../../../app/settings/server/cached'; @@ -145,7 +145,7 @@ export class ListenersModule { }, ); - service.onEvent('user.media-signal', ({ userId, signal }: { userId: string; signal: MediaSignal }) => { + service.onEvent('user.media-signal', ({ userId, signal }: { userId: string; signal: ServerMediaSignal }) => { notifications.notifyUserInThisInstance(userId, 'media-signal', signal); }); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 477e1ac6986bf..9038601f0e262 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,6 +1,6 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; -import type { MediaSignal, AgentMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; import { processSignal, createCall, setSignalHandler } from '@rocket.chat/media-signaling-server'; import { Users } from '@rocket.chat/models'; @@ -12,7 +12,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall setSignalHandler(this.sendSignal.bind(this)); } - public async processSignal(signal: AgentMediaSignal, uid: IUser['_id']): Promise { + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { return processSignal(signal, uid); } @@ -53,7 +53,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return this.createInternalCall(caller, { uid: userId }); } - private async sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { + private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { void api.broadcast('user.media-signal', { userId: toUid, signal }); } } diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 9f58a5b710b51..cd2d8c57c08fb 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -36,7 +36,7 @@ import type { IOTRMessage, MessageAttachment, } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import type * as UiKit from '@rocket.chat/ui-kit'; import type { AutoUpdateRecord } from '../types/IMeteor'; @@ -141,7 +141,7 @@ export type EventSignatures = { ): void; 'user.deleteCustomStatus'(userStatus: Omit): void; 'user.forceLogout': (uid: string) => void; - 'user.media-signal'(data: { userId: IUser['_id']; signal: MediaSignal }): void; + 'user.media-signal'(data: { userId: IUser['_id']; signal: ServerMediaSignal }): void; 'user.nameChanged'(user: Pick): void; 'user.realNameChanged'(user: Partial): void; 'user.roleUpdate'(update: { diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 4c746ecc1515e..6d348dcd55c6a 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,8 +1,8 @@ import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { - processSignal(signal: MediaSignal, fromUid: IUser['_id']): Promise; + processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 3fbd2314c64a7..00b5ff82f3c2b 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -45,4 +45,6 @@ export interface IMediaCall extends IRocketChatRecord { endedBy?: MediaCallActor | ServerActor; endedAt?: Date; hangupReason?: string; + + callerRequestedId?: string; } diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 4ca02105e893e..f07c347d3b907 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -26,7 +26,7 @@ import type { IWebdavAccount, MessageAttachment, } from '@rocket.chat/core-typings'; -import type { MediaSignal } from '@rocket.chat/media-signaling'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import type * as UiKit from '@rocket.chat/ui-kit'; type ClientAction = 'inserted' | 'updated' | 'removed' | 'changed'; @@ -195,7 +195,7 @@ export interface StreamerEvents { key: `${string}/video-conference`; args: [{ action: string; params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] } }]; }, - { key: `${string}/media-signal`; args: [MediaSignal] }, + { key: `${string}/media-signal`; args: [ServerMediaSignal] }, { key: `${string}/userData`; args: [IUserDataEvent] }, { key: `${string}/updateInvites`; args: [unknown] }, { key: `${string}/departmentAgentData`; args: [unknown] }, diff --git a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts index 71cc280fc8415..66b5a34925d63 100644 --- a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts +++ b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { CallContact, CallRole, CallNotification } from '@rocket.chat/media-signaling'; // The basic agent may be instantiated to send notifications to actors with no need for a contract @@ -27,7 +27,7 @@ export interface IMediaCallAgent extends IMediaCallBasicAgent { readonly contractId: string; readonly signed: boolean; - readonly actor: Required; + readonly actor: MediaCallSignedActor; setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise; getLocalDescription(): Promise; @@ -49,7 +49,7 @@ export abstract class MediaCallBasicAgent implemen this.signalProcessor = new UserAgentSignalProcessor(this); } - public async processSignal(signal: AgentMediaSignal, call: IMediaCall): Promise { + public async processSignal(signal: ClientMediaSignal, call: IMediaCall): Promise { return this.signalProcessor.processSignal(signal, call); } @@ -37,8 +37,8 @@ export class UserMediaCallAgent extends UserBasicAgent implemen console.log('UserAgent.setRemoteDescription'); await this.sendSignal({ callId: this.callId, - contractId: this.contractId, - type: 'sdp', + toContractId: this.contractId, + type: 'remote-sdp', sdp, }); } @@ -56,7 +56,7 @@ export class UserMediaCallAgent extends UserBasicAgent implemen await this.sendSignal({ callId: this.callId, - contractId: this.contractId, + toContractId: this.contractId, type: 'request-offer', ...params, }); diff --git a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts index e82b004d986ef..6b3b568f6cc22 100644 --- a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts +++ b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts @@ -26,6 +26,8 @@ export class UserNewCallAgent extends UserBasicAgent implements INewMediaCallAge kind: call.kind, role: this.role, contact, + + ...(this.role === 'caller' && call.callerRequestedId && { requestedCallId: call.callerRequestedId }), }); } } diff --git a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts index f88e42c9b32e9..ef3f0bab0330e 100644 --- a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts +++ b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts @@ -1,5 +1,5 @@ import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { AgentMediaSignal, MediaSignalAnswer } from '@rocket.chat/media-signaling'; +import type { CallAnswer, ClientMediaSignal } from '@rocket.chat/media-signaling'; import { MediaCallChannels } from '@rocket.chat/models'; import type { UserMediaCallAgent } from './Agent'; @@ -13,7 +13,7 @@ type ChannelFunctionParams = { export class UserAgentSignalProcessor { constructor(private readonly agent: UserMediaCallAgent) {} - public async processSignal(signal: AgentMediaSignal, call: IMediaCall): Promise { + public async processSignal(signal: ClientMediaSignal, call: IMediaCall): Promise { console.log('SignalProcessor.processSignal'); const channel = await agentManager.getOrCreateContract(call._id, this.agent, { acknowledged: true }); @@ -29,7 +29,7 @@ export class UserAgentSignalProcessor { const params = { channel, call }; switch (signal.type) { - case 'sdp': + case 'local-sdp': await this.saveLocalDescription(params, signal.sdp); break; case 'error': @@ -51,7 +51,7 @@ export class UserAgentSignalProcessor { await agentManager.setLocalDescription(this.agent, sdp); } - private async processAnswer(params: ChannelFunctionParams, answer: MediaSignalAnswer['answer']): Promise { + private async processAnswer(params: ChannelFunctionParams, answer: CallAnswer): Promise { console.log('processAnswer'); const { call } = params; diff --git a/packages/media-signaling-server/src/createCall.ts b/packages/media-signaling-server/src/createCall.ts index a9fa5202210db..af9c4dfc97f6b 100644 --- a/packages/media-signaling-server/src/createCall.ts +++ b/packages/media-signaling-server/src/createCall.ts @@ -3,7 +3,7 @@ import { MediaCalls } from '@rocket.chat/models'; import { agentManager } from './agents/Manager'; -export async function createCall(caller: MediaCallSignedActor, callee: MediaCallActor): Promise { +export async function createCall(caller: MediaCallSignedActor, callee: MediaCallActor, requestedCallId?: string): Promise { // The caller must always have a contract to create the call if (!caller.contractId) { throw new Error('invalid-caller'); @@ -29,6 +29,8 @@ export async function createCall(caller: MediaCallSignedActor, callee: MediaCall caller, callee, + + ...(requestedCallId && { callerRequestedId: requestedCallId }), }; const insertResult = await MediaCalls.insertOne(call); diff --git a/packages/media-signaling-server/src/mutateCallee.ts b/packages/media-signaling-server/src/mutateCallee.ts new file mode 100644 index 0000000000000..7df3ae0824dc8 --- /dev/null +++ b/packages/media-signaling-server/src/mutateCallee.ts @@ -0,0 +1,16 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { CallActorType } from '@rocket.chat/media-signaling'; +import { Users } from '@rocket.chat/models'; + +export async function mutateCallee(callee: { type: CallActorType; id: string }): Promise<{ type: CallActorType; id: string }> { + if (callee.type !== 'sip') { + return callee; + } + + const user = await Users.findOneByFreeSwitchExtension>(callee.id, { projection: { _id: 1 } }); + if (user) { + return { type: 'user', id: user._id }; + } + + return callee; +} diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts index ef7b1958ae37c..3a7c8327fa40c 100644 --- a/packages/media-signaling-server/src/processSignal.ts +++ b/packages/media-signaling-server/src/processSignal.ts @@ -1,15 +1,29 @@ import type { IUser } from '@rocket.chat/core-typings'; -import type { AgentMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { UserAgentFactory } from './agents/users/AgentFactory'; +import { createCall } from './createCall'; +import { mutateCallee } from './mutateCallee'; -export async function processSignal(signal: AgentMediaSignal, uid: IUser['_id']): Promise { +export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id']): Promise { console.log('server.processSignal', signal, uid); try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { + if (signal.type === 'request-call') { + console.log('creating call as requested'); + await createCall({ type: 'user', id: uid, contractId: signal.contractId }, await mutateCallee(signal.callee), signal.callId); + return; + } + + console.log('call not found', signal.type); + throw new Error('invalid-call'); + } + + if (signal.type === 'request-call') { + console.log('call found'); throw new Error('invalid-call'); } diff --git a/packages/media-signaling-server/src/signalHandler.ts b/packages/media-signaling-server/src/signalHandler.ts index 46636f7c5e33d..5041606cbf28c 100644 --- a/packages/media-signaling-server/src/signalHandler.ts +++ b/packages/media-signaling-server/src/signalHandler.ts @@ -1,5 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; -import type { MediaSignal, ServerMediaSignal, ServerMediaSignalType } from '@rocket.chat/media-signaling'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; export type SignalHandler = (uid: IUser['_id'], signal: ServerMediaSignal) => void; @@ -9,7 +9,7 @@ export function setSignalHandler(handlerFn: SignalHandler): void { handler = handlerFn; } -export async function sendSignal(toUid: IUser['_id'], signal: MediaSignal): Promise { +export async function sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { console.log('sendSignal', toUid, signal); if (!handler) { diff --git a/packages/media-signaling/src/definition/MediaSignal.ts b/packages/media-signaling/src/definition/MediaSignal.ts deleted file mode 100644 index 7c8bba61a4b67..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignal.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { CallContact, CallHangupReason, CallNotification, CallRole, CallService } from './call'; - -// Sent by the server to notify an agent that there's a new call for their actor -export type MediaSignalNewCall = { - callId: string; - type: 'new'; - - service: CallService; - kind: 'direct'; - role: CallRole; - contact: CallContact; -}; - -// Sent by both the agent and the server to deliver an SDP to the other -// the SDP might be an offer or an answer. If it's an offer, the sender will be waiting for an answer from the receiver -export type MediaSignalSDP = { - callId: string; - contractId: string; - type: 'sdp'; - - sdp: RTCSessionDescriptionInit; -}; - -// Sent by the server to request a WebRTC offer from an agent -export type MediaSignalRequestOffer = { - callId: string; - contractId: string; - type: 'request-offer'; - - iceRestart?: boolean; -}; - -// Sent by an agent to notify the server that something failed -export type MediaSignalError = { - callId: string; - contractId: string; - type: 'error'; - - errorCode: string; -}; - -// Sent by an agent to notify the server that the user accepted or rejected a call, or simply to report a status before for that user -export type MediaSignalAnswer = { - callId: string; - type: 'answer'; - contractId: string; - - answer: - | 'accept' // the user has accepted the call - | 'reject' // the user has rejected the call - | 'ack' // pre-answer notifying that the agent can reach the user, a new answer will be sent later - | 'unavailable'; // the user is busy, or the agent can't reach them -}; - -// Sent by an agent to notify the server that it had to hangup from a call. The reason specifies if its a clean hangup or an error -export type MediaSignalHangup = { - callId: string; - contractId: string; - type: 'hangup'; - - reason: CallHangupReason; -}; - -// Simple notifications sent by the server to the agents -export type MediaSignalNotification = { - callId: string; - contractId?: string; - type: 'notification'; - - notification: CallNotification; -}; - -type ExtractMediaSignal = T extends { type: K } ? T : never; - -export type AnyMediaSignal = - | MediaSignalError - | MediaSignalAnswer - | MediaSignalHangup - | MediaSignalSDP - | MediaSignalNewCall - | MediaSignalRequestOffer - | MediaSignalNotification; - -export type MediaSignalType = AnyMediaSignal['type']; - -export type MediaSignal = ExtractMediaSignal; - -export type AgentMediaSignal = MediaSignal<'error' | 'answer' | 'hangup' | 'sdp'>; - -export type ServerMediaSignal = MediaSignal<'sdp' | 'new' | 'request-offer' | 'notification'>; - -export type AgentMediaSignalType = AgentMediaSignal['type']; - -export type ServerMediaSignalType = ServerMediaSignal['type']; - -type RemoveContractId = T extends { contractId: string } ? Omit : T; - -export type MediaSignalBody = Omit>, 'callId' | 'type'>; - -export type MediaSignalBodyAndType = Omit>, 'callId'>; diff --git a/packages/media-signaling/src/definition/MediaSignalTransport.ts b/packages/media-signaling/src/definition/MediaSignalTransport.ts deleted file mode 100644 index 417835a9e16b5..0000000000000 --- a/packages/media-signaling/src/definition/MediaSignalTransport.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AgentMediaSignal, ServerMediaSignal } from './MediaSignal'; - -export type MediaSignalAgentTransport = (signal: AgentMediaSignal) => void; - -export type MediaSignalServerTransport = (signal: ServerMediaSignal) => void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 3f5918afb95a6..3fad5a8a57a15 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -2,8 +2,10 @@ import type { Emitter } from '@rocket.chat/emitter'; import type { CallEvents } from './CallEvents'; +export type CallActorType = 'user' | 'sip'; + export type CallContact = { - type?: 'user' | 'sip'; + type?: CallActorType; id?: string; displayName?: string; username?: string; @@ -34,6 +36,12 @@ export type CallHangupReason = | 'media-error' // Hanging up because of an error setting up the media connection | 'error'; // Hanging up because of an unidentified error +export type CallAnswer = + | 'accept' // actor accepts the call + | 'reject' // actor rejects the call + | 'ack' // agent confirms the actor is reachable + | 'unavailable'; // agent reports the actor is unavailable + export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors | 'hangup'; // notify that the call is over; diff --git a/packages/media-signaling/src/definition/index.ts b/packages/media-signaling/src/definition/index.ts index 1c2932aa317b4..415e596570ec2 100644 --- a/packages/media-signaling/src/definition/index.ts +++ b/packages/media-signaling/src/definition/index.ts @@ -1,5 +1,3 @@ -export * from './IWebRTCProcessor'; -export * from './IServiceProcessorFactoryList'; -export * from './MediaSignalTransport'; -export * from './MediaSignal'; export * from './call'; +export * from './services'; +export * from './signals'; diff --git a/packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts b/packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts similarity index 100% rename from packages/media-signaling/src/definition/IServiceProcessorFactoryList.ts rename to packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts diff --git a/packages/media-signaling/src/definition/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/IWebRTCProcessor.ts similarity index 100% rename from packages/media-signaling/src/definition/IWebRTCProcessor.ts rename to packages/media-signaling/src/definition/services/IWebRTCProcessor.ts diff --git a/packages/media-signaling/src/definition/MediaStreamFactory.ts b/packages/media-signaling/src/definition/services/MediaStreamFactory.ts similarity index 100% rename from packages/media-signaling/src/definition/MediaStreamFactory.ts rename to packages/media-signaling/src/definition/services/MediaStreamFactory.ts diff --git a/packages/media-signaling/src/definition/services/index.ts b/packages/media-signaling/src/definition/services/index.ts new file mode 100644 index 0000000000000..8e5e4e13f28e2 --- /dev/null +++ b/packages/media-signaling/src/definition/services/index.ts @@ -0,0 +1,3 @@ +export * from './IWebRTCProcessor'; +export * from './IServiceProcessorFactoryList'; +export * from './MediaStreamFactory'; diff --git a/packages/media-signaling/src/definition/signals/Transport.ts b/packages/media-signaling/src/definition/signals/Transport.ts new file mode 100644 index 0000000000000..2144c12e3dc07 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/Transport.ts @@ -0,0 +1 @@ +export type MediaSignalTransport = (signal: T) => void; diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts new file mode 100644 index 0000000000000..a59b358dc652d --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -0,0 +1,67 @@ +import type { CallAnswer, CallHangupReason, CallService } from '../../call'; + +export type ClientMediaSignalRequestCall = { + // the callId on this signal is temporary and is never propagated to other agents + callId: string; + contractId: string; + type: 'request-call'; + callee: { + type: 'user' | 'sip'; + id: string; + }; + supportedServices: CallService[]; +}; + +// Client is sending the local session description to the server +export type ClientMediaSignalLocalSDP = { + callId: string; + contractId: string; + type: 'local-sdp'; + + sdp: RTCSessionDescriptionInit; +}; + +// Client is reporting an error +export type ClientMediaSignalError = { + callId: string; + contractId: string; + type: 'error'; + + errorCode: string; +}; + +// Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached +export type ClientMediaSignalAnswer = { + callId: string; + type: 'answer'; + contractId: string; + + answer: CallAnswer; +}; + +// Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error +export type ClientMediaSignalHangup = { + callId: string; + contractId: string; + type: 'hangup'; + + reason: CallHangupReason; +}; + +export type ClientMediaSignal = + | ClientMediaSignalLocalSDP + | ClientMediaSignalError + | ClientMediaSignalAnswer + | ClientMediaSignalHangup + | ClientMediaSignalRequestCall; + +export type ClientMediaSignalType = ClientMediaSignal['type']; + +type ExtractMediaSignal = T extends { type: K } ? T : never; + +export type GenericClientMediaSignal = ExtractMediaSignal; + +export type ClientMediaSignalBody = Omit< + GenericClientMediaSignal, + 'callId' | 'contractId' | 'type' +>; diff --git a/packages/media-signaling/src/definition/signals/index.ts b/packages/media-signaling/src/definition/signals/index.ts new file mode 100644 index 0000000000000..2ff3e344d570b --- /dev/null +++ b/packages/media-signaling/src/definition/signals/index.ts @@ -0,0 +1,3 @@ +export * from './client/MediaSignal'; +export * from './server/MediaSignal'; +export * from './Transport'; diff --git a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts new file mode 100644 index 0000000000000..57c6f49afb5a1 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts @@ -0,0 +1,49 @@ +import type { CallContact, CallNotification, CallRole, CallService } from '../../call'; + +// Sent by the server to notify an agent that there's a new call for their actor +export type ServerMediaSignalNewCall = { + callId: string; + type: 'new'; + + service: CallService; + kind: 'direct'; + role: CallRole; + contact: CallContact; + + // This will only be sent to the caller, with the id it used to request this call + requestedCallId?: string; +}; + +// Server is sending the other actor's sdp +export type ServerMediaSignalRemoteSDP = { + callId: string; + toContractId: string; + type: 'remote-sdp'; + + sdp: RTCSessionDescriptionInit; +}; + +// Server is requesting a webrtc offer +export type ServerMediaSignalRequestOffer = { + callId: string; + toContractId: string; + type: 'request-offer'; + + iceRestart?: boolean; +}; + +// Server is sending a notification about the call state +export type ServerMediaSignalNotification = { + callId: string; + type: 'notification'; + + notification: CallNotification; +}; + +export type ServerMediaSignal = + | ServerMediaSignalNewCall + | ServerMediaSignalRemoteSDP + | ServerMediaSignalRequestOffer + | ServerMediaSignalNotification; + +export type ServerMediaSignalType = ServerMediaSignal['type']; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 1b426d12a3032..2e9c9c01d69e7 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -2,11 +2,25 @@ import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalTransportWrapper } from './TransportWrapper'; import type { IServiceProcessorFactoryList } from '../definition'; -import type { IWebRTCProcessor } from '../definition/IWebRTCProcessor'; -import type { MediaSignal } from '../definition/MediaSignal'; -import type { IClientMediaCall, CallEvents, CallContact, CallRole, CallState, CallService, CallHangupReason } from '../definition/call'; +import type { + IClientMediaCall, + CallEvents, + CallContact, + CallRole, + CallState, + CallService, + CallHangupReason, + CallActorType, +} from '../definition/call'; +import type { IWebRTCProcessor, MediaStreamFactory } from '../definition/services'; import { mergeContacts } from './utils/mergeContacts'; -import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; +import type { + ServerMediaSignal, + ServerMediaSignalNewCall, + ServerMediaSignalNotification, + ServerMediaSignalRemoteSDP, + ServerMediaSignalRequestOffer, +} from '../definition/signals/server/MediaSignal'; export interface IClientMediaCallConfig { transporter: MediaSignalTransportWrapper; @@ -26,7 +40,9 @@ type StateTimeoutHandler = { }; export class ClientMediaCall implements IClientMediaCall { - public readonly callId: string; + public get callId(): string { + return this.remoteCallId ?? this.localCallId; + } public readonly emitter: Emitter; @@ -76,10 +92,15 @@ export class ClientMediaCall implements IClientMediaCall { private acknowledged: boolean; - private earlySignals: Set; + private earlySignals: Set; private stateTimeoutHandlers: Set; + private remoteCallId: string | null; + + // localCallId will only be different on calls initiated by this session + private localCallId: string; + constructor( private readonly config: IClientMediaCallConfig, callId: string, @@ -88,7 +109,8 @@ export class ClientMediaCall implements IClientMediaCall { this.config.transporter = config.transporter; - this.callId = callId; + this.localCallId = callId; + this.remoteCallId = null; this.acceptedLocally = false; this.endedLocally = false; @@ -132,13 +154,28 @@ export class ClientMediaCall implements IClientMediaCall { } } + // Initialize an outbound call with the callee information and send a call request to the server + public async requestCall(callee: { type: CallActorType; id: string }, contactInfo?: CallContact): Promise { + if (this.initialized) { + return; + } + + this.config.transporter.sendToServer(this.callId, 'request-call', { + callee, + supportedServices: Object.keys(this.config.processorFactories) as CallService[], + }); + + return this.initializeOutboundCall({ ...contactInfo, ...callee }); + } + // initialize a call with the data received from the server on a 'new' signal; this gets executed once for every call - public async initializeRemoteCall(signal: MediaSignal<'new'>): Promise { + public async initializeRemoteCall(signal: ServerMediaSignalNewCall): Promise { if (this.hasRemoteData) { return; } console.log('call.initializeRemoteCall', signal.callId); + this.remoteCallId = signal.callId; const wasInitialized = this.initialized; this.initialized = true; @@ -210,15 +247,17 @@ export class ClientMediaCall implements IClientMediaCall { return this.webrtcProcessor.getRemoteMediaStream(); } - public async processSignal(signal: MediaSignal) { + public async processSignal(signal: ServerMediaSignal) { if (this.isOver()) { return; } - console.log('ClientMediaCall.processSignal', signal.type); + const { type: signalType } = signal; + + console.log('ClientMediaCall.processSignal', signalType); - if (signal.type === 'new') { - return this.initializeRemoteCall(signal as MediaSignal<'new'>); + if (signalType === 'new') { + return this.initializeRemoteCall(signal); } if (!this.hasRemoteData) { @@ -226,8 +265,8 @@ export class ClientMediaCall implements IClientMediaCall { return; } - switch (signal.type) { - case 'sdp': + switch (signalType) { + case 'remote-sdp': return this.processRemoteSDP(signal); case 'request-offer': return this.processOfferRequest(signal); @@ -235,7 +274,7 @@ export class ClientMediaCall implements IClientMediaCall { return this.processNotification(signal); } - console.log('signal ignored, as its type is not handled by this agent', signal.type); + console.log('signal ignored, as its type is not handled by this agent', signalType); } public async accept(): Promise { @@ -336,9 +375,9 @@ export class ClientMediaCall implements IClientMediaCall { } } - protected async processOfferRequest(signal: MediaSignal<'request-offer'>) { + protected async processOfferRequest(signal: ServerMediaSignalRequestOffer) { console.log('call.processOfferRequest'); - if (!signal.contractId) { + if (!signal.toContractId) { console.error('Received an untargeted offer request.'); return; } @@ -370,7 +409,7 @@ export class ClientMediaCall implements IClientMediaCall { return this.hasRemoteData && this._service !== 'webrtc'; } - protected async processAnswerRequest(signal: MediaSignal<'sdp'>): Promise { + protected async processAnswerRequest(signal: ServerMediaSignalRemoteSDP): Promise { console.log('Call.processAnswerRequest'); if (this.shouldIgnoreWebRTC()) { return; @@ -395,9 +434,9 @@ export class ClientMediaCall implements IClientMediaCall { await this.deliverSdp(answer); } - protected async processRemoteSDP(signal: MediaSignal<'sdp'>): Promise { + protected async processRemoteSDP(signal: ServerMediaSignalRemoteSDP): Promise { console.log('Call.processRemoteSDP'); - if (!signal.contractId) { + if (!signal.toContractId) { console.error('Received untargeted SDP signal'); return; } @@ -415,11 +454,11 @@ export class ClientMediaCall implements IClientMediaCall { this.hasRemoteDescription = true; } - protected async deliverSdp(sdp: { sdp: RTCSessionDescriptionInit }) { + protected async deliverSdp(data: { sdp: RTCSessionDescriptionInit }) { console.log('Call.deliverSdp'); this.hasLocalDescription = true; - return this.config.transporter.sendToServer(this.callId, 'sdp', sdp); + return this.config.transporter.sendToServer(this.callId, 'local-sdp', data); } protected async rejectAsUnavailable(): Promise { @@ -462,7 +501,7 @@ export class ClientMediaCall implements IClientMediaCall { } } - private async processNotification(signal: MediaSignal<'notification'>) { + private async processNotification(signal: ServerMediaSignalNotification) { console.log('Call.processNotification', signal.notification); switch (signal.notification) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index caf6d4c561dba..47580bcf3bd57 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -2,12 +2,15 @@ import { Emitter } from '@rocket.chat/emitter'; import { ClientMediaCall } from './Call'; import { MediaSignalTransportWrapper } from './TransportWrapper'; +import type { + ClientMediaSignal, + IServiceProcessorFactoryList, + MediaSignalTransport, + MediaStreamFactory, + ServerMediaSignal, +} from '../definition'; import { createRandomToken } from './utils/createRandomToken'; -import type { IServiceProcessorFactoryList } from '../definition/IServiceProcessorFactoryList'; -import type { MediaSignal } from '../definition/MediaSignal'; -import type { MediaSignalAgentTransport } from '../definition/MediaSignalTransport'; -import type { MediaStreamFactory } from '../definition/MediaStreamFactory'; -import type { IClientMediaCall, CallState } from '../definition/call'; +import type { IClientMediaCall, CallState, CallActorType, CallContact } from '../definition/call'; export type MediaSignalingEvents = { callContactUpdate: { call: IClientMediaCall }; @@ -21,7 +24,7 @@ export type MediaSignalingSessionConfig = { userId: string; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; - transport: MediaSignalAgentTransport; + transport: MediaSignalTransport; }; export class MediaSignalingSession extends Emitter { @@ -86,24 +89,39 @@ export class MediaSignalingSession extends Emitter { return ringingCall || pendingCall; } - public async processSignal(signal: MediaSignal): Promise { + public async processSignal(signal: ServerMediaSignal): Promise { console.log('session.processSignal', signal.type); if (this.isSignalTargetingAnotherSession(signal) || this.isCallIgnored(signal.callId)) { return; } - const call = await this.getOrCreateCall(signal.callId); + const call = await this.getOrCreateCallBySignal(signal); await call.processSignal(signal); } - public async registerOutboundCall(callId: string, contact: Record): Promise { - const call = await this.getOrCreateCall(callId); + public async startCall(calleeType: CallActorType, calleeId: string, contactInfo?: CallContact): Promise { + const callId = this.createTemporaryCallId(); + const call = await this.createCall(callId); - return call.initializeOutboundCall(contact); + await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } - private isSignalTargetingAnotherSession(signal: MediaSignal): boolean { - if ('sessionId' in signal && signal.sessionId && signal.sessionId !== this._sessionId) { + private createTemporaryCallId(): string { + const callId = createRandomToken(20); + + if (this.knownCalls.has(callId)) { + return this.createTemporaryCallId(); + } + + return callId; + } + + private isSignalTargetingAnotherSession(signal: ServerMediaSignal): boolean { + if (signal.type === 'new' || signal.type === 'notification') { + return false; + } + + if (signal.toContractId && signal.toContractId !== this._sessionId) { return true; } @@ -122,12 +140,30 @@ export class MediaSignalingSession extends Emitter { } } - private async getOrCreateCall(callId: string): Promise { + private async getOrCreateCall(callId: string, localCallId?: string): Promise { const existingCall = this.knownCalls.get(callId); if (existingCall) { return existingCall; } + const localCall = localCallId && this.knownCalls.get(localCallId); + if (localCall) { + this.knownCalls.set(callId, localCall); + return localCall; + } + + return this.createCall(callId); + } + + private async getOrCreateCallBySignal(signal: ServerMediaSignal): Promise { + if (signal.type === 'new') { + return this.getOrCreateCall(signal.callId, signal.requestedCallId); + } + + return this.getOrCreateCall(signal.callId); + } + + private async createCall(callId: string): Promise { const config = { transporter: this.transporter, processorFactories: this.config.processorFactories, diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index 48c2ff4937305..e65b2a1d842dc 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -1,41 +1,40 @@ +import type { CallAnswer, CallHangupReason } from '../definition'; import type { - MediaSignal, - MediaSignalAnswer, - MediaSignalHangup, - AgentMediaSignalType, - MediaSignalBody, - AgentMediaSignal, -} from '../definition/MediaSignal'; -import type { MediaSignalAgentTransport } from '../definition/MediaSignalTransport'; + MediaSignalTransport, + ClientMediaSignalType, + ClientMediaSignalBody, + GenericClientMediaSignal, + ClientMediaSignal, +} from '../definition/signals'; export class MediaSignalTransportWrapper { constructor( public readonly contractId: string, - private sendSignalFn: MediaSignalAgentTransport, + private sendSignalFn: MediaSignalTransport, ) {} - public sendToServer(callId: string, type: T, signal: MediaSignalBody) { + public sendToServer(callId: string, type: T, signal: ClientMediaSignalBody) { return this.sendSignal({ callId, contractId: this.contractId, type, ...signal, - } as MediaSignal); + } as GenericClientMediaSignal); } public sendError(callId: string, errorCode: string) { this.sendToServer(callId, 'error', { errorCode }); } - public answer(callId: string, answer: MediaSignalAnswer['answer']) { + public answer(callId: string, answer: CallAnswer) { return this.sendToServer(callId, 'answer', { answer }); } - public hangup(callId: string, reason: MediaSignalHangup['reason']) { + public hangup(callId: string, reason: CallHangupReason) { return this.sendToServer(callId, 'hangup', { reason }); } - public sendSignal(signal: AgentMediaSignal): void { + public sendSignal(signal: ClientMediaSignal): void { console.log('MediaSignalTransport.sendSignal', signal); this.sendSignalFn(signal); } diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts index 6a6b91c72d480..c73ba837544cb 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -1,10 +1,10 @@ -import { type AgentMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; import type { JSONSchemaType } from 'ajv'; import Ajv from 'ajv'; const ajv = new Ajv({ discriminator: true }); -export type MediaCallsSignalProps = { signal: AgentMediaSignal }; +export type MediaCallsSignalProps = { signal: ClientMediaSignal }; export type SDP = RTCSessionDescriptionInit; @@ -46,7 +46,47 @@ const mediaCallsSignalPropsSchema: JSONSchemaType = { nullable: true, }, type: { - const: 'sdp', + const: 'request-call', + }, + callee: { + type: 'object', + properties: { + type: { + type: 'string', + nullable: false, + }, + id: { + type: 'string', + nullable: false, + }, + }, + required: ['type', 'id'], + additionalProperties: false, + }, + supportedServices: { + type: 'array', + items: { + type: 'string', + }, + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'callee', 'supportedServices'], + }, + { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: true, + }, + type: { + const: 'local-sdp', }, sdp: sdpSchema, }, diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts deleted file mode 100644 index 821e6eb043a5d..0000000000000 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsStartProps.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { JSONSchemaType } from 'ajv'; -import Ajv from 'ajv'; - -const ajv = new Ajv(); - -export type MediaCallsStartProps = { contractId: string; identifier: string; identifierKind: 'user' | 'extension' }; - -const mediaCallsStartPropsSchema: JSONSchemaType = { - type: 'object', - properties: { - contractId: { - type: 'string', - nullable: false, - }, - identifier: { - type: 'string', - nullable: false, - }, - identifierKind: { - type: 'string', - nullable: false, - }, - }, - required: ['contractId', 'identifier', 'identifierKind'], - additionalProperties: false, -}; - -export const isMediaCallsStartProps = ajv.compile(mediaCallsStartPropsSchema); diff --git a/packages/rest-typings/src/v1/media-calls/index.ts b/packages/rest-typings/src/v1/media-calls/index.ts index cc1f92e322efb..632222af03dca 100644 --- a/packages/rest-typings/src/v1/media-calls/index.ts +++ b/packages/rest-typings/src/v1/media-calls/index.ts @@ -1,15 +1,8 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; - import type { MediaCallsSignalProps } from './MediaCallsSignalProps'; -import type { MediaCallsStartProps } from './MediaCallsStartProps'; export * from './MediaCallsSignalProps'; -export * from './MediaCallsStartProps'; export type MediaCallsEndpoints = { - '/v1/media-calls.start': { - POST: (params: MediaCallsStartProps) => { call: IMediaCall }; - }; '/v1/media-calls.signal': { POST: (params: MediaCallsSignalProps) => void; }; From 378abb90375867b5dfab21348bc39bf9f5250a66 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 7 Aug 2025 18:56:18 -0300 Subject: [PATCH 23/72] report webrtc states --- .../core-typings/src/mediaCalls/IMediaCall.ts | 1 - .../src/agents/Manager.ts | 4 +- .../media-signaling-server/src/createCall.ts | 22 +++- .../src/hangupEveryCall.ts | 10 ++ .../src/processNewCallSignal.ts | 110 ++++++++++++++++++ .../src/processSignal.ts | 17 +-- .../src/definition/call/CallEvents.ts | 7 +- .../src/definition/call/IClientMediaCall.ts | 7 ++ .../media-signaling/src/definition/client.ts | 9 ++ .../definition/services/IServiceProcessor.ts | 24 ++++ .../services/IServiceProcessorFactoryList.ts | 2 +- .../definition/services/IWebRTCProcessor.ts | 15 --- .../src/definition/services/index.ts | 2 +- .../services/webrtc/IWebRTCProcessor.ts | 30 +++++ .../definition/signals/client/MediaSignal.ts | 17 ++- .../definition/signals/server/MediaSignal.ts | 12 +- packages/media-signaling/src/lib/Call.ts | 101 ++++++++++++++-- .../src/lib/services/webrtc/Processor.ts | 47 +++++++- .../src/models/IMediaCallsModel.ts | 13 ++- packages/models/src/models/MediaCalls.ts | 59 +++++++--- .../v1/media-calls/MediaCallsSignalProps.ts | 35 ++++++ 21 files changed, 476 insertions(+), 68 deletions(-) create mode 100644 packages/media-signaling-server/src/hangupEveryCall.ts create mode 100644 packages/media-signaling-server/src/processNewCallSignal.ts create mode 100644 packages/media-signaling/src/definition/client.ts create mode 100644 packages/media-signaling/src/definition/services/IServiceProcessor.ts delete mode 100644 packages/media-signaling/src/definition/services/IWebRTCProcessor.ts create mode 100644 packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 00b5ff82f3c2b..2801543da54a8 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -14,7 +14,6 @@ export type MediaCallSignedActor = Re export type ServerActor = { type: 'server'; id: 'server'; - contractId: never; }; export type MediaCallContactInformation = { diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/packages/media-signaling-server/src/agents/Manager.ts index 80ff314e24155..952c3e51ead4f 100644 --- a/packages/media-signaling-server/src/agents/Manager.ts +++ b/packages/media-signaling-server/src/agents/Manager.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCall, IMediaCallChannel, MediaCallActor, ServerActor } from '@rocket.chat/core-typings'; import type { CallHangupReason, CallRole, CallNotification } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; @@ -177,7 +177,7 @@ class MediaCallAgentManager { // , errorType?: 'signaling' | 'service' | 'media' // const hangupReason = errorType ? `${errorType}-error` : 'error'; - const endedBy = { type: 'server', id: 'server' } as unknown as MediaCallActor; + const endedBy = { type: 'server', id: 'server' } as ServerActor; const stateResult = await MediaCalls.hangupCallById(callId, { endedBy, reason: serverErrorCode }); const result = Boolean(stateResult.modifiedCount); diff --git a/packages/media-signaling-server/src/createCall.ts b/packages/media-signaling-server/src/createCall.ts index af9c4dfc97f6b..9e82de988bc43 100644 --- a/packages/media-signaling-server/src/createCall.ts +++ b/packages/media-signaling-server/src/createCall.ts @@ -1,9 +1,20 @@ import type { IMediaCall, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { CallService } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { agentManager } from './agents/Manager'; -export async function createCall(caller: MediaCallSignedActor, callee: MediaCallActor, requestedCallId?: string): Promise { +export type CreateCallParams = { + caller: MediaCallSignedActor; + callee: MediaCallActor; + requestedCallId?: string; + requestedService?: CallService; +}; + +export async function createCall(params: CreateCallParams): Promise { + console.log('createCall', params); + const { caller, callee, requestedCallId, requestedService } = params; + // The caller must always have a contract to create the call if (!caller.contractId) { throw new Error('invalid-caller'); @@ -19,8 +30,15 @@ export async function createCall(caller: MediaCallSignedActor, callee: MediaCall throw new Error('invalid-callee'); } + const service = requestedService || 'webrtc'; + + // webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call + if (service !== 'webrtc') { + throw new Error('invalid-call-service'); + } + const call: Omit = { - service: 'webrtc', + service, kind: 'direct', state: 'none', diff --git a/packages/media-signaling-server/src/hangupEveryCall.ts b/packages/media-signaling-server/src/hangupEveryCall.ts new file mode 100644 index 0000000000000..d8a8fb493c177 --- /dev/null +++ b/packages/media-signaling-server/src/hangupEveryCall.ts @@ -0,0 +1,10 @@ +import { MediaCalls } from '@rocket.chat/models'; + +export async function hangupEveryCall(hangupReason?: string): Promise { + // change every pending or active call state to 'hangup' with the specified reason + + await MediaCalls.hangupEveryCall({ + endedBy: { type: 'server', id: 'server' }, + reason: hangupReason || 'full-server-hangup', + }); +} diff --git a/packages/media-signaling-server/src/processNewCallSignal.ts b/packages/media-signaling-server/src/processNewCallSignal.ts new file mode 100644 index 0000000000000..cbe7d249f6698 --- /dev/null +++ b/packages/media-signaling-server/src/processNewCallSignal.ts @@ -0,0 +1,110 @@ +import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { ClientMediaSignalRequestCall, ServerMediaSignalRejectedCallRequest } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { createCall } from './createCall'; +import { mutateCallee } from './mutateCallee'; +import { sendSignal } from './signalHandler'; + +function invalidCallId(uid: IUser['_id'], rejection: Omit): never { + void sendSignal(uid, { type: 'rejected-call-request', ...rejection }).catch(() => null); + + throw new Error(rejection.reason ?? 'invalid-call-id'); +} + +async function getExistingCall( + uid: IUser['_id'], + signal: ClientMediaSignalRequestCall, + callee: MediaCallActor, +): Promise { + const { callId: requestedCallId, callee: originalCallee } = signal; + + if (!requestedCallId) { + return null; + } + + console.log('getExistingCall'); + + const caller = { type: 'user', id: uid } as const; + const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; + + const call = await MediaCalls.findOneByIdOrCallerRequestedId(requestedCallId, caller); + + if (!call) { + return null; + } + + // if the requested id matches a real call id, we block it to avoid any potential issues as the requestedId is not expected to ever be an actual call id. + // if the call is already over, we treat it as an invalid id since it can't be reused + if (call._id === requestedCallId || call.state === 'hangup') { + invalidCallId(uid, rejection); + } + + const isMutatedCallee = call.callee.type === callee.type && call.callee.id === callee.id; + const isOriginalCallee = call.callee.type === originalCallee.type && call.callee.id === originalCallee.id; + const isSameCallee = isMutatedCallee || isOriginalCallee; + const isSameContract = call.caller.contractId === signal.contractId; + + if (!isSameCallee || !isSameContract) { + invalidCallId(uid, { ...rejection, reason: 'existing-call-id' }); + } + + if (signal.supportedServices?.length && !signal.supportedServices.includes(call.service)) { + invalidCallId(uid, { ...rejection, reason: 'unsupported' }); + } + + // if the call is already accepted, we won't send its signals again + if (['active'].includes(call.state)) { + invalidCallId(uid, { ...rejection, reason: 'already-requested' }); + } + + sendSignal(uid, { + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: 'caller', + contact: { + ...call.callee, + }, + requestedCallId: signal.callId, + }); + + return call; +} + +export async function processNewCallSignal(signal: ClientMediaSignalRequestCall, uid: IUser['_id']): Promise { + console.log('processNewCallSignal'); + + const caller = { type: 'user', id: uid } as const; + const callee = await mutateCallee(signal.callee); + + const existingCall = await getExistingCall(uid, signal, callee); + + if (existingCall) { + return; + } + + const services = signal.supportedServices ?? []; + const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); + + try { + await createCall({ + caller: { + ...caller, + contractId: signal.contractId, + }, + callee: await mutateCallee(signal.callee), + requestedCallId: signal.callId, + ...(requestedService && { requestedService }), + }); + } catch (e) { + void sendSignal(uid, { + type: 'rejected-call-request', + callId: signal.callId, + toContractId: signal.contractId, + reason: 'unsupported', + }).catch(() => null); + throw e; + } +} diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts index 3a7c8327fa40c..20810c37cf96d 100644 --- a/packages/media-signaling-server/src/processSignal.ts +++ b/packages/media-signaling-server/src/processSignal.ts @@ -3,30 +3,21 @@ import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { UserAgentFactory } from './agents/users/AgentFactory'; -import { createCall } from './createCall'; -import { mutateCallee } from './mutateCallee'; +import { processNewCallSignal } from './processNewCallSignal'; export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id']): Promise { console.log('server.processSignal', signal, uid); + if (signal.type === 'request-call') { + return processNewCallSignal(signal, uid); + } try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { - if (signal.type === 'request-call') { - console.log('creating call as requested'); - await createCall({ type: 'user', id: uid, contractId: signal.contractId }, await mutateCallee(signal.callee), signal.callId); - return; - } - console.log('call not found', signal.type); throw new Error('invalid-call'); } - if (signal.type === 'request-call') { - console.log('call found'); - throw new Error('invalid-call'); - } - const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); const agent = factory?.getCallAgent(call); if (!agent) { diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index a534a6d96beb0..d499dc1613f13 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -1,8 +1,13 @@ +import type { ClientState } from '../client'; import type { CallState } from './IClientMediaCall'; export type CallEvents = { - // stateChange receives the old state as param + // triggered when the state of a call changes (only for states known by the server); receives the old state as a param stateChange: CallState; + + // the client state is more detailed than the general call state, it includes sub-states that might not be relevant to the user or the UI + clientStateChange: ClientState; + // contactUpdate may be triggered even if nothing actually changed contactUpdate: void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 3fad5a8a57a15..badac73d57442 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -46,6 +46,13 @@ export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors | 'hangup'; // notify that the call is over; +export type CallRejectedReason = + | 'invalid-call-id' // the call id can't be used for a new call + | 'invalid-contract-id' // this specific contract can't request this call + | 'existing-call-id' // the call already exists with a different callee or contract + | 'already-requested' // the request is valid, but a call matching its params is already underway + | 'unsupported'; // no matching supported services between actors + export interface IClientMediaCallData { callId: string; role: CallRole; diff --git a/packages/media-signaling/src/definition/client.ts b/packages/media-signaling/src/definition/client.ts new file mode 100644 index 0000000000000..5865ed88b4184 --- /dev/null +++ b/packages/media-signaling/src/definition/client.ts @@ -0,0 +1,9 @@ +export type ClientState = + | 'none' // The client doesn't recognize a specific call id at all + | 'pending' // The call is ringing + | 'accepting' // The client tried to accept the call and is wating for confirmation from the server + | 'accepted' // The call was accepted, but the client doesn't have a webrtc offer yet + | 'has-offer' // The call was accepted and the client has a webrtc offer + | 'has-answer' // The call was accepted and the client has a webrtc offer and answer + | 'active' // The webrtc call was established + | 'hangup'; // The call is over, or happening in some other client diff --git a/packages/media-signaling/src/definition/services/IServiceProcessor.ts b/packages/media-signaling/src/definition/services/IServiceProcessor.ts new file mode 100644 index 0000000000000..ed222452264b3 --- /dev/null +++ b/packages/media-signaling/src/definition/services/IServiceProcessor.ts @@ -0,0 +1,24 @@ +import type { Emitter } from '@rocket.chat/emitter'; + +export type DefaultServiceStateMap = Record; + +export type ServiceStateEvent = { + [P in keyof ServiceStateMap]: ServiceStateMap[P] extends infer U ? { stateName: P; stateValue: U } : { stateName: P; stateValue: string }; +}[K]; + +export type ServiceStateValue = ServiceStateEvent< + ServiceStateMap, + K +>['stateValue']; + +export type ServiceProcessorEvents = { + stateChange: string; + internalStateChange: keyof ServiceStateMap; + internalError: { critical: boolean; error: string | Error }; +}; + +export interface IServiceProcessor { + emitter: Emitter>; + + getInternalState(stateName: K): ServiceStateValue; +} diff --git a/packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts b/packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts index 7afad077b9a7f..7bb278bd5d347 100644 --- a/packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts +++ b/packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts @@ -1,4 +1,4 @@ -import type { WebRTCProcessorFactory } from './IWebRTCProcessor'; +import type { WebRTCProcessorFactory } from './webrtc/IWebRTCProcessor'; export interface IServiceProcessorFactoryList { webrtc?: WebRTCProcessorFactory; diff --git a/packages/media-signaling/src/definition/services/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/IWebRTCProcessor.ts deleted file mode 100644 index 3eef450545e1d..0000000000000 --- a/packages/media-signaling/src/definition/services/IWebRTCProcessor.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { MediaStreamFactory } from './MediaStreamFactory'; - -export interface IWebRTCProcessor { - createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; - createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; - setRemoteDescription(params: { sdp: RTCSessionDescriptionInit }): Promise; - - getRemoteMediaStream(): MediaStream; -} - -export type WebRTCProcessorConfig = { - mediaStreamFactory: MediaStreamFactory; -}; - -export type WebRTCProcessorFactory = (config: WebRTCProcessorConfig) => IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/services/index.ts b/packages/media-signaling/src/definition/services/index.ts index 8e5e4e13f28e2..587361d596cb9 100644 --- a/packages/media-signaling/src/definition/services/index.ts +++ b/packages/media-signaling/src/definition/services/index.ts @@ -1,3 +1,3 @@ -export * from './IWebRTCProcessor'; +export * from './webrtc/IWebRTCProcessor'; export * from './IServiceProcessorFactoryList'; export * from './MediaStreamFactory'; diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts new file mode 100644 index 0000000000000..169ac8b186357 --- /dev/null +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -0,0 +1,30 @@ +import type { Emitter } from '@rocket.chat/emitter'; + +import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor'; +import type { MediaStreamFactory } from '../MediaStreamFactory'; + +export type WebRTCInternalStateMap = { + signaling: RTCSignalingState; + connection: RTCPeerConnectionState; + iceConnection: RTCIceConnectionState; + iceGathering: RTCIceGatheringState; + iceUntrickler: 'waiting' | 'not-waiting' | 'timeout'; +}; + +export type WebRTCProcessorEvents = ServiceProcessorEvents; + +export interface IWebRTCProcessor extends IServiceProcessor { + emitter: Emitter; + + createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; + createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; + setRemoteDescription(params: { sdp: RTCSessionDescriptionInit }): Promise; + + getRemoteMediaStream(): MediaStream; +} + +export type WebRTCProcessorConfig = { + mediaStreamFactory: MediaStreamFactory; +}; + +export type WebRTCProcessorFactory = (config: WebRTCProcessorConfig) => IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts index a59b358dc652d..95e39a1ae451a 100644 --- a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -1,4 +1,5 @@ -import type { CallAnswer, CallHangupReason, CallService } from '../../call'; +import type { CallAnswer, CallHangupReason, CallService, CallState } from '../../call'; +import type { ClientState } from '../../client'; export type ClientMediaSignalRequestCall = { // the callId on this signal is temporary and is never propagated to other agents @@ -48,12 +49,24 @@ export type ClientMediaSignalHangup = { reason: CallHangupReason; }; +// Client is sending their local call state +export type ClientMediaSignalLocalState = { + callId: string; + contractId: string; + type: 'local-state'; + + callState: CallState; + clientState: ClientState; + serviceStates?: Record; +}; + export type ClientMediaSignal = | ClientMediaSignalLocalSDP | ClientMediaSignalError | ClientMediaSignalAnswer | ClientMediaSignalHangup - | ClientMediaSignalRequestCall; + | ClientMediaSignalRequestCall + | ClientMediaSignalLocalState; export type ClientMediaSignalType = ClientMediaSignal['type']; diff --git a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts index 57c6f49afb5a1..0c19167bf5558 100644 --- a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts @@ -1,4 +1,4 @@ -import type { CallContact, CallNotification, CallRole, CallService } from '../../call'; +import type { CallContact, CallNotification, CallRejectedReason, CallRole, CallService } from '../../call'; // Sent by the server to notify an agent that there's a new call for their actor export type ServerMediaSignalNewCall = { @@ -40,10 +40,18 @@ export type ServerMediaSignalNotification = { notification: CallNotification; }; +export type ServerMediaSignalRejectedCallRequest = { + callId: string; + type: 'rejected-call-request'; + toContractId: string; + reason?: CallRejectedReason; +}; + export type ServerMediaSignal = | ServerMediaSignalNewCall | ServerMediaSignalRemoteSDP | ServerMediaSignalRequestOffer - | ServerMediaSignalNotification; + | ServerMediaSignalNotification + | ServerMediaSignalRejectedCallRequest; export type ServerMediaSignalType = ServerMediaSignal['type']; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 2e9c9c01d69e7..b90a1dc1e21ed 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -12,7 +12,8 @@ import type { CallHangupReason, CallActorType, } from '../definition/call'; -import type { IWebRTCProcessor, MediaStreamFactory } from '../definition/services'; +import type { ClientState } from '../definition/client'; +import type { IWebRTCProcessor, MediaStreamFactory, WebRTCInternalStateMap } from '../definition/services'; import { mergeContacts } from './utils/mergeContacts'; import type { ServerMediaSignal, @@ -31,8 +32,7 @@ export interface IClientMediaCallConfig { const TIMEOUT_TO_ACCEPT = 30000; const TIMEOUT_TO_CONFIRM_ACCEPTANCE = 2000; const TIMEOUT_TO_PROGRESS_SIGNALING = 10000; - -type ClientState = 'pending' | 'accepting' | 'accepted' | 'has-offer' | 'has-answer' | 'active' | 'hangup'; +const STATE_REPORT_DELAY = 300; type StateTimeoutHandler = { state: ClientState; @@ -98,6 +98,14 @@ export class ClientMediaCall implements IClientMediaCall { private remoteCallId: string | null; + private oldClientState: ClientState; + + private serviceStates: Map; + + private stateReporterTimeoutHandler: ReturnType | null; + + private mayReportStates: boolean; + // localCallId will only be different on calls initiated by this session private localCallId: string; @@ -119,11 +127,15 @@ export class ClientMediaCall implements IClientMediaCall { this.acknowledged = false; this.hasLocalDescription = false; this.hasRemoteDescription = false; + this.serviceStates = new Map(); + this.stateReporterTimeoutHandler = null; + this.mayReportStates = true; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); this._role = 'callee'; this._state = 'none'; + this.oldClientState = 'none'; this._ignored = false; this._contact = null; this._service = null; @@ -351,7 +363,7 @@ export class ClientMediaCall implements IClientMediaCall { const oldState = this._state; this._state = newState; - this.updateStateTimeouts(); + this.updateClientState(); this.emitter.emit('stateChange', oldState); @@ -363,6 +375,21 @@ export class ClientMediaCall implements IClientMediaCall { this.emitter.emit('ended'); break; } + this.requestStateReport(); + } + + private updateClientState(): void { + const { oldClientState } = this; + + const clientState = this.getClientState(); + if (clientState === oldClientState) { + return; + } + + this.updateStateTimeouts(); + this.requestStateReport(); + this.oldClientState = clientState; + this.emitter.emit('clientStateChange', oldClientState); } private changeContact(contact: CallContact | null, { prioritizeExisting }: { prioritizeExisting?: boolean } = {}): void { @@ -405,8 +432,17 @@ export class ClientMediaCall implements IClientMediaCall { } protected shouldIgnoreWebRTC(): boolean { - // Without the remote data we don't know if the call is using webrtc or not - return this.hasRemoteData && this._service !== 'webrtc'; + if (this.hasRemoteData) { + return this.service !== 'webrtc'; + } + + // If we called and we don't support webrtc, assume it's not gonna be a webrtc call + if (this._role === 'caller' && !this.config.processorFactories.webrtc) { + return true; + } + + // With no more info, we can't safely ignore webrtc + return false; } protected async processAnswerRequest(signal: ServerMediaSignalRemoteSDP): Promise { @@ -458,7 +494,9 @@ export class ClientMediaCall implements IClientMediaCall { console.log('Call.deliverSdp'); this.hasLocalDescription = true; - return this.config.transporter.sendToServer(this.callId, 'local-sdp', data); + this.config.transporter.sendToServer(this.callId, 'local-sdp', data); + + this.updateClientState(); } protected async rejectAsUnavailable(): Promise { @@ -586,6 +624,54 @@ export class ClientMediaCall implements IClientMediaCall { } } + private onWebRTCInternalStateChange(stateName: keyof WebRTCInternalStateMap): void { + if (!this.webrtcProcessor) { + return; + } + console.log('webrtc internal state changed: ', stateName); + const stateValue = this.webrtcProcessor.getInternalState(stateName); + + if (this.serviceStates.get(stateName) !== stateValue) { + this.serviceStates.set(stateName, stateValue); + this.requestStateReport(); + } + } + + private reportStates(): void { + this.clearStateReporter(); + if (!this.mayReportStates) { + return; + } + + this.config.transporter.sendToServer(this.callId, 'local-state', { + callState: this.state, + clientState: this.getClientState(), + serviceStates: Object.fromEntries(this.serviceStates.entries()), + }); + + if (this.state === 'hangup') { + this.mayReportStates = false; + } + } + + private clearStateReporter(): void { + if (this.stateReporterTimeoutHandler) { + clearTimeout(this.stateReporterTimeoutHandler); + this.stateReporterTimeoutHandler = null; + } + } + + private requestStateReport(): void { + this.clearStateReporter(); + if (!this.mayReportStates) { + return; + } + + this.stateReporterTimeoutHandler = setTimeout(() => { + this.reportStates(); + }, STATE_REPORT_DELAY); + } + private prepareWebRtcProcessor(): asserts this is ClientMediaCallWebRTC { if (this.webrtcProcessor) { return; @@ -603,6 +689,7 @@ export class ClientMediaCall implements IClientMediaCall { } this.webrtcProcessor = webrtcFactory({ mediaStreamFactory }); + this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); } private requireWebRTC(): asserts this is ClientMediaCallWebRTC { diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index a444ab0bd5689..903fd86237fda 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -1,6 +1,9 @@ +import { Emitter } from '@rocket.chat/emitter'; + import { LocalStream } from './LocalStream'; import { RemoteStream } from './RemoteStream'; -import type { IWebRTCProcessor, WebRTCProcessorConfig } from '../../../definition'; +import type { IWebRTCProcessor, WebRTCInternalStateMap, WebRTCProcessorConfig, WebRTCProcessorEvents } from '../../../definition'; +import type { ServiceStateValue } from '../../../definition/services/IServiceProcessor'; type IceGatheringData = { promise: Promise; @@ -10,10 +13,14 @@ type IceGatheringData = { }; export class MediaCallWebRTCProcessor implements IWebRTCProcessor { + public emitter: Emitter; + private peer: RTCPeerConnection; private iceGatheringFinished = false; + private iceGatheringTimedOut = false; + private localStream: LocalStream; private localMediaStream: MediaStream; @@ -35,6 +42,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.remoteStream = new RemoteStream(this.remoteMediaStream); this.peer = new RTCPeerConnection(); + this.emitter = new Emitter(); this.registerPeerEvents(); } @@ -90,10 +98,32 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.peer.setRemoteDescription(sdp); } + public getInternalState(stateName: K): ServiceStateValue { + switch (stateName) { + case 'signaling': + return this.peer.signalingState; + case 'connection': + return this.peer.connectionState; + case 'iceConnection': + return this.peer.iceConnectionState; + case 'iceGathering': + return this.peer.iceGatheringState; + case 'iceUntrickler': + if (this.iceGatheringTimedOut) { + return 'timeout'; + } + return this.iceGatheringWaiters.size > 0 ? 'waiting' : 'not-waiting'; + } + } + protected async getuserMedia(constraints: MediaStreamConstraints) { return this.config.mediaStreamFactory(constraints); } + private changeInternalState(stateName: keyof WebRTCInternalStateMap): void { + this.emitter.emit('internalStateChange', stateName); + } + private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { console.log('getLocalDescription'); await this.waitForIceGathering(); @@ -125,6 +155,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } + this.iceGatheringTimedOut = false; + const data: Partial = {}; data.promise = new Promise((resolve, reject) => { @@ -137,12 +169,15 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { console.log('timeout'); if (this.iceGatheringWaiters.has(iceGatheringData)) { this.clearIceGatheringData(iceGatheringData); + this.iceGatheringTimedOut = true; + this.changeInternalState('iceUntrickler'); } else { console.log('ice gathering not on the list'); } }, 500); this.iceGatheringWaiters.add(iceGatheringData); + this.changeInternalState('iceUntrickler'); return data.promise; } @@ -183,7 +218,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } console.log('onIceCandidate ERROR event'); - // + this.emitter.emit('internalError', { critical: false, error: 'ice-candidate-error' }); } private onNegotiationNeeded(peer: RTCPeerConnection) { @@ -209,6 +244,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } console.log('Connection state change', peer.connectionState); + this.changeInternalState('connection'); } private onIceConnectionStateChange(peer: RTCPeerConnection) { @@ -217,6 +253,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } console.log('Ice connection state change', peer.iceConnectionState); + this.changeInternalState('iceConnection'); } private onSignalingStateChange(peer: RTCPeerConnection) { @@ -225,6 +262,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } console.log('Signaling state change', peer.signalingState); + this.changeInternalState('signaling'); } private onIceGatheringStateChange(peer: RTCPeerConnection) { @@ -237,6 +275,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (peer.iceGatheringState === 'complete') { this.onIceGatheringComplete(); } + + this.changeInternalState('iceGathering'); } private async initializeLocalMediaStream(): Promise { @@ -288,9 +328,12 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { console.log('clear waiters'); const waiters = this.iceGatheringWaiters.values().toArray(); this.iceGatheringWaiters.clear(); + this.iceGatheringTimedOut = false; for (const iceGatheringData of waiters) { this.clearIceGatheringData(iceGatheringData, error); } + + this.changeInternalState('iceUntrickler'); } } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index af6ba18111c32..9048c4be58c06 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,11 +1,16 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { UpdateResult } from 'mongodb'; +import type { IMediaCall, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { Document, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { - setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise; + findOneByIdOrCallerRequestedId( + id: IMediaCall['_id'] | Required['callerRequestedId'], + caller: { type: MediaCallActorType; id: string }, + options?: FindOptions, + ): Promise; startRingingById(callId: string): Promise; acceptCallById(callId: string, calleeContractId: string): Promise; - hangupCallById(callId: string, params: { endedBy?: MediaCallActor; reason?: string } | undefined): Promise; + hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; + hangupEveryCall(params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 6a17f5d792811..3bae67c02f0e7 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -1,6 +1,6 @@ -import type { IMediaCall, RocketChatRecordDeleted, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCall, RocketChatRecordDeleted, MediaCallActorType } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; -import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; +import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult, FindOptions, Document } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -10,7 +10,29 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod } protected modelIndexes(): IndexDescription[] { - return [{ key: { rid: 1, createdAt: 1 }, unique: false }]; + return [ + { key: { createdAt: 1 }, unique: false }, + { key: { state: 1 }, unique: false }, + { key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 }, unique: true, sparse: true }, + ]; + } + + public async findOneByIdOrCallerRequestedId( + id: IMediaCall['_id'] | Required['callerRequestedId'], + caller: { type: MediaCallActorType; id: string }, + options?: FindOptions, + ): Promise { + return this.findOne( + { + $or: [ + // If we get an id, find any call with that id + { _id: id }, + // if we get a callerRequestedId, then only find it if it was created by the same actor + { 'caller.type': caller.type, 'caller.id': caller.id, 'callerRequestedId': id }, + ], + }, + options, + ); } public updateOneById( @@ -21,17 +43,6 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne({ _id }, update, options); } - public async setEndedById(callId: string, data?: { endedBy?: MediaCallActor; endedAt?: Date }): Promise { - const { endedBy, endedAt } = { endedAt: new Date(), ...data }; - - await this.updateOneById(callId, { - $set: { - ...(endedBy && { endedBy }), - endedAt, - }, - }); - } - public async startRingingById(callId: string): Promise { return this.updateOne( { @@ -53,7 +64,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async hangupCallById(callId: string, params?: { endedBy?: MediaCallActor; reason?: string }): Promise { + public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { const { endedBy, reason } = params || {}; return this.updateOne( @@ -71,4 +82,22 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, ); } + + public async hangupEveryCall(params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { + const { endedBy, reason } = params || {}; + + return this.col.updateMany( + { + state: { $ne: 'hangup' }, + }, + { + $set: { + state: 'hangup', + endedAt: new Date(), + ...(endedBy && { endedBy }), + ...(reason && { hangupReason: reason }), + }, + }, + ); + } } diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts index c73ba837544cb..b2534d648e5ca 100644 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts @@ -159,6 +159,41 @@ const mediaCallsSignalPropsSchema: JSONSchemaType = { additionalProperties: false, required: ['callId', 'contractId', 'type', 'reason'], }, + { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: true, + }, + type: { + const: 'local-state', + }, + callState: { + type: 'string', + nullable: false, + }, + clientState: { + type: 'string', + nullable: false, + }, + serviceStates: { + type: 'object', + patternProperties: { + '.*': { + type: 'string', + }, + }, + nullable: true, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'callState', 'clientState'], + }, ], }, }, From 7553d66907b53fd313c6b36cf0dcbdd0a588de66 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 7 Aug 2025 20:31:17 -0300 Subject: [PATCH 24/72] house cleaning --- apps/meteor/app/api/server/index.ts | 1 - apps/meteor/app/api/server/v1/mediacalls.ts | 27 --- .../notifications/notifications.module.ts | 18 +- .../server/services/media-call/service.ts | 35 ++- .../src/types/IMediaCallService.ts | 1 + packages/media-signaling-server/package.json | 3 +- .../src/agents/Manager.ts | 36 ++-- .../src/agents/users/Agent.ts | 9 +- .../src/agents/users/AgentFactory.ts | 15 +- .../src/agents/users/BasicAgent.ts | 3 +- .../src/agents/users/NewCallAgent.ts | 5 +- .../src/agents/users/SignalProcessor.ts | 11 +- .../media-signaling-server/src/createCall.ts | 5 +- .../src/hangupEveryCall.ts | 10 - packages/media-signaling-server/src/index.ts | 1 + .../src/isClientMediaSignal.ts | 9 + .../src/processNewCallSignal.ts | 5 +- .../src/processSignal.ts | 8 +- .../src/signalHandler.ts | 4 +- .../definition/signals/client/MediaSignal.ts | 82 ++----- .../src/definition/signals/client/answer.ts | 33 +++ .../src/definition/signals/client/error.ts | 31 +++ .../src/definition/signals/client/hangup.ts | 33 +++ .../src/definition/signals/client/index.ts | 8 + .../definition/signals/client/local-sdp.ts | 43 ++++ .../definition/signals/client/local-state.ts | 49 +++++ .../definition/signals/client/request-call.ts | 54 +++++ .../src/definition/signals/index.ts | 4 +- .../definition/signals/server/MediaSignal.ts | 53 +---- .../src/definition/signals/server/index.ts | 7 + .../src/definition/signals/server/new.ts | 15 ++ .../definition/signals/server/notification.ts | 9 + .../signals/server/rejected-call-request.ts | 8 + .../definition/signals/server/remote-sdp.ts | 8 + .../signals/server/request-offer.ts | 8 + packages/media-signaling/src/lib/Call.ts | 35 +-- packages/media-signaling/src/lib/Session.ts | 1 - .../src/lib/TransportWrapper.ts | 1 - .../src/lib/services/webrtc/LocalStream.ts | 4 - .../src/lib/services/webrtc/Processor.ts | 41 +--- .../src/lib/services/webrtc/RemoteStream.ts | 4 - packages/rest-typings/src/index.ts | 3 - .../v1/media-calls/MediaCallsSignalProps.ts | 204 ------------------ .../rest-typings/src/v1/media-calls/index.ts | 9 - yarn.lock | 1 + 45 files changed, 454 insertions(+), 500 deletions(-) delete mode 100644 apps/meteor/app/api/server/v1/mediacalls.ts delete mode 100644 packages/media-signaling-server/src/hangupEveryCall.ts create mode 100644 packages/media-signaling-server/src/isClientMediaSignal.ts create mode 100644 packages/media-signaling/src/definition/signals/client/answer.ts create mode 100644 packages/media-signaling/src/definition/signals/client/error.ts create mode 100644 packages/media-signaling/src/definition/signals/client/hangup.ts create mode 100644 packages/media-signaling/src/definition/signals/client/index.ts create mode 100644 packages/media-signaling/src/definition/signals/client/local-sdp.ts create mode 100644 packages/media-signaling/src/definition/signals/client/local-state.ts create mode 100644 packages/media-signaling/src/definition/signals/client/request-call.ts create mode 100644 packages/media-signaling/src/definition/signals/server/index.ts create mode 100644 packages/media-signaling/src/definition/signals/server/new.ts create mode 100644 packages/media-signaling/src/definition/signals/server/notification.ts create mode 100644 packages/media-signaling/src/definition/signals/server/rejected-call-request.ts create mode 100644 packages/media-signaling/src/definition/signals/server/remote-sdp.ts create mode 100644 packages/media-signaling/src/definition/signals/server/request-offer.ts delete mode 100644 packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts delete mode 100644 packages/rest-typings/src/v1/media-calls/index.ts diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index 33e3723ff2bd1..74b9599792b17 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -49,7 +49,6 @@ import './v1/voip/omnichannel'; import './v1/voip'; import './v1/federation'; import './v1/moderation'; -import './v1/mediacalls'; // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/app/api/server/v1/mediacalls.ts b/apps/meteor/app/api/server/v1/mediacalls.ts deleted file mode 100644 index b63c36bef5785..0000000000000 --- a/apps/meteor/app/api/server/v1/mediacalls.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MediaCall } from '@rocket.chat/core-services'; -import { isMediaCallsSignalProps } from '@rocket.chat/rest-typings'; - -import { API } from '../api'; - -API.v1.addRoute( - 'media-calls.signal', - { authRequired: true, validateParams: isMediaCallsSignalProps, rateLimiterOptions: { numRequestsAllowed: 30, intervalTimeInMS: 60000 } }, - { - async post() { - const { signal } = this.bodyParams; - const { userId } = this; - - // if (!(await hasPermissionAsync(userId, 'call-management', roomId))) { - // return API.v1.forbidden(); - // } - - try { - await MediaCall.processSignal(userId, signal); - - return API.v1.success(); - } catch (e) { - return API.v1.failure('error'); - } - }, - }, -); diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index ad6bacba354b2..e427db14487f4 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -1,4 +1,4 @@ -import { Authorization, VideoConf } from '@rocket.chat/core-services'; +import { Authorization, MediaCall, VideoConf } from '@rocket.chat/core-services'; import type { ISubscription, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client'; import { Rooms, Subscriptions, Users, Settings } from '@rocket.chat/models'; @@ -7,6 +7,7 @@ import type { IStreamer, IStreamerConstructor, IPublication } from 'meteor/rocke import type { ImporterProgress } from '../../../app/importer/server/classes/ImporterProgress'; import { emit, StreamPresence } from '../../../app/notifications/server/lib/Presence'; import { SystemLogger } from '../../lib/logger/system'; +import { isClientMediaSignal } from '@rocket.chat/media-signaling-server'; export class NotificationsModule { public readonly streamLogged: IStreamer<'notify-logged'>; @@ -308,6 +309,21 @@ export class NotificationsModule { }); } + if (e === 'media-calls') { + if (!this.userId || !data || typeof data !== 'object') { + return false; + } + + if (!isClientMediaSignal(data)) { + return false; + } + + void MediaCall.processSignal(this.userId, data).catch(() => null); + + // media call signals don't ever need to be broadcasted + return false; + } + return Boolean(this.userId); }); this.streamUser.allowRead(async function (eventName) { diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 9038601f0e262..331c6ee591a46 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,8 +1,8 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { processSignal, createCall, setSignalHandler } from '@rocket.chat/media-signaling-server'; -import { Users } from '@rocket.chat/models'; +import { processSignal, createCall, setSignalHandler, logger } from '@rocket.chat/media-signaling-server'; +import { MediaCalls, Users } from '@rocket.chat/models'; export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; @@ -13,7 +13,11 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { - return processSignal(signal, uid); + try { + await processSignal(signal, uid); + } catch (error) { + logger.error({ msg: 'failed to process client signal', error }); + } } public async createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise { @@ -22,14 +26,14 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('invalid-user'); } - return createCall( - { + return createCall({ + caller: { type: 'user', id: caller.uid, contractId: caller.contractId, }, - { type: 'user', id: callee.uid }, - ); + callee: { type: 'user', id: callee.uid }, + }); } public async callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise { @@ -38,14 +42,14 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('invalid-user'); } - return createCall( - { + return createCall({ + caller: { type: 'user', id: caller.uid, contractId: caller.contractId, }, - { type: 'user', id: user._id }, - ); + callee: { type: 'user', id: user._id }, + }); } public async callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise { @@ -56,4 +60,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { void api.broadcast('user.media-signal', { userId: toUid, signal }); } + + public async hangupEveryCall(hangupReason?: string): Promise { + // change every pending or active call state to 'hangup' with the specified reason + + await MediaCalls.hangupEveryCall({ + endedBy: { type: 'server', id: 'server' }, + reason: hangupReason || 'full-server-hangup', + }); + } } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 6d348dcd55c6a..a8611be291c0a 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -6,4 +6,5 @@ export interface IMediaCallService { createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; + hangupEveryCall(hangupReason?: string): Promise; } diff --git a/packages/media-signaling-server/package.json b/packages/media-signaling-server/package.json index b8a2f433cdc40..b65e173b68fa7 100644 --- a/packages/media-signaling-server/package.json +++ b/packages/media-signaling-server/package.json @@ -27,6 +27,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", - "@rocket.chat/models": "workspace:^" + "@rocket.chat/models": "workspace:^", + "ajv": "^8.17.1" } } diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/packages/media-signaling-server/src/agents/Manager.ts index 952c3e51ead4f..cd1ea1d9816bd 100644 --- a/packages/media-signaling-server/src/agents/Manager.ts +++ b/packages/media-signaling-server/src/agents/Manager.ts @@ -3,6 +3,7 @@ import type { CallHangupReason, CallRole, CallNotification } from '@rocket.chat/ import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; +import { logger } from '../logger'; import type { IMediaCallAgent, IMediaCallAgentFactory, IMediaCallBasicAgent, INewMediaCallAgent } from './definition/IMediaCallAgent'; import { UserAgentFactory } from './users/AgentFactory'; @@ -10,7 +11,7 @@ type FactoryFn = () => Promise; class MediaCallAgentManager { public async getAgentFactoryForActor(actor: MediaCallActor): Promise { - console.log('AgentManager.getAgentFactoryForActor'); + logger.debug({ msg: 'AgentManager.getAgentFactoryForActor', actor }); if (actor.type === 'user') { return UserAgentFactory.getAgentFactoryForActor(actor); } @@ -47,8 +48,9 @@ class MediaCallAgentManager { agent: IMediaCallBasicAgent, params?: Pick, ): Promise { - console.log('AgentManager.getOrCreateContract'); const { role, actorType, actorId, contractId } = agent; + logger.debug({ msg: 'AgentManager.getOrCreateContract', callId, params, role, actorType, actorId, contractId }); + if (!contractId) { throw new Error('error-invalid-contract'); } @@ -81,7 +83,7 @@ class MediaCallAgentManager { } public async hangupCall(agent: IMediaCallAgent, reason: CallHangupReason): Promise { - console.log('AgentManager.hangupCall'); + logger.debug({ msg: 'AgentManager.hangupCall', reason, actor: agent.actor }); const endedBy = agent.actor; const stateResult = await MediaCalls.hangupCallById(agent.callId, { endedBy, reason }); @@ -98,7 +100,7 @@ class MediaCallAgentManager { } public async acknowledgeCallee(agent: IMediaCallAgent): Promise { - console.log('AgentManager.acknowledgeCallee'); + logger.debug({ msg: 'AgentManager.acknowledgeCallee', actor: agent.actor }); if (agent.role !== 'callee') { return; } @@ -107,7 +109,7 @@ class MediaCallAgentManager { } public async acceptCall(agent: IMediaCallAgent): Promise { - console.log('AgentManager.acceptCall'); + logger.debug({ msg: 'AgentManager.acceptCall', actor: agent.actor }); if (agent.role !== 'callee') { return; } @@ -124,10 +126,10 @@ class MediaCallAgentManager { } public async setLocalDescription(agent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { - console.log('agentManager.setLocalDescription'); + logger.debug({ msg: 'AgentManager.setLocalDescription', actor: agent.actor, sdp }); const otherAgent = await this.getOppositeAgent(agent); if (!otherAgent) { - console.log('otherAgent not found'); + logger.debug({ msg: 'Opposite agent not found', method: 'AgentManager.setLocalDescription', role: agent.role, actor: agent.actor }); return; } @@ -135,7 +137,7 @@ class MediaCallAgentManager { } private async getOppositeAgentFactory(call: IMediaCall, agent: IMediaCallAgent): Promise { - console.log('AgentManager.getOppositeAgentFactory'); + logger.debug({ msg: 'AgentManager.getOppositeAgentFactory', actor: agent.actor }); switch (agent.role) { case 'caller': return this.getAgentFactoryForActor(call.callee); @@ -148,9 +150,10 @@ class MediaCallAgentManager { // This will only return an agent if a contract for the other role has already been signed private async getOppositeAgent(agent: IMediaCallAgent): Promise { - console.log('AgentManager.getOppositeAgent'); + logger.debug({ msg: 'AgentManager.getOppositeAgent', actor: agent.actor }); const call = await MediaCalls.findOneById(agent.callId); if (!call) { + logger.debug({ msg: 'call not found', method: 'AgentManager.getOppositeAgent' }); return null; } @@ -158,7 +161,7 @@ class MediaCallAgentManager { } private async getAnyOppositeAgent(agent: IMediaCallAgent): Promise { - console.log('AgentManager.getAnyOppositeAgent'); + logger.debug({ msg: 'AgentManager.getAnyOppositeAgent', actor: agent.actor }); const call = await MediaCalls.findOneById(agent.callId); if (!call) { return null; @@ -173,9 +176,7 @@ class MediaCallAgentManager { } private async hangupByServer(callId: string, serverErrorCode: string, agentsToNotify?: IMediaCallBasicAgent[]): Promise { - console.log('AgentManager.hangupByServer'); - // , errorType?: 'signaling' | 'service' | 'media' - // const hangupReason = errorType ? `${errorType}-error` : 'error'; + logger.debug({ msg: 'AgentManager.hangupByServer', callId, serverErrorCode }); const endedBy = { type: 'server', id: 'server' } as ServerActor; @@ -190,7 +191,7 @@ class MediaCallAgentManager { } private hangupAndThrow(callId: string, error: string, agents?: IMediaCallBasicAgent[]): never { - console.log('AgentManager.hangupAndThrow'); + logger.debug({ msg: 'AgentManager.hangupAndThrow', callId, error }); this.shieldPromise(this.hangupByServer(callId, error, agents)); throw new Error(error); @@ -202,12 +203,12 @@ class MediaCallAgentManager { } private async notifyAll(agents: IMediaCallBasicAgent[], callId: string, notification: CallNotification): Promise { - console.log('AgentManager.notifyAll'); + logger.debug({ msg: 'AgentManager.notifyAll', callId, notification }); await Promise.all(agents.map((agent) => agent.notify(callId, notification))); } private async processAcceptedCall(calleeAgent: IMediaCallAgent): Promise { - console.log('AgentManager.processAcceptedCall'); + logger.debug({ msg: 'AgentManager.processAcceptedCall', callee: calleeAgent.actor }); const { callId } = calleeAgent; const call = await MediaCalls.findOneById(callId); @@ -231,8 +232,7 @@ class MediaCallAgentManager { // #ToDo: handle cases where an actor's service role doesn't match the call role if (offer.type !== 'offer') { - console.log('caller sdp is not an offer'); - // logger.error({ msg: 'The local description of a caller channel is not an offer', local: offer }); + logger.warn({ msg: 'caller sdp is not an offer', local: offer }); throw new Error('unexpected-state'); } diff --git a/packages/media-signaling-server/src/agents/users/Agent.ts b/packages/media-signaling-server/src/agents/users/Agent.ts index ae706c1fc8e60..3566d9e95feb4 100644 --- a/packages/media-signaling-server/src/agents/users/Agent.ts +++ b/packages/media-signaling-server/src/agents/users/Agent.ts @@ -3,6 +3,7 @@ import type { ClientMediaSignal, CallRole } from '@rocket.chat/media-signaling'; import { UserBasicAgent, type MinimalUserData } from './BasicAgent'; import { UserAgentSignalProcessor } from './SignalProcessor'; +import { logger } from '../../logger'; import { agentManager } from '../Manager'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; @@ -34,7 +35,7 @@ export class UserMediaCallAgent extends UserBasicAgent implemen } public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { - console.log('UserAgent.setRemoteDescription'); + logger.debug({ msg: 'UserMediaCallAgent.setRemoteDescription', sdp }); await this.sendSignal({ callId: this.callId, toContractId: this.contractId, @@ -44,16 +45,16 @@ export class UserMediaCallAgent extends UserBasicAgent implemen } public async getLocalDescription(): Promise { - console.log('UserAgent.getRemoteDescription'); + logger.debug({ msg: 'UserMediaCallAgent.getRemoteDescription' }); const channel = await agentManager.getOrCreateContract(this.callId, this); return channel?.localDescription ?? null; } public async requestOffer(params: { iceRestart?: boolean }): Promise { - console.log('UserAgent.requestOffer'); - // #ToDo: this function may be called multiple times for the same call until an offer is provided; look into how to handle that + logger.debug({ msg: 'UserMediaCallAgent.requestOffer', params }); + // #ToDo: this function may be called multiple times for the same call until an offer is provided; look into how to handle that await this.sendSignal({ callId: this.callId, toContractId: this.contractId, diff --git a/packages/media-signaling-server/src/agents/users/AgentFactory.ts b/packages/media-signaling-server/src/agents/users/AgentFactory.ts index d7c8e3fd5d9b3..f809e7ddd304a 100644 --- a/packages/media-signaling-server/src/agents/users/AgentFactory.ts +++ b/packages/media-signaling-server/src/agents/users/AgentFactory.ts @@ -6,6 +6,7 @@ import type { MinimalUserData } from './BasicAgent'; import { agentManager } from '../Manager'; import { UserMediaCallAgent } from './Agent'; import { UserNewCallAgent } from './NewCallAgent'; +import { logger } from '../../logger'; import type { IMediaCallAgentFactory } from '../definition/IMediaCallAgent'; // Overriding the interface to use the same factory for the AgentManager class and the processSignal function @@ -22,7 +23,7 @@ export class UserAgentFactory { }); if (!user?.username) { - console.log('invalid user or no username'); + logger.debug({ msg: 'invalid user or no username', method: 'UserAgentFactory.getAgentFactoryForUser', userId, contractId }); return null; } @@ -36,13 +37,13 @@ export class UserAgentFactory { const { _id: callId } = call; if (!contractId) { - console.log('no contractId'); + logger.debug({ msg: 'no contractId', method: 'UserAgentFactory.getCallAgent', userId, contractId, callId }); return null; } const role = agentManager.getRoleForCallActor(call, { type: 'user', id: userId }); if (!role) { - console.log('no role'); + logger.debug({ msg: 'no role', method: 'UserAgentFactory.getCallAgent', userId, contractId, callId }); return null; } @@ -51,7 +52,13 @@ export class UserAgentFactory { // If the role is already signed with a different contract, do not create the agent if (contractSigned && callActor.contractId !== contractId) { - console.log('signed by another contract'); + logger.debug({ + msg: 'signed by another contract', + method: 'UserAgentFactory.getAgentFactoryForUser', + userId, + contractId, + callActor, + }); return null; } diff --git a/packages/media-signaling-server/src/agents/users/BasicAgent.ts b/packages/media-signaling-server/src/agents/users/BasicAgent.ts index a229480efe606..f31c5e42aa31e 100644 --- a/packages/media-signaling-server/src/agents/users/BasicAgent.ts +++ b/packages/media-signaling-server/src/agents/users/BasicAgent.ts @@ -1,6 +1,7 @@ import type { IUser, MediaCallActor } from '@rocket.chat/core-typings'; import type { CallContact, CallRole, ServerMediaSignal, CallNotification } from '@rocket.chat/media-signaling'; +import { logger } from '../../logger'; import { sendSignal } from '../../signalHandler'; import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; import { MediaCallBasicAgent } from '../definition/IMediaCallAgent'; @@ -34,7 +35,7 @@ export class UserBasicAgent } public async notify(callId: string, notification: CallNotification): Promise { - console.log('UserBasicAgent.notify'); + logger.debug({ msg: 'UserBasicAgent.notify', callId, notification }); return this.sendSignal({ callId, type: 'notification', diff --git a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts index 6b3b568f6cc22..d858f700908d8 100644 --- a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts +++ b/packages/media-signaling-server/src/agents/users/NewCallAgent.ts @@ -1,12 +1,13 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; import { UserBasicAgent } from './BasicAgent'; +import { logger } from '../../logger'; import { agentManager } from '../Manager'; import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; export class UserNewCallAgent extends UserBasicAgent implements INewMediaCallAgent { public async onNewCall(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { - console.log('UserNewCallAgent.onNewCall'); + logger.debug({ msg: 'UserNewCallAgent.onNewCall', call, contractId: this.contractId }); // If we have a contract id, ensure the contract is registered if (this.contractId) { await agentManager.getOrCreateContract(call._id, this); @@ -16,7 +17,7 @@ export class UserNewCallAgent extends UserBasicAgent implements INewMediaCallAge } private async sendNewSignal(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { - console.log('UserNewCallAgent.sendNewSignal'); + logger.debug({ msg: 'UserNewCallAgent.sendNewSignal', call }); const contact = await otherAgent.getContactInfo(); return this.sendSignal({ diff --git a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts index ef3f0bab0330e..eab3316ef9403 100644 --- a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts +++ b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts @@ -3,6 +3,7 @@ import type { CallAnswer, ClientMediaSignal } from '@rocket.chat/media-signaling import { MediaCallChannels } from '@rocket.chat/models'; import type { UserMediaCallAgent } from './Agent'; +import { logger } from '../../logger'; import { agentManager } from '../Manager'; type ChannelFunctionParams = { @@ -14,7 +15,7 @@ export class UserAgentSignalProcessor { constructor(private readonly agent: UserMediaCallAgent) {} public async processSignal(signal: ClientMediaSignal, call: IMediaCall): Promise { - console.log('SignalProcessor.processSignal'); + logger.debug({ msg: 'UserAgentSignalProcessor.processSignal', call, signal }); const channel = await agentManager.getOrCreateContract(call._id, this.agent, { acknowledged: true }); if (!channel) { @@ -45,14 +46,14 @@ export class UserAgentSignalProcessor { } private async saveLocalDescription({ channel }: ChannelFunctionParams, sdp: RTCSessionDescriptionInit): Promise { - console.log('SignalProcessor.saveLocalDescription'); + logger.debug({ msg: 'UserAgentSignalProcessor.saveLocalDescription', sdp }); await MediaCallChannels.setLocalDescription(channel._id, sdp); await agentManager.setLocalDescription(this.agent, sdp); } private async processAnswer(params: ChannelFunctionParams, answer: CallAnswer): Promise { - console.log('processAnswer'); + logger.debug({ msg: 'UserAgentSignalProcessor.processAnswer', params, answer }); const { call } = params; switch (answer) { @@ -69,13 +70,12 @@ export class UserAgentSignalProcessor { } private async processReject(call: IMediaCall): Promise { - console.log('SignalProcessor.processReject'); + logger.debug({ msg: 'UserAgentSignalProcessor.processReject', call }); if (this.agent.role !== 'callee') { return; } if (!['none', 'ringing'].includes(call.state)) { - console.log('cant reject an ongoing call.'); return; } @@ -83,6 +83,7 @@ export class UserAgentSignalProcessor { } private async processACK({ channel }: ChannelFunctionParams): Promise { + logger.debug({ msg: 'UserAgentSignalProcessor.processACK' }); switch (this.agent.role) { case 'callee': // Change the call state from 'none' to 'ringing' when any callee session is found diff --git a/packages/media-signaling-server/src/createCall.ts b/packages/media-signaling-server/src/createCall.ts index 9e82de988bc43..5292719156483 100644 --- a/packages/media-signaling-server/src/createCall.ts +++ b/packages/media-signaling-server/src/createCall.ts @@ -3,8 +3,9 @@ import type { CallService } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { agentManager } from './agents/Manager'; +import { logger } from './logger'; -export type CreateCallParams = { +type CreateCallParams = { caller: MediaCallSignedActor; callee: MediaCallActor; requestedCallId?: string; @@ -12,7 +13,7 @@ export type CreateCallParams = { }; export async function createCall(params: CreateCallParams): Promise { - console.log('createCall', params); + logger.debug({ msg: 'createCall', params }); const { caller, callee, requestedCallId, requestedService } = params; // The caller must always have a contract to create the call diff --git a/packages/media-signaling-server/src/hangupEveryCall.ts b/packages/media-signaling-server/src/hangupEveryCall.ts deleted file mode 100644 index d8a8fb493c177..0000000000000 --- a/packages/media-signaling-server/src/hangupEveryCall.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { MediaCalls } from '@rocket.chat/models'; - -export async function hangupEveryCall(hangupReason?: string): Promise { - // change every pending or active call state to 'hangup' with the specified reason - - await MediaCalls.hangupEveryCall({ - endedBy: { type: 'server', id: 'server' }, - reason: hangupReason || 'full-server-hangup', - }); -} diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index 3fff0dbb09f21..d057d4bf8ecae 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1,4 +1,5 @@ export { createCall } from './createCall'; export { processSignal } from './processSignal'; +export { isClientMediaSignal } from './isClientMediaSignal'; export { setSignalHandler } from './signalHandler'; export { logger } from './logger'; diff --git a/packages/media-signaling-server/src/isClientMediaSignal.ts b/packages/media-signaling-server/src/isClientMediaSignal.ts new file mode 100644 index 0000000000000..e817be9d03b7e --- /dev/null +++ b/packages/media-signaling-server/src/isClientMediaSignal.ts @@ -0,0 +1,9 @@ +import { clientMediaSignalSchema, type ClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { JSONSchemaType } from 'ajv'; +import Ajv from 'ajv'; + +const ajv = new Ajv({ discriminator: true }); + +const schema: JSONSchemaType = clientMediaSignalSchema; + +export const isClientMediaSignal = ajv.compile(schema); diff --git a/packages/media-signaling-server/src/processNewCallSignal.ts b/packages/media-signaling-server/src/processNewCallSignal.ts index cbe7d249f6698..d4eb251bb670b 100644 --- a/packages/media-signaling-server/src/processNewCallSignal.ts +++ b/packages/media-signaling-server/src/processNewCallSignal.ts @@ -3,6 +3,7 @@ import type { ClientMediaSignalRequestCall, ServerMediaSignalRejectedCallRequest import { MediaCalls } from '@rocket.chat/models'; import { createCall } from './createCall'; +import { logger } from './logger'; import { mutateCallee } from './mutateCallee'; import { sendSignal } from './signalHandler'; @@ -23,7 +24,7 @@ async function getExistingCall( return null; } - console.log('getExistingCall'); + logger.debug({ msg: 'getExistingCall', uid, signal, callee }); const caller = { type: 'user', id: uid } as const; const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; @@ -74,7 +75,7 @@ async function getExistingCall( } export async function processNewCallSignal(signal: ClientMediaSignalRequestCall, uid: IUser['_id']): Promise { - console.log('processNewCallSignal'); + logger.debug({ msg: 'processNewCallSignal', signal, uid }); const caller = { type: 'user', id: uid } as const; const callee = await mutateCallee(signal.callee); diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts index 20810c37cf96d..439bd9d49b90f 100644 --- a/packages/media-signaling-server/src/processSignal.ts +++ b/packages/media-signaling-server/src/processSignal.ts @@ -3,10 +3,11 @@ import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { UserAgentFactory } from './agents/users/AgentFactory'; +import { logger } from './logger'; import { processNewCallSignal } from './processNewCallSignal'; export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id']): Promise { - console.log('server.processSignal', signal, uid); + logger.debug({ msg: 'processSignal', signal, uid }); if (signal.type === 'request-call') { return processNewCallSignal(signal, uid); } @@ -14,19 +15,20 @@ export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id'] try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { - console.log('call not found', signal.type); + logger.error({ msg: 'call not found', method: 'processSignal', signal }); throw new Error('invalid-call'); } const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); const agent = factory?.getCallAgent(call); if (!agent) { + logger.error({ msg: 'agent not found', method: 'processSignal', signal }); throw new Error('invalid-call'); } await agent.processSignal(signal, call); } catch (e) { - console.log(e); + logger.error(e); throw e; } } diff --git a/packages/media-signaling-server/src/signalHandler.ts b/packages/media-signaling-server/src/signalHandler.ts index 5041606cbf28c..411e3e63858e5 100644 --- a/packages/media-signaling-server/src/signalHandler.ts +++ b/packages/media-signaling-server/src/signalHandler.ts @@ -1,6 +1,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; +import { logger } from './logger'; + export type SignalHandler = (uid: IUser['_id'], signal: ServerMediaSignal) => void; let handler: SignalHandler | null = null; @@ -10,7 +12,7 @@ export function setSignalHandler(handlerFn: SignalHandler): void { } export async function sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { - console.log('sendSignal', toUid, signal); + logger.debug({ msg: 'sendSignal', toUid, signal }); if (!handler) { throw new Error('media-signaling-server-not-configured'); diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts index 95e39a1ae451a..ca73b9f999038 100644 --- a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -1,64 +1,9 @@ -import type { CallAnswer, CallHangupReason, CallService, CallState } from '../../call'; -import type { ClientState } from '../../client'; - -export type ClientMediaSignalRequestCall = { - // the callId on this signal is temporary and is never propagated to other agents - callId: string; - contractId: string; - type: 'request-call'; - callee: { - type: 'user' | 'sip'; - id: string; - }; - supportedServices: CallService[]; -}; - -// Client is sending the local session description to the server -export type ClientMediaSignalLocalSDP = { - callId: string; - contractId: string; - type: 'local-sdp'; - - sdp: RTCSessionDescriptionInit; -}; - -// Client is reporting an error -export type ClientMediaSignalError = { - callId: string; - contractId: string; - type: 'error'; - - errorCode: string; -}; - -// Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached -export type ClientMediaSignalAnswer = { - callId: string; - type: 'answer'; - contractId: string; - - answer: CallAnswer; -}; - -// Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error -export type ClientMediaSignalHangup = { - callId: string; - contractId: string; - type: 'hangup'; - - reason: CallHangupReason; -}; - -// Client is sending their local call state -export type ClientMediaSignalLocalState = { - callId: string; - contractId: string; - type: 'local-state'; - - callState: CallState; - clientState: ClientState; - serviceStates?: Record; -}; +import { clientMediaSignalAnswerSchema, type ClientMediaSignalAnswer } from './answer'; +import { clientMediaSignalErrorSchema, type ClientMediaSignalError } from './error'; +import { clientMediaSignalHangupSchema, type ClientMediaSignalHangup } from './hangup'; +import { clientMediaSignalLocalSDPSchema, type ClientMediaSignalLocalSDP } from './local-sdp'; +import { clientMediaSignalLocalStateSchema, type ClientMediaSignalLocalState } from './local-state'; +import { clientMediaSignalRequestCallSchema, type ClientMediaSignalRequestCall } from './request-call'; export type ClientMediaSignal = | ClientMediaSignalLocalSDP @@ -68,6 +13,21 @@ export type ClientMediaSignal = | ClientMediaSignalRequestCall | ClientMediaSignalLocalState; +export const clientMediaSignalSchema = { + type: 'object', + additionalProperties: true, + discriminator: { propertyName: 'type' }, + required: ['type'], + oneOf: [ + clientMediaSignalLocalSDPSchema, + clientMediaSignalErrorSchema, + clientMediaSignalAnswerSchema, + clientMediaSignalHangupSchema, + clientMediaSignalRequestCallSchema, + clientMediaSignalLocalStateSchema, + ], +} as const; + export type ClientMediaSignalType = ClientMediaSignal['type']; type ExtractMediaSignal = T extends { type: K } ? T : never; diff --git a/packages/media-signaling/src/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts new file mode 100644 index 0000000000000..42709b8b66eaf --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -0,0 +1,33 @@ +import type { CallAnswer } from '../../call'; + +// Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached +export type ClientMediaSignalAnswer = { + callId: string; + type: 'answer'; + contractId: string; + + answer: CallAnswer; +}; + +export const clientMediaSignalAnswerSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'answer', + }, + answer: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'answer'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/client/error.ts b/packages/media-signaling/src/definition/signals/client/error.ts new file mode 100644 index 0000000000000..f7e9de3f0ec35 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/error.ts @@ -0,0 +1,31 @@ +// Client is reporting an error +export type ClientMediaSignalError = { + callId: string; + contractId: string; + type: 'error'; + + errorCode: string; +}; + +export const clientMediaSignalErrorSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'error', + }, + errorCode: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'errorCode'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/client/hangup.ts b/packages/media-signaling/src/definition/signals/client/hangup.ts new file mode 100644 index 0000000000000..c11ac2e8aaa41 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/hangup.ts @@ -0,0 +1,33 @@ +import type { CallHangupReason } from '../../call'; + +// Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error +export type ClientMediaSignalHangup = { + callId: string; + contractId: string; + type: 'hangup'; + + reason: CallHangupReason; +}; + +export const clientMediaSignalHangupSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'hangup', + }, + reason: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'reason'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/client/index.ts b/packages/media-signaling/src/definition/signals/client/index.ts new file mode 100644 index 0000000000000..03261aba4025d --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/index.ts @@ -0,0 +1,8 @@ +export * from './answer'; +export * from './error'; +export * from './hangup'; +export * from './local-sdp'; +export * from './local-state'; +export * from './request-call'; + +export * from './MediaSignal'; diff --git a/packages/media-signaling/src/definition/signals/client/local-sdp.ts b/packages/media-signaling/src/definition/signals/client/local-sdp.ts new file mode 100644 index 0000000000000..003430e307703 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/local-sdp.ts @@ -0,0 +1,43 @@ +// Client is sending the local session description to the server +export type ClientMediaSignalLocalSDP = { + callId: string; + contractId: string; + type: 'local-sdp'; + + sdp: RTCSessionDescriptionInit; +}; + +export const clientMediaSignalLocalSDPSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'local-sdp', + }, + sdp: { + type: 'object', + properties: { + sdp: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + nullable: false, + required: ['type'], + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'sdp'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/client/local-state.ts b/packages/media-signaling/src/definition/signals/client/local-state.ts new file mode 100644 index 0000000000000..1342f3676207b --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/local-state.ts @@ -0,0 +1,49 @@ +import type { CallState } from '../../call'; +import type { ClientState } from '../../client'; + +// Client is sending their local call state +export type ClientMediaSignalLocalState = { + callId: string; + contractId: string; + type: 'local-state'; + + callState: CallState; + clientState: ClientState; + serviceStates?: Record; +}; + +export const clientMediaSignalLocalStateSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'local-state', + }, + callState: { + type: 'string', + nullable: false, + }, + clientState: { + type: 'string', + nullable: false, + }, + serviceStates: { + type: 'object', + patternProperties: { + '.*': { + type: 'string', + }, + }, + nullable: true, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'callState', 'clientState'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/client/request-call.ts b/packages/media-signaling/src/definition/signals/client/request-call.ts new file mode 100644 index 0000000000000..6604319a3d526 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/request-call.ts @@ -0,0 +1,54 @@ +import type { CallService } from '../../call'; + +export type ClientMediaSignalRequestCall = { + // the callId on this signal is temporary and is never propagated to other agents + callId: string; + contractId: string; + type: 'request-call'; + callee: { + type: 'user' | 'sip'; + id: string; + }; + supportedServices: CallService[]; +}; + +export const clientMediaSignalRequestCallSchema = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + const: 'request-call', + }, + callee: { + type: 'object', + properties: { + type: { + type: 'string', + nullable: false, + }, + id: { + type: 'string', + nullable: false, + }, + }, + required: ['type', 'id'], + additionalProperties: false, + }, + supportedServices: { + type: 'array', + items: { + type: 'string', + }, + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'callee', 'supportedServices'], +} as const; diff --git a/packages/media-signaling/src/definition/signals/index.ts b/packages/media-signaling/src/definition/signals/index.ts index 2ff3e344d570b..36a8a58d17248 100644 --- a/packages/media-signaling/src/definition/signals/index.ts +++ b/packages/media-signaling/src/definition/signals/index.ts @@ -1,3 +1,3 @@ -export * from './client/MediaSignal'; -export * from './server/MediaSignal'; +export * from './client'; +export * from './server'; export * from './Transport'; diff --git a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts index 0c19167bf5558..b75e5fb04a808 100644 --- a/packages/media-signaling/src/definition/signals/server/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/server/MediaSignal.ts @@ -1,51 +1,8 @@ -import type { CallContact, CallNotification, CallRejectedReason, CallRole, CallService } from '../../call'; - -// Sent by the server to notify an agent that there's a new call for their actor -export type ServerMediaSignalNewCall = { - callId: string; - type: 'new'; - - service: CallService; - kind: 'direct'; - role: CallRole; - contact: CallContact; - - // This will only be sent to the caller, with the id it used to request this call - requestedCallId?: string; -}; - -// Server is sending the other actor's sdp -export type ServerMediaSignalRemoteSDP = { - callId: string; - toContractId: string; - type: 'remote-sdp'; - - sdp: RTCSessionDescriptionInit; -}; - -// Server is requesting a webrtc offer -export type ServerMediaSignalRequestOffer = { - callId: string; - toContractId: string; - type: 'request-offer'; - - iceRestart?: boolean; -}; - -// Server is sending a notification about the call state -export type ServerMediaSignalNotification = { - callId: string; - type: 'notification'; - - notification: CallNotification; -}; - -export type ServerMediaSignalRejectedCallRequest = { - callId: string; - type: 'rejected-call-request'; - toContractId: string; - reason?: CallRejectedReason; -}; +import type { ServerMediaSignalNewCall } from './new'; +import type { ServerMediaSignalNotification } from './notification'; +import type { ServerMediaSignalRejectedCallRequest } from './rejected-call-request'; +import type { ServerMediaSignalRemoteSDP } from './remote-sdp'; +import type { ServerMediaSignalRequestOffer } from './request-offer'; export type ServerMediaSignal = | ServerMediaSignalNewCall diff --git a/packages/media-signaling/src/definition/signals/server/index.ts b/packages/media-signaling/src/definition/signals/server/index.ts new file mode 100644 index 0000000000000..7516765b8982b --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/index.ts @@ -0,0 +1,7 @@ +export * from './new'; +export * from './notification'; +export * from './rejected-call-request'; +export * from './remote-sdp'; +export * from './request-offer'; + +export * from './MediaSignal'; diff --git a/packages/media-signaling/src/definition/signals/server/new.ts b/packages/media-signaling/src/definition/signals/server/new.ts new file mode 100644 index 0000000000000..93bb05184227b --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/new.ts @@ -0,0 +1,15 @@ +import type { CallContact, CallRole, CallService } from '../../call'; + +// Sent by the server to notify an agent that there's a new call for their actor +export type ServerMediaSignalNewCall = { + callId: string; + type: 'new'; + + service: CallService; + kind: 'direct'; + role: CallRole; + contact: CallContact; + + // This will only be sent to the caller, with the id it used to request this call + requestedCallId?: string; +}; diff --git a/packages/media-signaling/src/definition/signals/server/notification.ts b/packages/media-signaling/src/definition/signals/server/notification.ts new file mode 100644 index 0000000000000..7e3db7f43329b --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/notification.ts @@ -0,0 +1,9 @@ +import type { CallNotification } from '../../call'; + +// Server is sending a notification about the call state +export type ServerMediaSignalNotification = { + callId: string; + type: 'notification'; + + notification: CallNotification; +}; diff --git a/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts b/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts new file mode 100644 index 0000000000000..d0c66a5fccb52 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts @@ -0,0 +1,8 @@ +import type { CallRejectedReason } from '../../call'; + +export type ServerMediaSignalRejectedCallRequest = { + callId: string; + type: 'rejected-call-request'; + toContractId: string; + reason?: CallRejectedReason; +}; diff --git a/packages/media-signaling/src/definition/signals/server/remote-sdp.ts b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts new file mode 100644 index 0000000000000..8f13ea14e47ce --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts @@ -0,0 +1,8 @@ +// Server is sending the other actor's sdp +export type ServerMediaSignalRemoteSDP = { + callId: string; + toContractId: string; + type: 'remote-sdp'; + + sdp: RTCSessionDescriptionInit; +}; diff --git a/packages/media-signaling/src/definition/signals/server/request-offer.ts b/packages/media-signaling/src/definition/signals/server/request-offer.ts new file mode 100644 index 0000000000000..b649e52ad9fd8 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/server/request-offer.ts @@ -0,0 +1,8 @@ +// Server is requesting a webrtc offer +export type ServerMediaSignalRequestOffer = { + callId: string; + toContractId: string; + type: 'request-offer'; + + iceRestart?: boolean; +}; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index b90a1dc1e21ed..7de362d43dcdb 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -21,7 +21,7 @@ import type { ServerMediaSignalNotification, ServerMediaSignalRemoteSDP, ServerMediaSignalRequestOffer, -} from '../definition/signals/server/MediaSignal'; +} from '../definition/signals/server'; export interface IClientMediaCallConfig { transporter: MediaSignalTransportWrapper; @@ -186,7 +186,6 @@ export class ClientMediaCall implements IClientMediaCall { return; } - console.log('call.initializeRemoteCall', signal.callId); this.remoteCallId = signal.callId; const wasInitialized = this.initialized; @@ -266,8 +265,6 @@ export class ClientMediaCall implements IClientMediaCall { const { type: signalType } = signal; - console.log('ClientMediaCall.processSignal', signalType); - if (signalType === 'new') { return this.initializeRemoteCall(signal); } @@ -285,12 +282,9 @@ export class ClientMediaCall implements IClientMediaCall { case 'notification': return this.processNotification(signal); } - - console.log('signal ignored, as its type is not handled by this agent', signalType); } public async accept(): Promise { - console.log('call.accept'); if (!this.isPendingOurAcceptance()) { throw new Error('call-not-pending-acceptance'); } @@ -311,7 +305,6 @@ export class ClientMediaCall implements IClientMediaCall { } public async reject(): Promise { - console.log('call.reject'); if (!this.isPendingOurAcceptance()) { throw new Error('call-not-pending-acceptance'); } @@ -325,7 +318,6 @@ export class ClientMediaCall implements IClientMediaCall { } public async hangup(reason: CallHangupReason = 'normal'): Promise { - console.log('call.hangup'); if (this.endedLocally || this._state === 'hangup') { return; } @@ -359,8 +351,6 @@ export class ClientMediaCall implements IClientMediaCall { return; } - console.log('call.changeState', newState); - const oldState = this._state; this._state = newState; this.updateClientState(); @@ -403,7 +393,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processOfferRequest(signal: ServerMediaSignalRequestOffer) { - console.log('call.processOfferRequest'); if (!signal.toContractId) { console.error('Received an untargeted offer request.'); return; @@ -446,7 +435,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processAnswerRequest(signal: ServerMediaSignalRemoteSDP): Promise { - console.log('Call.processAnswerRequest'); if (this.shouldIgnoreWebRTC()) { return; } @@ -471,7 +459,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processRemoteSDP(signal: ServerMediaSignalRemoteSDP): Promise { - console.log('Call.processRemoteSDP'); if (!signal.toContractId) { console.error('Received untargeted SDP signal'); return; @@ -491,7 +478,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async deliverSdp(data: { sdp: RTCSessionDescriptionInit }) { - console.log('Call.deliverSdp'); this.hasLocalDescription = true; this.config.transporter.sendToServer(this.callId, 'local-sdp', data); @@ -500,8 +486,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async rejectAsUnavailable(): Promise { - console.log('call.rejectAsUnavailable'); - // If we have already told the server we accept this call, then we need to send a hangup to get out of it if (this.acceptedLocally) { return this.hangup('unavailable'); @@ -512,7 +496,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processEarlySignals(): Promise { - console.log('call.processEarlySignals'); const earlySignals = this.earlySignals.values().toArray(); this.earlySignals.clear(); @@ -526,7 +509,6 @@ export class ClientMediaCall implements IClientMediaCall { } protected acknowledge(): void { - console.log('call.acknowledge'); if (this.acknowledged) { return; } @@ -540,21 +522,15 @@ export class ClientMediaCall implements IClientMediaCall { } private async processNotification(signal: ServerMediaSignalNotification) { - console.log('Call.processNotification', signal.notification); - switch (signal.notification) { case 'accepted': return this.flagAsAccepted(); case 'hangup': return this.flagAsEnded('remote'); } - - console.log('notification ignored as its type is not handled by this agent', signal.notification); } private async flagAsAccepted(): Promise { - console.log('flagAsAccepted'); - if (!this.acceptedLocally) { // #ToDo: test this situation; remove exception, read this response on the server this.config.transporter.sendError(this.callId, 'not-accepted'); @@ -569,8 +545,6 @@ export class ClientMediaCall implements IClientMediaCall { } private flagAsEnded(reason: CallHangupReason): void { - console.log('flagAsEnded'); - if (this._state === 'hangup') { return; } @@ -585,8 +559,6 @@ export class ClientMediaCall implements IClientMediaCall { return; } - console.log(`adding a timeout of ${timeout / 1000} seconds to the state [${state}]`); - const handler = { state, handler: setTimeout(() => { @@ -598,8 +570,6 @@ export class ClientMediaCall implements IClientMediaCall { return; } - console.log(`reached timeout for the [${state}] state.`); - if (callback) { callback(); } else { @@ -628,7 +598,6 @@ export class ClientMediaCall implements IClientMediaCall { if (!this.webrtcProcessor) { return; } - console.log('webrtc internal state changed: ', stateName); const stateValue = this.webrtcProcessor.getInternalState(stateName); if (this.serviceStates.get(stateName) !== stateValue) { @@ -677,8 +646,6 @@ export class ClientMediaCall implements IClientMediaCall { return; } - console.log('session.createWebRtcProcessor'); - const { mediaStreamFactory, processorFactories: { webrtc: webrtcFactory }, diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 47580bcf3bd57..7b12c21ea9cec 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -90,7 +90,6 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: ServerMediaSignal): Promise { - console.log('session.processSignal', signal.type); if (this.isSignalTargetingAnotherSession(signal) || this.isCallIgnored(signal.callId)) { return; } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index e65b2a1d842dc..dc639d9e2365e 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -35,7 +35,6 @@ export class MediaSignalTransportWrapper { } public sendSignal(signal: ClientMediaSignal): void { - console.log('MediaSignalTransport.sendSignal', signal); this.sendSignalFn(signal); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index 41aeec8a0e0af..abea0c4733685 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -3,12 +3,10 @@ import { Stream } from './Stream'; export class LocalStream extends Stream { public async setTrack(newTrack: MediaStreamTrack, peer: RTCPeerConnection): Promise { if (newTrack.kind === 'video') { - console.log('skipping video track'); return; } if (newTrack.kind !== 'audio') { - console.log(newTrack); throw new Error('Unsupported track kind'); } @@ -19,10 +17,8 @@ export class LocalStream extends Stream { return; } - console.log('Replacing sender track'); await sender.replaceTrack(newTrack); - console.log('Replacing track on local media stream'); const oldTrack = this.mediaStream.getTracks().find((localTrack) => localTrack.kind === newTrack.kind); if (oldTrack) { oldTrack.stop(); diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 903fd86237fda..63408fb7c8a58 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -51,21 +51,17 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }> { - console.log('processor.createOffer'); await this.initializeLocalMediaStream(); if (iceRestart) { this.restartIce(); } - console.log('peer.createOffer'); // #ToDo: direction changes const offer = await this.peer.createOffer(); - console.log('setLocalDescription'); await this.peer.setLocalDescription(offer); - console.log('return getLocalDescription'); return this.getLocalDescription(); } @@ -88,13 +84,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async setRemoteDescription({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { - console.log('setRemoteDescription'); await this.initializeLocalMediaStream(); // #ToDo: validate this.peer.signalingState ? - - console.log('peer.setRemoteDescription'); - this.peer.setRemoteDescription(sdp); } @@ -125,21 +117,17 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { - console.log('getLocalDescription'); await this.waitForIceGathering(); - console.log('waited'); + // always wait a little extra to ensure all relevant events have been fired await new Promise((resolve) => setTimeout(resolve, 30)); - console.log('extra waited'); const sdp = this.peer.localDescription; if (!sdp) { - console.log('no-local-sdp'); throw new Error('no-local-sdp'); } - console.log('return sdp'); return { sdp, }; @@ -151,7 +139,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } if (this.peer.iceGatheringState === 'complete') { - console.log('ice gathering already complete'); return; } @@ -166,13 +153,10 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { const iceGatheringData = data as IceGatheringData; data.timeout = setTimeout(() => { - console.log('timeout'); if (this.iceGatheringWaiters.has(iceGatheringData)) { this.clearIceGatheringData(iceGatheringData); this.iceGatheringTimedOut = true; this.changeInternalState('iceUntrickler'); - } else { - console.log('ice gathering not on the list'); } }, 500); @@ -208,16 +192,13 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } console.log('onIceCandidate event', event.candidate); - // if (event.candidate) { - // this.localCandidates.add(event.candidate); - // } } - private onIceCandidateError(peer: RTCPeerConnection, _event: RTCPeerConnectionIceErrorEvent) { + private onIceCandidateError(peer: RTCPeerConnection, event: RTCPeerConnectionIceErrorEvent) { if (peer !== this.peer) { return; } - console.log('onIceCandidate ERROR event'); + console.error('onIceCandidate ERROR event', event.errorCode, event.errorText); this.emitter.emit('internalError', { critical: false, error: 'ice-candidate-error' }); } @@ -225,7 +206,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (peer !== this.peer) { return; } - console.log('onNegotiationNeeded event'); + // #ToDo negotiation needed + console.log('negotiation-needed'); } private onTrack(peer: RTCPeerConnection, event: RTCTrackEvent): void { @@ -233,8 +215,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } // Received a remote stream - console.log('ontrack', event.track.kind, event.track.enabled); - this.remoteStream.setTrack(event.track, peer); } @@ -243,7 +223,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('Connection state change', peer.connectionState); this.changeInternalState('connection'); } @@ -252,7 +231,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('Ice connection state change', peer.iceConnectionState); this.changeInternalState('iceConnection'); } @@ -261,7 +239,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('Signaling state change', peer.signalingState); this.changeInternalState('signaling'); } @@ -270,8 +247,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('Ice gathering state change', peer.iceGatheringState); - if (peer.iceGatheringState === 'complete') { this.onIceGatheringComplete(); } @@ -292,25 +267,21 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private onIceGatheringComplete() { - console.log('onIceGatheringComplete'); this.iceGatheringFinished = true; this.clearIceGatheringWaiters(); } private clearIceGatheringData(iceGatheringData: IceGatheringData, error?: Error) { - console.log('clearIceGatheringData'); if (this.iceGatheringWaiters.has(iceGatheringData)) { this.iceGatheringWaiters.delete(iceGatheringData); } if (iceGatheringData.timeout) { - console.log('clearTimeout'); clearTimeout(iceGatheringData.timeout); } if (error) { - console.log('reject wait promise'); if (iceGatheringData.promiseReject) { iceGatheringData.promiseReject(error); } @@ -318,14 +289,12 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('resolve wait promise'); if (iceGatheringData.promiseResolve) { iceGatheringData.promiseResolve(); } } private clearIceGatheringWaiters(error?: Error) { - console.log('clear waiters'); const waiters = this.iceGatheringWaiters.values().toArray(); this.iceGatheringWaiters.clear(); this.iceGatheringTimedOut = false; diff --git a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts index 34e076bca4096..e8638b608241b 100644 --- a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts @@ -2,15 +2,11 @@ import { Stream } from './Stream'; export class RemoteStream extends Stream { public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { - console.log('setRemoteTrack'); - if (this.mediaStream.getTrackById(newTrack.id)) { - console.log('remote track already set'); return; } if (newTrack.kind !== 'audio') { - console.log('received non-audio track: ', newTrack.kind); return; } diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 1f2e18bffdaa9..2b1e2e8faaba0 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -31,7 +31,6 @@ import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MailerEndpoints } from './v1/mailer'; import type { MeEndpoints } from './v1/me'; -import type { MediaCallsEndpoints } from './v1/media-calls'; import type { MiscEndpoints } from './v1/misc'; import type { ModerationEndpoints } from './v1/moderation'; import type { OAuthAppsEndpoint } from './v1/oauthapps'; @@ -102,7 +101,6 @@ export interface Endpoints ImportEndpoints, VoipFreeSwitchEndpoints, ServerEventsEndpoints, - MediaCallsEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -222,7 +220,6 @@ export * from './v1/roles'; export * from './v1/settings'; export * from './v1/teams'; export * from './v1/videoConference'; -export * from './v1/media-calls'; export * from './v1/assets'; export * from './v1/channels'; export * from './v1/customUserStatus'; diff --git a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts b/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts deleted file mode 100644 index b2534d648e5ca..0000000000000 --- a/packages/rest-typings/src/v1/media-calls/MediaCallsSignalProps.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; -import type { JSONSchemaType } from 'ajv'; -import Ajv from 'ajv'; - -const ajv = new Ajv({ discriminator: true }); - -export type MediaCallsSignalProps = { signal: ClientMediaSignal }; - -export type SDP = RTCSessionDescriptionInit; - -export const sdpSchema: JSONSchemaType = { - type: 'object', - properties: { - sdp: { - type: 'string', - nullable: true, - }, - type: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: false, - nullable: false, - required: ['type'], -}; - -const mediaCallsSignalPropsSchema: JSONSchemaType = { - type: 'object', - properties: { - signal: { - type: 'object', - additionalProperties: true, - discriminator: { propertyName: 'type' }, - required: ['type'], - oneOf: [ - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'request-call', - }, - callee: { - type: 'object', - properties: { - type: { - type: 'string', - nullable: false, - }, - id: { - type: 'string', - nullable: false, - }, - }, - required: ['type', 'id'], - additionalProperties: false, - }, - supportedServices: { - type: 'array', - items: { - type: 'string', - }, - nullable: false, - }, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'callee', 'supportedServices'], - }, - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'local-sdp', - }, - sdp: sdpSchema, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'sdp'], - }, - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'error', - }, - errorCode: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'errorCode'], - }, - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'answer', - }, - answer: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'answer'], - }, - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'hangup', - }, - reason: { - type: 'string', - nullable: false, - }, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'reason'], - }, - { - type: 'object', - properties: { - callId: { - type: 'string', - nullable: false, - }, - contractId: { - type: 'string', - nullable: true, - }, - type: { - const: 'local-state', - }, - callState: { - type: 'string', - nullable: false, - }, - clientState: { - type: 'string', - nullable: false, - }, - serviceStates: { - type: 'object', - patternProperties: { - '.*': { - type: 'string', - }, - }, - nullable: true, - }, - }, - additionalProperties: false, - required: ['callId', 'contractId', 'type', 'callState', 'clientState'], - }, - ], - }, - }, - required: ['signal'], - additionalProperties: false, -}; - -export const isMediaCallsSignalProps = ajv.compile(mediaCallsSignalPropsSchema); diff --git a/packages/rest-typings/src/v1/media-calls/index.ts b/packages/rest-typings/src/v1/media-calls/index.ts deleted file mode 100644 index 632222af03dca..0000000000000 --- a/packages/rest-typings/src/v1/media-calls/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MediaCallsSignalProps } from './MediaCallsSignalProps'; - -export * from './MediaCallsSignalProps'; - -export type MediaCallsEndpoints = { - '/v1/media-calls.signal': { - POST: (params: MediaCallsSignalProps) => void; - }; -}; diff --git a/yarn.lock b/yarn.lock index 024615a6391b5..28b2d58e80af5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7820,6 +7820,7 @@ __metadata: "@rocket.chat/models": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" + ajv: "npm:^8.17.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" typescript: "npm:~5.8.3" From 8ed23433df6842b8cdac654b311ebbd91e24d055 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 8 Aug 2025 13:51:16 -0300 Subject: [PATCH 25/72] serialization and type validation --- .../notifications/notifications.module.ts | 9 +---- .../server/services/media-call/service.ts | 38 ++++++++++++++++--- .../src/types/IMediaCallService.ts | 1 + packages/media-signaling-server/package.json | 3 +- packages/media-signaling-server/src/index.ts | 1 - .../src/isClientMediaSignal.ts | 9 ----- packages/media-signaling/package.json | 3 +- .../definition/signals/client/MediaSignal.ts | 8 +++- .../src/definition/signals/client/answer.ts | 7 +++- .../src/definition/signals/client/error.ts | 7 +++- .../src/definition/signals/client/hangup.ts | 7 +++- .../definition/signals/client/local-sdp.ts | 7 +++- .../definition/signals/client/local-state.ts | 8 +++- .../definition/signals/client/request-call.ts | 7 +++- yarn.lock | 2 +- 15 files changed, 77 insertions(+), 40 deletions(-) delete mode 100644 packages/media-signaling-server/src/isClientMediaSignal.ts diff --git a/apps/meteor/server/modules/notifications/notifications.module.ts b/apps/meteor/server/modules/notifications/notifications.module.ts index e427db14487f4..3d1f1ac681754 100644 --- a/apps/meteor/server/modules/notifications/notifications.module.ts +++ b/apps/meteor/server/modules/notifications/notifications.module.ts @@ -7,7 +7,6 @@ import type { IStreamer, IStreamerConstructor, IPublication } from 'meteor/rocke import type { ImporterProgress } from '../../../app/importer/server/classes/ImporterProgress'; import { emit, StreamPresence } from '../../../app/notifications/server/lib/Presence'; import { SystemLogger } from '../../lib/logger/system'; -import { isClientMediaSignal } from '@rocket.chat/media-signaling-server'; export class NotificationsModule { public readonly streamLogged: IStreamer<'notify-logged'>; @@ -310,15 +309,11 @@ export class NotificationsModule { } if (e === 'media-calls') { - if (!this.userId || !data || typeof data !== 'object') { - return false; - } - - if (!isClientMediaSignal(data)) { + if (!this.userId || !data || typeof data !== 'string') { return false; } - void MediaCall.processSignal(this.userId, data).catch(() => null); + void MediaCall.processSerializedSignal(this.userId, data).catch(() => null); // media call signals don't ever need to be broadcasted return false; diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 331c6ee591a46..4575857eb498d 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,6 +1,6 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; +import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; import { processSignal, createCall, setSignalHandler, logger } from '@rocket.chat/media-signaling-server'; import { MediaCalls, Users } from '@rocket.chat/models'; @@ -14,9 +14,22 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { + logger.debug({ msg: 'new client signal', signal, uid }); await processSignal(signal, uid); } catch (error) { - logger.error({ msg: 'failed to process client signal', error }); + logger.error({ msg: 'failed to process client signal', error, signal, uid }); + } + } + + public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise { + try { + logger.debug({ msg: 'new client signal', signal, uid }); + + const deserialized = await this.deserializeClientSignal(signal); + + await processSignal(deserialized, uid); + } catch (error) { + logger.error({ msg: 'failed to process client signal', error, signal, uid }); } } @@ -57,10 +70,6 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return this.createInternalCall(caller, { uid: userId }); } - private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { - void api.broadcast('user.media-signal', { userId: toUid, signal }); - } - public async hangupEveryCall(hangupReason?: string): Promise { // change every pending or active call state to 'hangup' with the specified reason @@ -69,4 +78,21 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall reason: hangupReason || 'full-server-hangup', }); } + + private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { + void api.broadcast('user.media-signal', { userId: toUid, signal }); + } + + private async deserializeClientSignal(serialized: string): Promise { + try { + const signal = JSON.parse(serialized); + if (!isClientMediaSignal(signal)) { + throw new Error('signal-format-invalid'); + } + return signal; + } catch (error) { + logger.error({ msg: 'Failed to parse client signal' }, error); + throw error; + } + } } diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index a8611be291c0a..3e89f11343384 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -3,6 +3,7 @@ import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; + processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; diff --git a/packages/media-signaling-server/package.json b/packages/media-signaling-server/package.json index b65e173b68fa7..b8a2f433cdc40 100644 --- a/packages/media-signaling-server/package.json +++ b/packages/media-signaling-server/package.json @@ -27,7 +27,6 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", - "@rocket.chat/models": "workspace:^", - "ajv": "^8.17.1" + "@rocket.chat/models": "workspace:^" } } diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index d057d4bf8ecae..3fff0dbb09f21 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1,5 +1,4 @@ export { createCall } from './createCall'; export { processSignal } from './processSignal'; -export { isClientMediaSignal } from './isClientMediaSignal'; export { setSignalHandler } from './signalHandler'; export { logger } from './logger'; diff --git a/packages/media-signaling-server/src/isClientMediaSignal.ts b/packages/media-signaling-server/src/isClientMediaSignal.ts deleted file mode 100644 index e817be9d03b7e..0000000000000 --- a/packages/media-signaling-server/src/isClientMediaSignal.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { clientMediaSignalSchema, type ClientMediaSignal } from '@rocket.chat/media-signaling'; -import type { JSONSchemaType } from 'ajv'; -import Ajv from 'ajv'; - -const ajv = new Ajv({ discriminator: true }); - -const schema: JSONSchemaType = clientMediaSignalSchema; - -export const isClientMediaSignal = ajv.compile(schema); diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 3d074f437b1b1..1dd13deddee49 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -24,6 +24,7 @@ "/dist" ], "dependencies": { - "@rocket.chat/emitter": "~0.31.25" + "@rocket.chat/emitter": "~0.31.25", + "ajv": "^8.17.1" } } diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts index ca73b9f999038..9ab5f1b64b313 100644 --- a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -1,3 +1,5 @@ +import { Ajv, type JSONSchemaType } from 'ajv'; + import { clientMediaSignalAnswerSchema, type ClientMediaSignalAnswer } from './answer'; import { clientMediaSignalErrorSchema, type ClientMediaSignalError } from './error'; import { clientMediaSignalHangupSchema, type ClientMediaSignalHangup } from './hangup'; @@ -5,6 +7,8 @@ import { clientMediaSignalLocalSDPSchema, type ClientMediaSignalLocalSDP } from import { clientMediaSignalLocalStateSchema, type ClientMediaSignalLocalState } from './local-state'; import { clientMediaSignalRequestCallSchema, type ClientMediaSignalRequestCall } from './request-call'; +const ajv = new Ajv({ discriminator: true }); + export type ClientMediaSignal = | ClientMediaSignalLocalSDP | ClientMediaSignalError @@ -13,7 +17,7 @@ export type ClientMediaSignal = | ClientMediaSignalRequestCall | ClientMediaSignalLocalState; -export const clientMediaSignalSchema = { +export const clientMediaSignalSchema: JSONSchemaType = { type: 'object', additionalProperties: true, discriminator: { propertyName: 'type' }, @@ -28,6 +32,8 @@ export const clientMediaSignalSchema = { ], } as const; +export const isClientMediaSignal = ajv.compile(clientMediaSignalSchema); + export type ClientMediaSignalType = ClientMediaSignal['type']; type ExtractMediaSignal = T extends { type: K } ? T : never; diff --git a/packages/media-signaling/src/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts index 42709b8b66eaf..f3d27c01868bb 100644 --- a/packages/media-signaling/src/definition/signals/client/answer.ts +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + import type { CallAnswer } from '../../call'; // Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached @@ -9,7 +11,7 @@ export type ClientMediaSignalAnswer = { answer: CallAnswer; }; -export const clientMediaSignalAnswerSchema = { +export const clientMediaSignalAnswerSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -21,6 +23,7 @@ export const clientMediaSignalAnswerSchema = { nullable: false, }, type: { + type: 'string', const: 'answer', }, answer: { @@ -30,4 +33,4 @@ export const clientMediaSignalAnswerSchema = { }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'answer'], -} as const; +}; diff --git a/packages/media-signaling/src/definition/signals/client/error.ts b/packages/media-signaling/src/definition/signals/client/error.ts index f7e9de3f0ec35..79e18a15e7512 100644 --- a/packages/media-signaling/src/definition/signals/client/error.ts +++ b/packages/media-signaling/src/definition/signals/client/error.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + // Client is reporting an error export type ClientMediaSignalError = { callId: string; @@ -7,7 +9,7 @@ export type ClientMediaSignalError = { errorCode: string; }; -export const clientMediaSignalErrorSchema = { +export const clientMediaSignalErrorSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -19,6 +21,7 @@ export const clientMediaSignalErrorSchema = { nullable: false, }, type: { + type: 'string', const: 'error', }, errorCode: { @@ -28,4 +31,4 @@ export const clientMediaSignalErrorSchema = { }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'errorCode'], -} as const; +}; diff --git a/packages/media-signaling/src/definition/signals/client/hangup.ts b/packages/media-signaling/src/definition/signals/client/hangup.ts index c11ac2e8aaa41..859b07e9cff9b 100644 --- a/packages/media-signaling/src/definition/signals/client/hangup.ts +++ b/packages/media-signaling/src/definition/signals/client/hangup.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + import type { CallHangupReason } from '../../call'; // Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error @@ -9,7 +11,7 @@ export type ClientMediaSignalHangup = { reason: CallHangupReason; }; -export const clientMediaSignalHangupSchema = { +export const clientMediaSignalHangupSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -21,6 +23,7 @@ export const clientMediaSignalHangupSchema = { nullable: false, }, type: { + type: 'string', const: 'hangup', }, reason: { @@ -30,4 +33,4 @@ export const clientMediaSignalHangupSchema = { }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'reason'], -} as const; +}; diff --git a/packages/media-signaling/src/definition/signals/client/local-sdp.ts b/packages/media-signaling/src/definition/signals/client/local-sdp.ts index 003430e307703..cad916f74492f 100644 --- a/packages/media-signaling/src/definition/signals/client/local-sdp.ts +++ b/packages/media-signaling/src/definition/signals/client/local-sdp.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + // Client is sending the local session description to the server export type ClientMediaSignalLocalSDP = { callId: string; @@ -7,7 +9,7 @@ export type ClientMediaSignalLocalSDP = { sdp: RTCSessionDescriptionInit; }; -export const clientMediaSignalLocalSDPSchema = { +export const clientMediaSignalLocalSDPSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -19,6 +21,7 @@ export const clientMediaSignalLocalSDPSchema = { nullable: false, }, type: { + type: 'string', const: 'local-sdp', }, sdp: { @@ -40,4 +43,4 @@ export const clientMediaSignalLocalSDPSchema = { }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'sdp'], -} as const; +}; diff --git a/packages/media-signaling/src/definition/signals/client/local-state.ts b/packages/media-signaling/src/definition/signals/client/local-state.ts index 1342f3676207b..5b2feb59da5d9 100644 --- a/packages/media-signaling/src/definition/signals/client/local-state.ts +++ b/packages/media-signaling/src/definition/signals/client/local-state.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + import type { CallState } from '../../call'; import type { ClientState } from '../../client'; @@ -12,7 +14,7 @@ export type ClientMediaSignalLocalState = { serviceStates?: Record; }; -export const clientMediaSignalLocalStateSchema = { +export const clientMediaSignalLocalStateSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -24,6 +26,7 @@ export const clientMediaSignalLocalStateSchema = { nullable: false, }, type: { + type: 'string', const: 'local-state', }, callState: { @@ -42,8 +45,9 @@ export const clientMediaSignalLocalStateSchema = { }, }, nullable: true, + required: [], }, }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'callState', 'clientState'], -} as const; +}; diff --git a/packages/media-signaling/src/definition/signals/client/request-call.ts b/packages/media-signaling/src/definition/signals/client/request-call.ts index 6604319a3d526..c11546953d92f 100644 --- a/packages/media-signaling/src/definition/signals/client/request-call.ts +++ b/packages/media-signaling/src/definition/signals/client/request-call.ts @@ -1,3 +1,5 @@ +import type { JSONSchemaType } from 'ajv'; + import type { CallService } from '../../call'; export type ClientMediaSignalRequestCall = { @@ -12,7 +14,7 @@ export type ClientMediaSignalRequestCall = { supportedServices: CallService[]; }; -export const clientMediaSignalRequestCallSchema = { +export const clientMediaSignalRequestCallSchema: JSONSchemaType = { type: 'object', properties: { callId: { @@ -24,6 +26,7 @@ export const clientMediaSignalRequestCallSchema = { nullable: false, }, type: { + type: 'string', const: 'request-call', }, callee: { @@ -51,4 +54,4 @@ export const clientMediaSignalRequestCallSchema = { }, additionalProperties: false, required: ['callId', 'contractId', 'type', 'callee', 'supportedServices'], -} as const; +}; diff --git a/yarn.lock b/yarn.lock index 28b2d58e80af5..9ea047bac3bdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7820,7 +7820,6 @@ __metadata: "@rocket.chat/models": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" - ajv: "npm:^8.17.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" typescript: "npm:~5.8.3" @@ -7835,6 +7834,7 @@ __metadata: "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" + ajv: "npm:^8.17.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" typescript: "npm:~5.8.3" From a1c6907c95f5b37aa8971d5ad73bb0009ba75973 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 8 Aug 2025 20:41:25 -0300 Subject: [PATCH 26/72] Multi session --- .../src/agents/Manager.ts | 2 + .../src/agents/definition/IMediaCallAgent.ts | 1 + .../src/agents/definition/common.ts | 1 + .../src/agents/users/Agent.ts | 40 ++- .../src/agents/users/AgentFactory.ts | 10 +- .../src/agents/users/BasicAgent.ts | 3 +- packages/media-signaling-server/src/logger.ts | 3 - .../src/processSignal.ts | 15 ++ .../src/definition/call/CallEvents.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 12 +- .../media-signaling/src/definition/client.ts | 8 + .../media-signaling/src/definition/index.ts | 2 + .../media-signaling/src/definition/logger.ts | 6 + .../services/webrtc/IWebRTCProcessor.ts | 2 + .../definition/signals/client/local-state.ts | 14 +- .../definition/signals/server/notification.ts | 3 + packages/media-signaling/src/lib/Call.ts | 234 +++++++++++++++--- packages/media-signaling/src/lib/Session.ts | 134 ++++++++-- .../src/lib/TransportWrapper.ts | 4 + .../src/lib/services/webrtc/Processor.ts | 27 +- 20 files changed, 439 insertions(+), 83 deletions(-) create mode 100644 packages/media-signaling-server/src/agents/definition/common.ts create mode 100644 packages/media-signaling/src/definition/logger.ts diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/packages/media-signaling-server/src/agents/Manager.ts index cd1ea1d9816bd..aec8e603b952b 100644 --- a/packages/media-signaling-server/src/agents/Manager.ts +++ b/packages/media-signaling-server/src/agents/Manager.ts @@ -121,6 +121,8 @@ class MediaCallAgentManager { return; } + agent.sign(); + // #ToDo: notify client if this throws any error return this.processAcceptedCall(agent); } diff --git a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts index 66b5a34925d63..1a7ea33d2aa47 100644 --- a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts +++ b/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts @@ -32,6 +32,7 @@ export interface IMediaCallAgent extends IMediaCallBasicAgent { setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise; getLocalDescription(): Promise; requestOffer(params: { iceRestart?: boolean }): Promise; + sign(): Promise; } // The agent factory is an object with functions to instantiate the two agent types diff --git a/packages/media-signaling-server/src/agents/definition/common.ts b/packages/media-signaling-server/src/agents/definition/common.ts new file mode 100644 index 0000000000000..ba2c623e49e19 --- /dev/null +++ b/packages/media-signaling-server/src/agents/definition/common.ts @@ -0,0 +1 @@ +export type AgentContractState = 'proposed' | 'signed' | 'ignored'; diff --git a/packages/media-signaling-server/src/agents/users/Agent.ts b/packages/media-signaling-server/src/agents/users/Agent.ts index 3566d9e95feb4..a1335afcca2bb 100644 --- a/packages/media-signaling-server/src/agents/users/Agent.ts +++ b/packages/media-signaling-server/src/agents/users/Agent.ts @@ -1,31 +1,36 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, CallRole } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, CallRole, CallNotification } from '@rocket.chat/media-signaling'; import { UserBasicAgent, type MinimalUserData } from './BasicAgent'; import { UserAgentSignalProcessor } from './SignalProcessor'; import { logger } from '../../logger'; import { agentManager } from '../Manager'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; +import type { AgentContractState } from '../definition/common'; export class UserMediaCallAgent extends UserBasicAgent implements IMediaCallAgent { public readonly callId: string; public readonly contractId: string; - protected _signed: boolean; + protected contractState: AgentContractState; protected signalProcessor: UserAgentSignalProcessor; public get signed(): boolean { - return this._signed; + return this.contractState === 'signed'; } - constructor(user: MinimalUserData, data: { role: CallRole; callId: string; contractId: string; contractSigned?: boolean }) { - const { callId, contractSigned, ...params } = data; + public get ignored(): boolean { + return this.contractState === 'ignored'; + } + + constructor(user: MinimalUserData, data: { role: CallRole; callId: string; contractId: string; contractState?: AgentContractState }) { + const { callId, contractState, ...params } = data; super(user, params); this.contractId = data.contractId; this.callId = callId; - this._signed = contractSigned ?? false; + this.contractState = (this.contractId && contractState) || 'proposed'; this.signalProcessor = new UserAgentSignalProcessor(this); } @@ -52,9 +57,9 @@ export class UserMediaCallAgent extends UserBasicAgent implemen } public async requestOffer(params: { iceRestart?: boolean }): Promise { - logger.debug({ msg: 'UserMediaCallAgent.requestOffer', params }); + logger.debug({ msg: 'UserMediaCallAgent.requestOffer', params, actor: this.actor, contractState: this.contractState }); - // #ToDo: this function may be called multiple times for the same call until an offer is provided; look into how to handle that + // #ToDo: this function may be called multiple times for the same call until an offer is provided; maybe consider the channel state before sending the offer await this.sendSignal({ callId: this.callId, toContractId: this.contractId, @@ -62,4 +67,23 @@ export class UserMediaCallAgent extends UserBasicAgent implemen ...params, }); } + + public async notify(callId: string, notification: CallNotification, signedContractId?: string): Promise { + // If we have been ignored, we should not be notifying anyone + if (this.ignored) { + return; + } + + // If we know we're signed, inject our contractId into all notifications we send to the client + const signedId = signedContractId || (this.signed && this.contractId) || undefined; + return super.notify(callId, notification, signedId); + } + + public async sign(): Promise { + if (this.contractState !== 'proposed') { + throw new Error(`Can't sign a contract that is not pending.`); + } + + this.contractState = 'signed'; + } } diff --git a/packages/media-signaling-server/src/agents/users/AgentFactory.ts b/packages/media-signaling-server/src/agents/users/AgentFactory.ts index f809e7ddd304a..84fe82901df5d 100644 --- a/packages/media-signaling-server/src/agents/users/AgentFactory.ts +++ b/packages/media-signaling-server/src/agents/users/AgentFactory.ts @@ -8,6 +8,7 @@ import { UserMediaCallAgent } from './Agent'; import { UserNewCallAgent } from './NewCallAgent'; import { logger } from '../../logger'; import type { IMediaCallAgentFactory } from '../definition/IMediaCallAgent'; +import type { AgentContractState } from '../definition/common'; // Overriding the interface to use the same factory for the AgentManager class and the processSignal function export interface IUserAgentFactory extends IMediaCallAgentFactory { @@ -48,10 +49,13 @@ export class UserAgentFactory { } const { [role]: callActor } = call; - const contractSigned = Boolean(callActor.contractId); + let contractState: AgentContractState = 'proposed'; + if (callActor.contractId) { + contractState = callActor.contractId === contractId ? 'signed' : 'ignored'; + } // If the role is already signed with a different contract, do not create the agent - if (contractSigned && callActor.contractId !== contractId) { + if (contractState === 'ignored') { logger.debug({ msg: 'signed by another contract', method: 'UserAgentFactory.getAgentFactoryForUser', @@ -62,7 +66,7 @@ export class UserAgentFactory { return null; } - return new UserMediaCallAgent(userData, { role, callId, contractId, contractSigned }); + return new UserMediaCallAgent(userData, { role, callId, contractId, contractState }); }, }; } diff --git a/packages/media-signaling-server/src/agents/users/BasicAgent.ts b/packages/media-signaling-server/src/agents/users/BasicAgent.ts index f31c5e42aa31e..989cec3ca7695 100644 --- a/packages/media-signaling-server/src/agents/users/BasicAgent.ts +++ b/packages/media-signaling-server/src/agents/users/BasicAgent.ts @@ -34,12 +34,13 @@ export class UserBasicAgent }; } - public async notify(callId: string, notification: CallNotification): Promise { + public async notify(callId: string, notification: CallNotification, signedContractId?: string): Promise { logger.debug({ msg: 'UserBasicAgent.notify', callId, notification }); return this.sendSignal({ callId, type: 'notification', notification, + ...(signedContractId && { signedContractId }), }); } diff --git a/packages/media-signaling-server/src/logger.ts b/packages/media-signaling-server/src/logger.ts index 8cdcd64a7d2df..b15554abc0194 100644 --- a/packages/media-signaling-server/src/logger.ts +++ b/packages/media-signaling-server/src/logger.ts @@ -1,6 +1,3 @@ import { Logger } from '@rocket.chat/logger'; export const logger = new Logger('MediaSignalingServer'); - -// #ToDo: Remove -logger.level('debug'); diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts index 439bd9d49b90f..77e9ac5203d97 100644 --- a/packages/media-signaling-server/src/processSignal.ts +++ b/packages/media-signaling-server/src/processSignal.ts @@ -19,6 +19,21 @@ export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id'] throw new Error('invalid-call'); } + const isCaller = call.caller.type === 'user' && call.caller.id === uid; + const isCallee = call.callee.type === 'user' && call.callee.id === uid; + if (!isCaller && !isCallee) { + logger.error({ msg: 'actor is not part of the call', method: 'processSignal', signal }); + throw new Error('invalid-call'); + } + + // Ignore signals from different sessions + if (isCaller && call.caller.contractId && call.caller.contractId !== signal.contractId) { + return; + } + if (isCallee && call.callee.contractId && call.callee.contractId !== signal.contractId) { + return; + } + const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); const agent = factory?.getCallAgent(call); if (!agent) { diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index d499dc1613f13..0d7ec4c1e1b29 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -15,6 +15,7 @@ export type CallEvents = { accepting: void; accepted: void; + hidden: void; ended: void; }; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index badac73d57442..a64c82adfa74a 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -53,18 +53,18 @@ export type CallRejectedReason = | 'already-requested' // the request is valid, but a call matching its params is already underway | 'unsupported'; // no matching supported services between actors -export interface IClientMediaCallData { +export interface IClientMediaCall { callId: string; role: CallRole; service: CallService | null; - state?: CallState; - ignored?: boolean; + state: CallState; + ignored: boolean; + signed: boolean; + hidden: boolean; - contact?: CallContact; -} + contact: CallContact; -export interface IClientMediaCall extends Required { emitter: Emitter; getRemoteMediaStream(): MediaStream; diff --git a/packages/media-signaling/src/definition/client.ts b/packages/media-signaling/src/definition/client.ts index 5865ed88b4184..8f0632baf649e 100644 --- a/packages/media-signaling/src/definition/client.ts +++ b/packages/media-signaling/src/definition/client.ts @@ -3,7 +3,15 @@ export type ClientState = | 'pending' // The call is ringing | 'accepting' // The client tried to accept the call and is wating for confirmation from the server | 'accepted' // The call was accepted, but the client doesn't have a webrtc offer yet + | 'busy-elsewhere' // The call is happening in a different session/client | 'has-offer' // The call was accepted and the client has a webrtc offer | 'has-answer' // The call was accepted and the client has a webrtc offer and answer | 'active' // The webrtc call was established | 'hangup'; // The call is over, or happening in some other client + +export type ClientContractState = + | 'proposed' // we don't know if the contract will be signed + | 'signed' // the server signed this session's contract + | 'pre-signed' // the session that requested a call is assuming it will be signed into it + | 'self-signed' // the call has progressed beyond the signing stage without any signature confirmation + | 'ignored'; // the server signed a contract from a different session diff --git a/packages/media-signaling/src/definition/index.ts b/packages/media-signaling/src/definition/index.ts index 415e596570ec2..2b01a9c85d3cb 100644 --- a/packages/media-signaling/src/definition/index.ts +++ b/packages/media-signaling/src/definition/index.ts @@ -1,3 +1,5 @@ export * from './call'; export * from './services'; export * from './signals'; +export * from './client'; +export * from './logger'; diff --git a/packages/media-signaling/src/definition/logger.ts b/packages/media-signaling/src/definition/logger.ts new file mode 100644 index 0000000000000..918d6631d3d0b --- /dev/null +++ b/packages/media-signaling/src/definition/logger.ts @@ -0,0 +1,6 @@ +export interface IMediaSignalLogger { + log(...what: any[]): void; + debug(...what: any[]): void; + error(...what: any[]): void; + warn(...what: any[]): void; +} diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index 169ac8b186357..f6e9b1bbba3d1 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -1,5 +1,6 @@ import type { Emitter } from '@rocket.chat/emitter'; +import type { IMediaSignalLogger } from '../../logger'; import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor'; import type { MediaStreamFactory } from '../MediaStreamFactory'; @@ -25,6 +26,7 @@ export interface IWebRTCProcessor extends IServiceProcessor IWebRTCProcessor; diff --git a/packages/media-signaling/src/definition/signals/client/local-state.ts b/packages/media-signaling/src/definition/signals/client/local-state.ts index 5b2feb59da5d9..9c27af4b7a5d6 100644 --- a/packages/media-signaling/src/definition/signals/client/local-state.ts +++ b/packages/media-signaling/src/definition/signals/client/local-state.ts @@ -1,7 +1,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallState } from '../../call'; -import type { ClientState } from '../../client'; +import type { ClientContractState, ClientState } from '../../client'; // Client is sending their local call state export type ClientMediaSignalLocalState = { @@ -12,6 +12,8 @@ export type ClientMediaSignalLocalState = { callState: CallState; clientState: ClientState; serviceStates?: Record; + ignored?: boolean; + contractState: ClientContractState; }; export const clientMediaSignalLocalStateSchema: JSONSchemaType = { @@ -47,7 +49,15 @@ export const clientMediaSignalLocalStateSchema: JSONSchemaType; @@ -76,6 +81,14 @@ export class ClientMediaCall implements IClientMediaCall { return this._service; } + public get signed(): boolean { + return ['signed', 'pre-signed', 'self-signed'].includes(this.contractState); + } + + public get hidden(): boolean { + return this.ignored || this.contractState === 'ignored'; + } + protected webrtcProcessor: IWebRTCProcessor | null = null; private acceptedLocally: boolean; @@ -106,6 +119,8 @@ export class ClientMediaCall implements IClientMediaCall { private mayReportStates: boolean; + private contractState: ClientContractState; + // localCallId will only be different on calls initiated by this session private localCallId: string; @@ -125,6 +140,7 @@ export class ClientMediaCall implements IClientMediaCall { this.hasRemoteData = false; this.initialized = false; this.acknowledged = false; + this.contractState = 'proposed'; this.hasLocalDescription = false; this.hasRemoteDescription = false; this.serviceStates = new Map(); @@ -147,6 +163,7 @@ export class ClientMediaCall implements IClientMediaCall { if (this.acceptedLocally) { return; } + this.config.logger?.debug('ClientMediaCall.initializeOutboundCall'); const wasInitialized = this.initialized; @@ -172,6 +189,8 @@ export class ClientMediaCall implements IClientMediaCall { return; } + this.config.logger?.debug('ClientMediaCall.requestCall', callee); + this.config.transporter.sendToServer(this.callId, 'request-call', { callee, supportedServices: Object.keys(this.config.processorFactories) as CallService[], @@ -186,6 +205,8 @@ export class ClientMediaCall implements IClientMediaCall { return; } + this.config.logger?.debug('ClientMediaCall.initializeRemoteCall', signal); + this.remoteCallId = signal.callId; const wasInitialized = this.initialized; @@ -196,6 +217,11 @@ export class ClientMediaCall implements IClientMediaCall { this.changeContact(signal.contact); + if (this._role === 'caller' && !this.acceptedLocally && AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS) { + this.config.logger?.log('Ignoring Unknown Outbound Call'); + this.ignore(); + } + // If it's flagged as ignored even before the initialization, tell the server we're unavailable if (this.ignored) { return this.rejectAsUnavailable(); @@ -217,6 +243,11 @@ export class ClientMediaCall implements IClientMediaCall { this.addStateTimeout('pending', TIMEOUT_TO_ACCEPT); } + // If the call was requested by this specific session, assume we're signed already. + if (this._role === 'caller' && this.acceptedLocally && signal.requestedCallId === this.localCallId) { + this.contractState = 'pre-signed'; + } + if (!wasInitialized) { this.emitter.emit('initialized'); } @@ -225,6 +256,14 @@ export class ClientMediaCall implements IClientMediaCall { } public getClientState(): ClientState { + if (this.isOver()) { + return 'hangup'; + } + + if (this.hidden) { + return 'busy-elsewhere'; + } + switch (this._state) { case 'none': case 'ringing': @@ -241,16 +280,15 @@ export class ClientMediaCall implements IClientMediaCall { } return 'accepted'; - case 'active': - return 'active'; - case 'hangup': - return 'hangup'; + default: + return this._state; } } public getRemoteMediaStream(): MediaStream { + this.config.logger?.debug('ClientMediaCall.getRemoteMediaStream'); if (this.shouldIgnoreWebRTC()) { - throw new Error('getRemoteMediaStream is not available for this service'); + this.throwError('getRemoteMediaStream is not available for this service'); } this.prepareWebRtcProcessor(); @@ -262,6 +300,7 @@ export class ClientMediaCall implements IClientMediaCall { if (this.isOver()) { return; } + this.config.logger?.debug('ClientMediaCall.processSignal', signal); const { type: signalType } = signal; @@ -270,6 +309,7 @@ export class ClientMediaCall implements IClientMediaCall { } if (!this.hasRemoteData) { + this.config.logger?.debug('Remote data missing, adding signal to queue'); this.earlySignals.add(signal); return; } @@ -285,12 +325,14 @@ export class ClientMediaCall implements IClientMediaCall { } public async accept(): Promise { + this.config.logger?.debug('ClientMediaCall.accept'); + if (!this.isPendingOurAcceptance()) { - throw new Error('call-not-pending-acceptance'); + this.throwError('call-not-pending-acceptance'); } if (!this.hasRemoteData) { - throw new Error('missing-remote-data'); + this.throwError('missing-remote-data'); } this.acceptedLocally = true; @@ -305,12 +347,14 @@ export class ClientMediaCall implements IClientMediaCall { } public async reject(): Promise { + this.config.logger?.debug('ClientMediaCall.reject'); + if (!this.isPendingOurAcceptance()) { - throw new Error('call-not-pending-acceptance'); + this.throwError('call-not-pending-acceptance'); } if (!this.hasRemoteData) { - throw new Error('missing-remote-data'); + this.throwError('missing-remote-data'); } this.config.transporter.answer(this.callId, 'reject'); @@ -318,10 +362,15 @@ export class ClientMediaCall implements IClientMediaCall { } public async hangup(reason: CallHangupReason = 'normal'): Promise { + this.config.logger?.debug('ClientMediaCall.hangup', reason); if (this.endedLocally || this._state === 'hangup') { return; } + if (this.hidden) { + return; + } + this.endedLocally = true; this.flagAsEnded(reason); } @@ -331,11 +380,11 @@ export class ClientMediaCall implements IClientMediaCall { } public isPendingOurAcceptance(): boolean { - if (this._role !== 'callee') { + if (this._role !== 'callee' || this.acceptedLocally) { return false; } - if (this.acceptedLocally) { + if (this.hidden) { return false; } @@ -343,7 +392,59 @@ export class ClientMediaCall implements IClientMediaCall { } public isOver(): boolean { - return this.ignored || this._state === 'hangup'; + return this._state === 'hangup'; + } + + public ignore(): void { + if (this.ignored) { + return; + } + + const { hidden: wasHidden } = this; + + this.config.logger?.debug('ClientMediaCall.ignore'); + + this._ignored = true; + if (this.hidden && !wasHidden) { + this.emitter.emit('hidden'); + } + + this.updateClientState(); + this.reportStates(); + this.mayReportStates = false; + } + + public setContractState(state: 'signed' | 'ignored') { + if (this.contractState === state) { + return; + } + this.config.logger?.debug('ClientMediaCall.setContractState', `${this.contractState} => ${state}`); + + if (['pre-signed', 'self-signed'].includes(this.contractState) && state === 'signed') { + this.contractState = state; + return; + } + + if (this.contractState !== 'proposed') { + this.reportStates(); + } + + if (this.contractState === 'signed') { + if (state === 'ignored') { + this.config.logger?.error('[Media Signal] Trying to ignore a contract that was already signed.'); + } + return; + } + + if (this.contractState === 'pre-signed' && state === 'ignored') { + this.config.logger?.error('[Media Signal] Our self signed contract was ignored.'); + } + + const { hidden: wasHidden } = this; + this.contractState = state; + if (this.hidden && !wasHidden) { + this.emitter.emit('hidden'); + } } private changeState(newState: CallState): void { @@ -351,21 +452,24 @@ export class ClientMediaCall implements IClientMediaCall { return; } + this.config.logger?.debug('ClientMediaCall.changeState', `${this._state} => ${newState}`); + const oldState = this._state; this._state = newState; this.updateClientState(); this.emitter.emit('stateChange', oldState); + this.requestStateReport(); switch (newState) { case 'accepted': this.emitter.emit('accepted'); + break; case 'hangup': this.emitter.emit('ended'); break; } - this.requestStateReport(); } private updateClientState(): void { @@ -376,6 +480,8 @@ export class ClientMediaCall implements IClientMediaCall { return; } + this.config.logger?.debug('ClientMediaCall.updateClientState', `${oldClientState} => ${clientState}`); + this.updateStateTimeouts(); this.requestStateReport(); this.oldClientState = clientState; @@ -383,6 +489,7 @@ export class ClientMediaCall implements IClientMediaCall { } private changeContact(contact: CallContact | null, { prioritizeExisting }: { prioritizeExisting?: boolean } = {}): void { + this.config.logger?.debug('ClientMediaCall.changeContact'); const oldContct = prioritizeExisting ? contact : this._contact; const newContact = prioritizeExisting ? this._contact : contact; @@ -393,13 +500,15 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processOfferRequest(signal: ServerMediaSignalRequestOffer) { - if (!signal.toContractId) { - console.error('Received an untargeted offer request.'); + this.config.logger?.debug('ClientMediaCall.processOfferRequest', signal); + + if (!signal.toContractId && !this.signed) { + this.config.logger?.error('Received an unsigned offer request.'); return; } if (this.shouldIgnoreWebRTC()) { - this.config.transporter.sendError(this.callId, 'invalid-service'); + this.sendError('invalid-service'); return; } @@ -409,12 +518,12 @@ export class ClientMediaCall implements IClientMediaCall { try { offer = await this.webrtcProcessor.createOffer(signal); } catch (e) { - this.config.transporter.sendError(this.callId, 'failed-to-create-offer'); + this.sendError('failed-to-create-offer'); throw e; } if (!offer) { - this.config.transporter.sendError(this.callId, 'implementation-error'); + this.sendError('implementation-error'); } await this.deliverSdp(offer); @@ -439,18 +548,20 @@ export class ClientMediaCall implements IClientMediaCall { return; } + this.config.logger?.debug('ClientMediaCall.processAnswerRequest', signal); + this.requireWebRTC(); let answer: { sdp: RTCSessionDescriptionInit } | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal); } catch (e) { - this.config.transporter.sendError(this.callId, 'failed-to-create-answer'); + this.sendError('failed-to-create-answer'); throw e; } if (!answer) { - this.config.transporter.sendError(this.callId, 'implementation-error'); + this.sendError('implementation-error'); return; } @@ -458,9 +569,24 @@ export class ClientMediaCall implements IClientMediaCall { await this.deliverSdp(answer); } + protected sendError(errorCode: string): void { + this.config.logger?.debug('ClientMediaCall.sendError', errorCode); + + if (this.hidden) { + return; + } + + this.config.transporter.sendError(this.callId, errorCode); + } + protected async processRemoteSDP(signal: ServerMediaSignalRemoteSDP): Promise { - if (!signal.toContractId) { - console.error('Received untargeted SDP signal'); + this.config.logger?.debug('ClientMediaCall.processRemoteSDP', signal); + if (this.hidden) { + return; + } + + if (!signal.toContractId && !this.signed) { + this.config.logger?.error('Received an unsigned SDP signal'); return; } @@ -478,14 +604,19 @@ export class ClientMediaCall implements IClientMediaCall { } protected async deliverSdp(data: { sdp: RTCSessionDescriptionInit }) { + this.config.logger?.debug('ClientMediaCall.deliverSdp'); this.hasLocalDescription = true; - this.config.transporter.sendToServer(this.callId, 'local-sdp', data); + if (!this.hidden) { + this.config.transporter.sendToServer(this.callId, 'local-sdp', data); + } this.updateClientState(); } protected async rejectAsUnavailable(): Promise { + this.config.logger?.debug('ClientMediaCall.rejectAsUnavailable'); + // If we have already told the server we accept this call, then we need to send a hangup to get out of it if (this.acceptedLocally) { return this.hangup('unavailable'); @@ -496,6 +627,8 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processEarlySignals(): Promise { + this.config.logger?.debug('ClientMediaCall.processEarlySignals'); + const earlySignals = this.earlySignals.values().toArray(); this.earlySignals.clear(); @@ -503,16 +636,18 @@ export class ClientMediaCall implements IClientMediaCall { try { await this.processSignal(signal); } catch (e) { - console.error('Error processing early signal', e); + this.config.logger?.error('Error processing early signal', e); } } } protected acknowledge(): void { - if (this.acknowledged) { + if (this.acknowledged || this.hidden) { return; } + this.config.logger?.debug('ClientMediaCall.acknowledge'); + this.acknowledged = true; this.config.transporter.answer(this.callId, 'ack'); @@ -522,19 +657,34 @@ export class ClientMediaCall implements IClientMediaCall { } private async processNotification(signal: ServerMediaSignalNotification) { + this.config.logger?.debug('ClientMediaCall.processNotification'); + switch (signal.notification) { case 'accepted': return this.flagAsAccepted(); + case 'hangup': return this.flagAsEnded('remote'); } } private async flagAsAccepted(): Promise { + this.config.logger?.debug('ClientMediaCall.flagAsAccepted'); + + // If hidden, just move the state without doing anything + if (this.hidden) { + this.changeState('accepted'); + return; + } + if (!this.acceptedLocally) { // #ToDo: test this situation; remove exception, read this response on the server this.config.transporter.sendError(this.callId, 'not-accepted'); - throw new Error('Trying to activate a call that was not yet accepted locally.'); + this.throwError('Trying to activate a call that was not yet accepted locally.'); + } + + if (this.contractState === 'proposed') { + this.contractState = 'self-signed'; } // Both sides of the call have accepted it, we can change the state now @@ -545,19 +695,27 @@ export class ClientMediaCall implements IClientMediaCall { } private flagAsEnded(reason: CallHangupReason): void { + this.config.logger?.debug('ClientMediaCall.flagAsEnded', reason); if (this._state === 'hangup') { return; } - this.config.transporter.hangup(this.callId, reason); + if (!this.hidden) { + this.config.transporter.hangup(this.callId, reason); + } this.changeState('hangup'); } private addStateTimeout(state: ClientState, timeout: number, callback?: () => void): void { + this.config.logger?.debug('ClientMediaCall.addStateTimeout', state, `${timeout / 1000}s`); if (this.getClientState() !== state) { return; } + // Do not set state timeouts if the call is not happening on this session, unless there's a callback attached to that timeout + if (this.hidden && !callback) { + return; + } const handler = { state, @@ -582,6 +740,7 @@ export class ClientMediaCall implements IClientMediaCall { } private updateStateTimeouts(): void { + this.config.logger?.debug('ClientMediaCall.updateStateTimeouts'); const clientState = this.getClientState(); for (const handler of this.stateTimeoutHandlers.values()) { @@ -595,6 +754,7 @@ export class ClientMediaCall implements IClientMediaCall { } private onWebRTCInternalStateChange(stateName: keyof WebRTCInternalStateMap): void { + this.config.logger?.debug('ClientMediaCall.onWebRTCInternalStateChange'); if (!this.webrtcProcessor) { return; } @@ -607,6 +767,7 @@ export class ClientMediaCall implements IClientMediaCall { } private reportStates(): void { + this.config.logger?.debug('ClientMediaCall.reportStates'); this.clearStateReporter(); if (!this.mayReportStates) { return; @@ -616,6 +777,8 @@ export class ClientMediaCall implements IClientMediaCall { callState: this.state, clientState: this.getClientState(), serviceStates: Object.fromEntries(this.serviceStates.entries()), + ignored: this.ignored, + contractState: this.contractState, }); if (this.state === 'hangup') { @@ -624,6 +787,7 @@ export class ClientMediaCall implements IClientMediaCall { } private clearStateReporter(): void { + this.config.logger?.debug('ClientMediaCall.clearStateReporter'); if (this.stateReporterTimeoutHandler) { clearTimeout(this.stateReporterTimeoutHandler); this.stateReporterTimeoutHandler = null; @@ -631,6 +795,7 @@ export class ClientMediaCall implements IClientMediaCall { } private requestStateReport(): void { + this.config.logger?.debug('ClientMediaCall.requestStateReport'); this.clearStateReporter(); if (!this.mayReportStates) { return; @@ -641,21 +806,28 @@ export class ClientMediaCall implements IClientMediaCall { }, STATE_REPORT_DELAY); } + private throwError(error: string): never { + this.config.logger?.error(error); + throw new Error(error); + } + private prepareWebRtcProcessor(): asserts this is ClientMediaCallWebRTC { + this.config.logger?.debug('ClientMediaCall.prepareWebRtcProcessor'); if (this.webrtcProcessor) { return; } const { mediaStreamFactory, + logger, processorFactories: { webrtc: webrtcFactory }, } = this.config; if (!webrtcFactory) { - throw new Error('webrtc-not-implemented'); + this.throwError('webrtc-not-implemented'); } - this.webrtcProcessor = webrtcFactory({ mediaStreamFactory }); + this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger }); this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); } @@ -663,7 +835,7 @@ export class ClientMediaCall implements IClientMediaCall { try { this.prepareWebRtcProcessor(); } catch (e) { - this.config.transporter.sendError(this.callId, 'webrtc-not-implemented'); + this.sendError('webrtc-not-implemented'); throw e; } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 7b12c21ea9cec..ac7d44cd6605b 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -4,6 +4,7 @@ import { ClientMediaCall } from './Call'; import { MediaSignalTransportWrapper } from './TransportWrapper'; import type { ClientMediaSignal, + ClientState, IServiceProcessorFactoryList, MediaSignalTransport, MediaStreamFactory, @@ -11,17 +12,21 @@ import type { } from '../definition'; import { createRandomToken } from './utils/createRandomToken'; import type { IClientMediaCall, CallState, CallActorType, CallContact } from '../definition/call'; +import type { IMediaSignalLogger } from '../definition/logger'; export type MediaSignalingEvents = { callContactUpdate: { call: IClientMediaCall }; callStateChange: { call: IClientMediaCall; oldState: CallState }; + callClientStateChange: { call: IClientMediaCall; oldState: ClientState }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; endedCall: { call: IClientMediaCall }; + hiddenCall: { call: IClientMediaCall }; }; export type MediaSignalingSessionConfig = { userId: string; + logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; transport: MediaSignalTransport; @@ -53,7 +58,7 @@ export class MediaSignalingSession extends Emitter { this.knownCalls = new Map(); this.ignoredCalls = new Set(); - this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport); + this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); } public isBusy(): boolean { @@ -74,6 +79,10 @@ export class MediaSignalingSession extends Emitter { let pendingCall: IClientMediaCall | null = null; for (const call of this.knownCalls.values()) { + if (call.state === 'hangup' || call.ignored) { + continue; + } + if (['accepted', 'active'].includes(call.state)) { return call; } @@ -81,7 +90,7 @@ export class MediaSignalingSession extends Emitter { ringingCall = call; continue; } - if (call.state === 'none') { + if (call.state === 'none' && !pendingCall) { pendingCall = call; continue; } @@ -90,17 +99,29 @@ export class MediaSignalingSession extends Emitter { } public async processSignal(signal: ServerMediaSignal): Promise { + this.config.logger?.debug('MediaSignalingSession.processSignal', signal); if (this.isSignalTargetingAnotherSession(signal) || this.isCallIgnored(signal.callId)) { return; } - const call = await this.getOrCreateCallBySignal(signal); + const call = this.getOrCreateCallBySignal(signal); + + if (signal.type === 'notification' && signal.signedContractId) { + if (signal.signedContractId === this._sessionId) { + call.setContractState('signed'); + } else if (signal.notification === 'accepted') { + // The server accepted a contract, but it wasn't ours - ignore the call in this session + call.setContractState('ignored'); + } + } + await call.processSignal(signal); } public async startCall(calleeType: CallActorType, calleeId: string, contactInfo?: CallContact): Promise { + this.config.logger?.debug('MediaSignalingSession.startCall', calleeId); const callId = this.createTemporaryCallId(); - const call = await this.createCall(callId); + const call = this.createCall(callId); await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } @@ -132,38 +153,48 @@ export class MediaSignalingSession extends Emitter { } private ignoreCall(callId: string) { + this.config.logger?.debug('MediaSignalingSession.ignoreCall', callId); this.ignoredCalls.add(callId); if (this.knownCalls.has(callId)) { + const call = this.knownCalls.get(callId); this.knownCalls.delete(callId); + + call?.ignore(); } } - private async getOrCreateCall(callId: string, localCallId?: string): Promise { - const existingCall = this.knownCalls.get(callId); + private getExistingCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + const existingCall = this.knownCalls.get(signal.callId); if (existingCall) { return existingCall; } - const localCall = localCallId && this.knownCalls.get(localCallId); - if (localCall) { - this.knownCalls.set(callId, localCall); - return localCall; + if (signal.type === 'new' && signal.requestedCallId) { + const localCall = this.knownCalls.get(signal.requestedCallId); + if (localCall) { + this.knownCalls.set(signal.callId, localCall); + return localCall; + } } - return this.createCall(callId); + return null; } - private async getOrCreateCallBySignal(signal: ServerMediaSignal): Promise { - if (signal.type === 'new') { - return this.getOrCreateCall(signal.callId, signal.requestedCallId); + private getOrCreateCallBySignal(signal: ServerMediaSignal): ClientMediaCall { + this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); + const existingCall = this.getExistingCallBySignal(signal); + if (existingCall) { + return existingCall; } - return this.getOrCreateCall(signal.callId); + return this.createCall(signal.callId); } - private async createCall(callId: string): Promise { + private createCall(callId: string): ClientMediaCall { + this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { + logger: this.config.logger, transporter: this.transporter, processorFactories: this.config.processorFactories, mediaStreamFactory: this.config.mediaStreamFactory, @@ -172,15 +203,70 @@ export class MediaSignalingSession extends Emitter { const call = new ClientMediaCall(config, callId); this.knownCalls.set(callId, call); - call.emitter.on('contactUpdate', () => this.emit('callContactUpdate', { call })); - call.emitter.on('stateChange', (oldState) => this.emit('callStateChange', { call, oldState })); - call.emitter.on('initialized', () => this.emit('newCall', { call })); - call.emitter.on('accepted', () => this.emit('acceptedCall', { call })); - call.emitter.on('ended', () => { - this.ignoreCall(call.callId); - this.emit('endedCall', { call }); - }); + call.emitter.on('contactUpdate', () => this.onCallContactUpdate(call)); + call.emitter.on('stateChange', (oldState) => this.onCallStateChange(call, oldState)); + call.emitter.on('clientStateChange', (oldState) => this.onCallClientStateChange(call, oldState)); + call.emitter.on('initialized', () => this.onNewCall(call)); + call.emitter.on('accepted', () => this.onAcceptedCall(call)); + call.emitter.on('hidden', () => this.onHiddenCall(call)); + call.emitter.on('ended', () => this.onEndedCall(call)); return call; } + + private onCallContactUpdate(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onCallContactUpdate'); + if (call.hidden) { + return; + } + + this.emit('callContactUpdate', { call }); + } + + private onCallStateChange(call: ClientMediaCall, oldState: CallState): void { + this.config.logger?.debug('MediaSignalingSession.onCallStateChange'); + if (call.hidden && call.state !== 'hangup') { + return; + } + + this.emit('callStateChange', { call, oldState }); + } + + private onCallClientStateChange(call: ClientMediaCall, oldState: ClientState): void { + this.config.logger?.debug('MediaSignalingSession.onCallClientStateChange'); + if (call.hidden && call.state !== 'hangup') { + return; + } + + this.emit('callClientStateChange', { call, oldState }); + } + + private onNewCall(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onNewCall'); + if (call.hidden) { + return; + } + + this.emit('newCall', { call }); + } + + private onAcceptedCall(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onAcceptedCall'); + if (call.hidden) { + return; + } + + this.emit('acceptedCall', { call }); + } + + private onEndedCall(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onEndedCall'); + this.ignoreCall(call.callId); + this.emit('endedCall', { call }); + } + + private onHiddenCall(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onHiddenCall'); + this.emit('hiddenCall', { call }); + } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index dc639d9e2365e..8c82c0462cd70 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -1,4 +1,5 @@ import type { CallAnswer, CallHangupReason } from '../definition'; +import type { IMediaSignalLogger } from '../definition/logger'; import type { MediaSignalTransport, ClientMediaSignalType, @@ -11,9 +12,11 @@ export class MediaSignalTransportWrapper { constructor( public readonly contractId: string, private sendSignalFn: MediaSignalTransport, + private logger?: IMediaSignalLogger, ) {} public sendToServer(callId: string, type: T, signal: ClientMediaSignalBody) { + this.logger?.debug('MediaSignalTransportWrapper.sendToServer', type); return this.sendSignal({ callId, contractId: this.contractId, @@ -35,6 +38,7 @@ export class MediaSignalTransportWrapper { } public sendSignal(signal: ClientMediaSignal): void { + this.logger?.debug('MediaSignalTransportWrapper.sendSignal', signal); this.sendSignalFn(signal); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 63408fb7c8a58..ec67ccc175a58 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -51,6 +51,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }> { + this.config.logger?.debug('MediaCallWebRTCProcessor.createOffer'); await this.initializeLocalMediaStream(); if (iceRestart) { @@ -66,6 +67,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async createAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }> { + this.config.logger?.debug('MediaCallWebRTCProcessor.createAnswer'); if (sdp.type !== 'offer') { throw new Error('invalid-webrtc-offer'); } @@ -84,6 +86,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async setRemoteDescription({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription'); await this.initializeLocalMediaStream(); // #ToDo: validate this.peer.signalingState ? @@ -109,14 +112,17 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } protected async getuserMedia(constraints: MediaStreamConstraints) { + this.config.logger?.debug('MediaCallWebRTCProcessor.getuserMedia'); return this.config.mediaStreamFactory(constraints); } private changeInternalState(stateName: keyof WebRTCInternalStateMap): void { + this.config.logger?.debug('MediaCallWebRTCProcessor.changeInternalState', stateName); this.emitter.emit('internalStateChange', stateName); } private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { + this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription'); await this.waitForIceGathering(); // always wait a little extra to ensure all relevant events have been fired @@ -134,6 +140,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private async waitForIceGathering(): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering'); if (this.iceGatheringFinished) { return; } @@ -179,6 +186,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private restartIce() { + this.config.logger?.debug('MediaCallWebRTCProcessor.restartIce'); this.iceGatheringFinished = false; this.clearIceGatheringWaiters(new Error('ice-restart')); @@ -191,14 +199,15 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - console.log('onIceCandidate event', event.candidate); + this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', event.candidate); } private onIceCandidateError(peer: RTCPeerConnection, event: RTCPeerConnectionIceErrorEvent) { if (peer !== this.peer) { return; } - console.error('onIceCandidate ERROR event', event.errorCode, event.errorText); + this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidateError'); + this.config.logger?.error(event); this.emitter.emit('internalError', { critical: false, error: 'ice-candidate-error' }); } @@ -206,14 +215,15 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (peer !== this.peer) { return; } + this.config.logger?.debug('MediaCallWebRTCProcessor.onNegotiationNeeded'); // #ToDo negotiation needed - console.log('negotiation-needed'); } private onTrack(peer: RTCPeerConnection, event: RTCTrackEvent): void { if (peer !== this.peer) { return; } + this.config.logger?.debug('MediaCallWebRTCProcessor.onTrack', event.track.kind); // Received a remote stream this.remoteStream.setTrack(event.track, peer); } @@ -222,7 +232,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (peer !== this.peer) { return; } - + this.config.logger?.debug('MediaCallWebRTCProcessor.onConnectionStateChange'); this.changeInternalState('connection'); } @@ -230,7 +240,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (peer !== this.peer) { return; } - + this.config.logger?.debug('MediaCallWebRTCProcessor.onIceConnectionStateChange'); this.changeInternalState('iceConnection'); } @@ -239,6 +249,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } + this.config.logger?.debug('MediaCallWebRTCProcessor.onSignalingStateChange'); this.changeInternalState('signaling'); } @@ -247,6 +258,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } + this.config.logger?.debug('MediaCallWebRTCProcessor.onIceGatheringStateChange'); + if (peer.iceGatheringState === 'complete') { this.onIceGatheringComplete(); } @@ -258,6 +271,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (this.localMediaStreamInitialized) { return; } + this.config.logger?.debug('MediaCallWebRTCProcessor.initializeLocalMediaStream'); const mediaStream = await this.getuserMedia({ audio: true }); @@ -267,12 +281,14 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private onIceGatheringComplete() { + this.config.logger?.debug('MediaCallWebRTCProcessor.onIceGatheringComplete'); this.iceGatheringFinished = true; this.clearIceGatheringWaiters(); } private clearIceGatheringData(iceGatheringData: IceGatheringData, error?: Error) { + this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringData'); if (this.iceGatheringWaiters.has(iceGatheringData)) { this.iceGatheringWaiters.delete(iceGatheringData); } @@ -295,6 +311,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private clearIceGatheringWaiters(error?: Error) { + this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringWaiters'); const waiters = this.iceGatheringWaiters.values().toArray(); this.iceGatheringWaiters.clear(); this.iceGatheringTimedOut = false; From 834fd5abff3ff9c284aa12bd68e61a34be09f99d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 8 Aug 2025 22:18:46 -0300 Subject: [PATCH 27/72] active state --- .../src/agents/Manager.ts | 14 +++ .../src/agents/users/SignalProcessor.ts | 24 +++++- .../src/processSignal.ts | 7 ++ .../src/definition/call/CallEvents.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 7 +- .../definition/signals/client/MediaSignal.ts | 7 +- .../src/definition/signals/client/index.ts | 1 + .../src/definition/signals/client/register.ts | 29 +++++++ packages/media-signaling/src/lib/Call.ts | 86 +++++++++++++++---- packages/media-signaling/src/lib/Session.ts | 50 +++++++++++ .../src/models/IMediaCallChannelsModel.ts | 1 + .../src/models/IMediaCallsModel.ts | 1 + .../models/src/models/MediaCallChannels.ts | 4 + packages/models/src/models/MediaCalls.ts | 14 +++ 14 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 packages/media-signaling/src/definition/signals/client/register.ts diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/packages/media-signaling-server/src/agents/Manager.ts index aec8e603b952b..9125e4101f42e 100644 --- a/packages/media-signaling-server/src/agents/Manager.ts +++ b/packages/media-signaling-server/src/agents/Manager.ts @@ -99,6 +99,20 @@ class MediaCallAgentManager { } } + public async activateCall(agent: IMediaCallAgent): Promise { + logger.debug({ msg: 'AgentManager.activateCall', actor: agent.actor }); + + const stateResult = await MediaCalls.activateCallById(agent.callId); + if (!stateResult.modifiedCount) { + return; + } + + const otherAgent = await this.getAnyOppositeAgent(agent); + if (otherAgent) { + otherAgent.notify(agent.callId, 'active'); + } + } + public async acknowledgeCallee(agent: IMediaCallAgent): Promise { logger.debug({ msg: 'AgentManager.acknowledgeCallee', actor: agent.actor }); if (agent.role !== 'callee') { diff --git a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts index eab3316ef9403..b009963d91066 100644 --- a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts +++ b/packages/media-signaling-server/src/agents/users/SignalProcessor.ts @@ -1,5 +1,5 @@ import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { CallAnswer, ClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { CallAnswer, ClientMediaSignal, ClientMediaSignalLocalState } from '@rocket.chat/media-signaling'; import { MediaCallChannels } from '@rocket.chat/models'; import type { UserMediaCallAgent } from './Agent'; @@ -42,6 +42,9 @@ export class UserAgentSignalProcessor { case 'hangup': await agentManager.hangupCall(this.agent, signal.reason); break; + case 'local-state': + await this.reviewLocalState(params, signal); + break; } } @@ -104,4 +107,23 @@ export class UserAgentSignalProcessor { await this.agent.requestOffer(params); } + + private async reviewLocalState({ channel }: ChannelFunctionParams, signal: ClientMediaSignalLocalState): Promise { + if (!this.agent.signed) { + return; + } + + // #ToDo: Save the timestamp of the last active state so we can autodetect lost calls + + if (signal.clientState === 'active') { + if (channel.state === 'active' || channel.activeAt) { + return; + } + + const result = await MediaCallChannels.setActiveById(channel._id); + if (result.modifiedCount) { + await agentManager.activateCall(this.agent); + } + } + } } diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts index 77e9ac5203d97..ca371499ca8b9 100644 --- a/packages/media-signaling-server/src/processSignal.ts +++ b/packages/media-signaling-server/src/processSignal.ts @@ -12,6 +12,13 @@ export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id'] return processNewCallSignal(signal, uid); } + if (signal.type === 'register') { + // #ToDo: client registration + // 1. Re-send signals for any pending calls involving this actor; + // 2. Hangup active calls involving the oldContractId if it's different from the new one. + return; + } + try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 0d7ec4c1e1b29..38d78a654b854 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -15,6 +15,7 @@ export type CallEvents = { accepting: void; accepted: void; + active: void; hidden: void; ended: void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index a64c82adfa74a..962ec4312bbaa 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -44,6 +44,7 @@ export type CallAnswer = export type CallNotification = | 'accepted' // notify that the call has been accepted by both actors + | 'active' // notify that call activity was confirmed | 'hangup'; // notify that the call is over; export type CallRejectedReason = @@ -69,7 +70,7 @@ export interface IClientMediaCall { getRemoteMediaStream(): MediaStream; - accept(): Promise; - reject(): Promise; - hangup(): Promise; + accept(): void; + reject(): void; + hangup(): void; } diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts index 9ab5f1b64b313..306e66d63c38f 100644 --- a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -5,6 +5,7 @@ import { clientMediaSignalErrorSchema, type ClientMediaSignalError } from './err import { clientMediaSignalHangupSchema, type ClientMediaSignalHangup } from './hangup'; import { clientMediaSignalLocalSDPSchema, type ClientMediaSignalLocalSDP } from './local-sdp'; import { clientMediaSignalLocalStateSchema, type ClientMediaSignalLocalState } from './local-state'; +import { clientMediaSignalRegisterSchema, type ClientMediaSignalRegister } from './register'; import { clientMediaSignalRequestCallSchema, type ClientMediaSignalRequestCall } from './request-call'; const ajv = new Ajv({ discriminator: true }); @@ -15,7 +16,8 @@ export type ClientMediaSignal = | ClientMediaSignalAnswer | ClientMediaSignalHangup | ClientMediaSignalRequestCall - | ClientMediaSignalLocalState; + | ClientMediaSignalLocalState + | ClientMediaSignalRegister; export const clientMediaSignalSchema: JSONSchemaType = { type: 'object', @@ -29,8 +31,9 @@ export const clientMediaSignalSchema: JSONSchemaType = { clientMediaSignalHangupSchema, clientMediaSignalRequestCallSchema, clientMediaSignalLocalStateSchema, + clientMediaSignalRegisterSchema, ], -} as const; +}; export const isClientMediaSignal = ajv.compile(clientMediaSignalSchema); diff --git a/packages/media-signaling/src/definition/signals/client/index.ts b/packages/media-signaling/src/definition/signals/client/index.ts index 03261aba4025d..078d5ed781f2f 100644 --- a/packages/media-signaling/src/definition/signals/client/index.ts +++ b/packages/media-signaling/src/definition/signals/client/index.ts @@ -3,6 +3,7 @@ export * from './error'; export * from './hangup'; export * from './local-sdp'; export * from './local-state'; +export * from './register'; export * from './request-call'; export * from './MediaSignal'; diff --git a/packages/media-signaling/src/definition/signals/client/register.ts b/packages/media-signaling/src/definition/signals/client/register.ts new file mode 100644 index 0000000000000..a1544d7092136 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/register.ts @@ -0,0 +1,29 @@ +import type { JSONSchemaType } from 'ajv'; + +// Client is reporting a brand new session +export type ClientMediaSignalRegister = { + type: 'register'; + contractId: string; + + oldContractId?: string; +}; + +export const clientMediaSignalRegisterSchema: JSONSchemaType = { + type: 'object', + properties: { + contractId: { + type: 'string', + nullable: false, + }, + type: { + type: 'string', + const: 'register', + }, + oldContractId: { + type: 'string', + nullable: true, + }, + }, + additionalProperties: false, + required: ['contractId', 'type'], +}; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index be27a4b5f9002..04208acb492ed 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -324,7 +324,7 @@ export class ClientMediaCall implements IClientMediaCall { } } - public async accept(): Promise { + public accept(): void { this.config.logger?.debug('ClientMediaCall.accept'); if (!this.isPendingOurAcceptance()) { @@ -346,7 +346,7 @@ export class ClientMediaCall implements IClientMediaCall { } } - public async reject(): Promise { + public reject(): void { this.config.logger?.debug('ClientMediaCall.reject'); if (!this.isPendingOurAcceptance()) { @@ -361,7 +361,7 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('hangup'); } - public async hangup(reason: CallHangupReason = 'normal'): Promise { + public hangup(reason: CallHangupReason = 'normal'): void { this.config.logger?.debug('ClientMediaCall.hangup', reason); if (this.endedLocally || this._state === 'hangup') { return; @@ -447,6 +447,26 @@ export class ClientMediaCall implements IClientMediaCall { } } + public reportStates(): void { + this.config.logger?.debug('ClientMediaCall.reportStates'); + this.clearStateReporter(); + if (!this.mayReportStates) { + return; + } + + this.config.transporter.sendToServer(this.callId, 'local-state', { + callState: this.state, + clientState: this.getClientState(), + serviceStates: Object.fromEntries(this.serviceStates.entries()), + ignored: this.ignored, + contractState: this.contractState, + }); + + if (this.state === 'hangup') { + this.mayReportStates = false; + } + } + private changeState(newState: CallState): void { if (newState === this._state) { return; @@ -464,8 +484,12 @@ export class ClientMediaCall implements IClientMediaCall { switch (newState) { case 'accepted': this.emitter.emit('accepted'); - break; + case 'active': + this.emitter.emit('active'); + this.reportStates(); + break; + case 'hangup': this.emitter.emit('ended'); break; @@ -662,6 +686,11 @@ export class ClientMediaCall implements IClientMediaCall { switch (signal.notification) { case 'accepted': return this.flagAsAccepted(); + case 'active': + if (this.state === 'accepted') { + this.changeState('active'); + } + return; case 'hangup': return this.flagAsEnded('remote'); @@ -762,27 +791,48 @@ export class ClientMediaCall implements IClientMediaCall { if (this.serviceStates.get(stateName) !== stateValue) { this.serviceStates.set(stateName, stateValue); + + switch (stateName) { + case 'connection': + this.onWebRTCConnectionStateChange(stateValue as RTCPeerConnectionState); + break; + } + this.requestStateReport(); } } - private reportStates(): void { - this.config.logger?.debug('ClientMediaCall.reportStates'); - this.clearStateReporter(); - if (!this.mayReportStates) { + private onWebRTCConnectionStateChange(stateValue: RTCPeerConnectionState): void { + if (this.hidden) { return; } - this.config.transporter.sendToServer(this.callId, 'local-state', { - callState: this.state, - clientState: this.getClientState(), - serviceStates: Object.fromEntries(this.serviceStates.entries()), - ignored: this.ignored, - contractState: this.contractState, - }); - - if (this.state === 'hangup') { - this.mayReportStates = false; + try { + switch (stateValue) { + case 'connected': + if (this.state === 'accepted') { + this.changeState('active'); + } + break; + case 'failed': + if (!this.isOver()) { + this.hangup('service-error'); + } + break; + case 'closed': + if (!this.isOver()) { + this.hangup('service-error'); + } + break; + case 'disconnected': + // #ToDo: Attempt to reconnect when possible + if (this.state === 'active') { + this.hangup('service-error'); + } + break; + } + } catch (e) { + this.config.logger?.error('An error occured while reviewing the webrtc connection state change', e); } } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index ac7d44cd6605b..c221471882f0e 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -20,18 +20,22 @@ export type MediaSignalingEvents = { callClientStateChange: { call: IClientMediaCall; oldState: ClientState }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; + activeCall: { call: IClientMediaCall }; endedCall: { call: IClientMediaCall }; hiddenCall: { call: IClientMediaCall }; }; export type MediaSignalingSessionConfig = { userId: string; + oldSessionId?: string; logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; transport: MediaSignalTransport; }; +const STATE_REPORT_INTERVAL = 60000; + export class MediaSignalingSession extends Emitter { private _userId: string; @@ -43,6 +47,8 @@ export class MediaSignalingSession extends Emitter { private transporter: MediaSignalTransportWrapper; + private recurringStateReportHandler: ReturnType | null; + public get sessionId(): string { return this._sessionId; } @@ -55,10 +61,14 @@ export class MediaSignalingSession extends Emitter { super(); this._userId = config.userId; this._sessionId = createRandomToken(8); + this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); + + this.register(); + this.enableStateReport(STATE_REPORT_INTERVAL); } public isBusy(): boolean { @@ -70,6 +80,21 @@ export class MediaSignalingSession extends Emitter { return ['accepted', 'active'].includes(call.state); } + public enableStateReport(interval: number): void { + this.disableStateReport(); + + this.recurringStateReportHandler = setInterval(() => { + this.reportState(); + }, interval); + } + + public disableStateReport(): void { + if (this.recurringStateReportHandler) { + clearInterval(this.recurringStateReportHandler); + this.recurringStateReportHandler = null; + } + } + public getCallData(callId: string): IClientMediaCall | null { return this.knownCalls.get(callId) || null; } @@ -191,6 +216,25 @@ export class MediaSignalingSession extends Emitter { return this.createCall(signal.callId); } + private reportState(): void { + const call = this.getMainCall() as ClientMediaCall | null; + if (call && !call.isOver()) { + call.reportStates(); + return; + } + + // If we don't have any call to report the state on, send a register signal instead; the server will ignore it if there's nothing on that side either + this.register(); + } + + private register(): void { + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); + } + private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { @@ -209,6 +253,7 @@ export class MediaSignalingSession extends Emitter { call.emitter.on('initialized', () => this.onNewCall(call)); call.emitter.on('accepted', () => this.onAcceptedCall(call)); call.emitter.on('hidden', () => this.onHiddenCall(call)); + call.emitter.on('active', () => this.onActiveCall(call)); call.emitter.on('ended', () => this.onEndedCall(call)); return call; @@ -269,4 +314,9 @@ export class MediaSignalingSession extends Emitter { this.config.logger?.debug('MediaSignalingSession.onHiddenCall'); this.emit('hiddenCall', { call }); } + + private onActiveCall(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onActiveCall'); + this.emit('activeCall', { call }); + } } diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index ff489adcaf8f8..1cd5a2c2b85b1 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -7,5 +7,6 @@ export interface IMediaCallChannelsModel extends IBaseModel { createOrUpdateChannel(channel: InsertionModel): Promise; findOneByCallIdAndActor(callId: string, actor: Required): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; + setActiveById(_id: string): Promise; setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 9048c4be58c06..61a7938e1b1d2 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -11,6 +11,7 @@ export interface IMediaCallsModel extends IBaseModel { ): Promise; startRingingById(callId: string): Promise; acceptCallById(callId: string, calleeContractId: string): Promise; + activateCallById(callId: string): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; hangupEveryCall(params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index f6cdfa274a242..96f291a13661e 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -68,6 +68,10 @@ export class MediaCallChannelsRaw extends BaseRaw implements return this.updateOneById(_id, { $set: { state } }); } + public async setActiveById(_id: string): Promise { + return this.updateOne({ _id, activeAt: { $exists: false } }, { $set: { state: 'active', activeAt: new Date() } }); + } + public async setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise { return this.updateOneById(_id, { $set: { localDescription } }); } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 3bae67c02f0e7..c7c68e515e944 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -64,6 +64,20 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async activateCallById(callId: string): Promise { + return this.updateOne( + { + _id: callId, + state: 'accepted', + }, + { + $set: { + state: 'active', + }, + }, + ); + } + public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { const { endedBy, reason } = params || {}; From 4afb2af10be66072529e5ea80ffa3af496f55936 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 11 Aug 2025 13:52:36 -0300 Subject: [PATCH 28/72] organized global media signaling server functions --- .../server/services/media-call/service.ts | 15 +- .../src/mediaCalls/IMediaCallChannel.ts | 1 + .../src/agents/users/BasicAgent.ts | 4 +- .../media-signaling-server/src/createCall.ts | 69 ----- .../src/global/ISignalGateway.ts | 17 ++ .../src/global/SignalGateway.ts | 52 ++++ .../src/global/SignalProcessor.ts | 254 ++++++++++++++++++ packages/media-signaling-server/src/index.ts | 8 +- .../src/mutateCallee.ts | 16 -- .../src/processNewCallSignal.ts | 111 -------- .../src/processSignal.ts | 56 ---- .../src/signalHandler.ts | 22 -- packages/rest-typings/package.json | 1 - 13 files changed, 339 insertions(+), 287 deletions(-) delete mode 100644 packages/media-signaling-server/src/createCall.ts create mode 100644 packages/media-signaling-server/src/global/ISignalGateway.ts create mode 100644 packages/media-signaling-server/src/global/SignalGateway.ts create mode 100644 packages/media-signaling-server/src/global/SignalProcessor.ts delete mode 100644 packages/media-signaling-server/src/mutateCallee.ts delete mode 100644 packages/media-signaling-server/src/processNewCallSignal.ts delete mode 100644 packages/media-signaling-server/src/processSignal.ts delete mode 100644 packages/media-signaling-server/src/signalHandler.ts diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 4575857eb498d..b7207de1a4577 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,21 +1,24 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { processSignal, createCall, setSignalHandler, logger } from '@rocket.chat/media-signaling-server'; +import { gateway } from '@rocket.chat/media-signaling-server'; import { MediaCalls, Users } from '@rocket.chat/models'; +const logger = new Logger('media-call service'); + export class MediaCallService extends ServiceClassInternal implements IMediaCallService { protected name = 'media-call'; constructor() { super(); - setSignalHandler(this.sendSignal.bind(this)); + gateway.setSignalHandler(this.sendSignal.bind(this)); } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { logger.debug({ msg: 'new client signal', signal, uid }); - await processSignal(signal, uid); + gateway.receiveSignal(uid, signal); } catch (error) { logger.error({ msg: 'failed to process client signal', error, signal, uid }); } @@ -27,7 +30,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall const deserialized = await this.deserializeClientSignal(signal); - await processSignal(deserialized, uid); + gateway.receiveSignal(uid, deserialized); } catch (error) { logger.error({ msg: 'failed to process client signal', error, signal, uid }); } @@ -39,7 +42,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('invalid-user'); } - return createCall({ + return gateway.createCall({ caller: { type: 'user', id: caller.uid, @@ -55,7 +58,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall throw new Error('invalid-user'); } - return createCall({ + return gateway.createCall({ caller: { type: 'user', id: caller.uid, diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index b6b09b5e441b4..bb2e5daa4f842 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -25,5 +25,6 @@ export interface IMediaCallChannel extends IRocketChatRecord { localDescription?: RTCSessionDescriptionInit; + // for rocket.chat users, acknowledged means that a client user session was reached by the signaling acknowledged: boolean; } diff --git a/packages/media-signaling-server/src/agents/users/BasicAgent.ts b/packages/media-signaling-server/src/agents/users/BasicAgent.ts index 989cec3ca7695..4454923c380eb 100644 --- a/packages/media-signaling-server/src/agents/users/BasicAgent.ts +++ b/packages/media-signaling-server/src/agents/users/BasicAgent.ts @@ -1,8 +1,8 @@ import type { IUser, MediaCallActor } from '@rocket.chat/core-typings'; import type { CallContact, CallRole, ServerMediaSignal, CallNotification } from '@rocket.chat/media-signaling'; +import { gateway } from '../../global/SignalGateway'; import { logger } from '../../logger'; -import { sendSignal } from '../../signalHandler'; import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; import { MediaCallBasicAgent } from '../definition/IMediaCallAgent'; @@ -45,6 +45,6 @@ export class UserBasicAgent } protected async sendSignal(signal: ServerMediaSignal): Promise { - sendSignal(this.user._id, signal); + gateway.sendSignal(this.user._id, signal); } } diff --git a/packages/media-signaling-server/src/createCall.ts b/packages/media-signaling-server/src/createCall.ts deleted file mode 100644 index 5292719156483..0000000000000 --- a/packages/media-signaling-server/src/createCall.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { IMediaCall, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; -import type { CallService } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { agentManager } from './agents/Manager'; -import { logger } from './logger'; - -type CreateCallParams = { - caller: MediaCallSignedActor; - callee: MediaCallActor; - requestedCallId?: string; - requestedService?: CallService; -}; - -export async function createCall(params: CreateCallParams): Promise { - logger.debug({ msg: 'createCall', params }); - const { caller, callee, requestedCallId, requestedService } = params; - - // The caller must always have a contract to create the call - if (!caller.contractId) { - throw new Error('invalid-caller'); - } - - const callerAgent = await agentManager.getNewAgentForActor(caller, 'caller'); - if (!callerAgent) { - throw new Error('invalid-caller'); - } - - const calleeAgent = await agentManager.getNewAgentForActor(callee, 'callee'); - if (!calleeAgent) { - throw new Error('invalid-callee'); - } - - const service = requestedService || 'webrtc'; - - // webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call - if (service !== 'webrtc') { - throw new Error('invalid-call-service'); - } - - const call: Omit = { - service, - kind: 'direct', - state: 'none', - - createdBy: caller, - createdAt: new Date(), - - caller, - callee, - - ...(requestedCallId && { callerRequestedId: requestedCallId }), - }; - - const insertResult = await MediaCalls.insertOne(call); - if (!insertResult.insertedId) { - throw new Error('failed-to-create-call'); - } - - const newCall = await MediaCalls.findOneById(insertResult.insertedId); - if (!newCall) { - throw new Error('failed-to-create-call'); - } - - await callerAgent.onNewCall(newCall, calleeAgent); - await calleeAgent.onNewCall(newCall, callerAgent); - - return newCall; -} diff --git a/packages/media-signaling-server/src/global/ISignalGateway.ts b/packages/media-signaling-server/src/global/ISignalGateway.ts new file mode 100644 index 0000000000000..a08d4b1dad979 --- /dev/null +++ b/packages/media-signaling-server/src/global/ISignalGateway.ts @@ -0,0 +1,17 @@ +import type { IMediaCall, IUser, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { CallService, ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; + +export type CreateCallParams = { + caller: MediaCallSignedActor; + callee: MediaCallActor; + requestedCallId?: string; + requestedService?: CallService; +}; + +export type ServerSignalTransport = (uid: IUser['_id'], signal: ServerMediaSignal) => void; + +export interface ISignalGateway { + createCall(params: CreateCallParams): Promise; + setSignalHandler(handlerFn: ServerSignalTransport): void; + receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; +} diff --git a/packages/media-signaling-server/src/global/SignalGateway.ts b/packages/media-signaling-server/src/global/SignalGateway.ts new file mode 100644 index 0000000000000..df6ce65392307 --- /dev/null +++ b/packages/media-signaling-server/src/global/SignalGateway.ts @@ -0,0 +1,52 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; + +import { logger } from '../logger'; +import type { ISignalGateway, ServerSignalTransport } from './ISignalGateway'; +import { GlobalSignalProcessor } from './SignalProcessor'; + +// Class used as gateway to send and receive signals to/from clients +// The actual function used to send the signals needs to be set by the server +export class SignalGateway extends GlobalSignalProcessor implements ISignalGateway { + private handler: ServerSignalTransport | null = null; + + public setSignalHandler(handlerFn: ServerSignalTransport): void { + this.handler = handlerFn; + } + + public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { + logger.debug({ msg: 'SignalGateway.receiveSignal', signal, fromUid }); + + if (!isClientMediaSignal(signal)) { + this.debugError('The Media Signal Server received an invalid client signal object', { signal }); + throw new Error('invalid-signal'); + } + + this.processSignal(fromUid, signal).catch((error) => + this.debugError('Failed to process client signal', { signal }, { error, type: signal.type }), + ); + } + + public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { + logger.debug({ msg: 'SignalGateway.sendSignal', toUid, signal }); + + if (!this.handler) { + this.debugError('The Media Signal Server tried to send a signal to a client without being configured first.', { signal }); + throw new Error('media-signaling-server-not-configured'); + } + + try { + this.handler(toUid, signal); + } catch (error) { + this.debugError(`SignalGateway's handler threw an exception`, { toUid, signal }, { error }); + } + } + + private debugError(msg: string, debugInfo: Record, errorInfo?: Record): void { + logger.error({ msg, ...errorInfo }); + logger.debug({ msg: `${msg} - debug info`, ...debugInfo }); + } +} + +export const gateway = new SignalGateway(); diff --git a/packages/media-signaling-server/src/global/SignalProcessor.ts b/packages/media-signaling-server/src/global/SignalProcessor.ts new file mode 100644 index 0000000000000..c7e3dbf25d6c1 --- /dev/null +++ b/packages/media-signaling-server/src/global/SignalProcessor.ts @@ -0,0 +1,254 @@ +import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { + CallActorType, + ClientMediaSignal, + ClientMediaSignalRegister, + ClientMediaSignalRequestCall, + ServerMediaSignal, + ServerMediaSignalRejectedCallRequest, +} from '@rocket.chat/media-signaling'; +import { MediaCalls, Users } from '@rocket.chat/models'; + +import { agentManager } from '../agents/Manager'; +import { UserAgentFactory } from '../agents/users/AgentFactory'; +import { logger } from '../logger'; +import type { CreateCallParams } from './ISignalGateway'; + +export abstract class GlobalSignalProcessor { + protected abstract sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; + + public async createCall(params: CreateCallParams): Promise { + logger.debug({ msg: 'GlobalSignalProcessor.createCall', params }); + const { caller, callee, requestedCallId, requestedService } = params; + + // The caller must always have a contract to create the call + if (!caller.contractId) { + throw new Error('invalid-caller'); + } + + const callerAgent = await agentManager.getNewAgentForActor(caller, 'caller'); + if (!callerAgent) { + throw new Error('invalid-caller'); + } + + const calleeAgent = await agentManager.getNewAgentForActor(callee, 'callee'); + if (!calleeAgent) { + throw new Error('invalid-callee'); + } + + const service = requestedService || 'webrtc'; + + // webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call + if (service !== 'webrtc') { + throw new Error('invalid-call-service'); + } + + const call: Omit = { + service, + kind: 'direct', + state: 'none', + + createdBy: caller, + createdAt: new Date(), + + caller, + callee, + + ...(requestedCallId && { callerRequestedId: requestedCallId }), + }; + + const insertResult = await MediaCalls.insertOne(call); + if (!insertResult.insertedId) { + throw new Error('failed-to-create-call'); + } + + const newCall = await MediaCalls.findOneById(insertResult.insertedId); + if (!newCall) { + throw new Error('failed-to-create-call'); + } + + await callerAgent.onNewCall(newCall, calleeAgent); + await calleeAgent.onNewCall(newCall, callerAgent); + + return newCall; + } + + protected async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { + logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal, uid }); + + switch (signal.type) { + case 'register': + return this.processRegisterSignal(uid, signal); + case 'request-call': + return this.processRequestCallSignal(uid, signal); + } + + if ('callId' in signal) { + return this.processCallSignal(uid, signal); + } + + logger.error({ msg: 'Unrecognized media signal', signal }); + } + + protected async mutateCallee(callee: { type: CallActorType; id: string }): Promise<{ type: CallActorType; id: string }> { + if (callee.type !== 'sip') { + return callee; + } + + const user = await Users.findOneByFreeSwitchExtension>(callee.id, { projection: { _id: 1 } }); + if (user) { + return { type: 'user', id: user._id }; + } + + return callee; + } + + private async processCallSignal( + uid: IUser['_id'], + signal: Exclude, + ): Promise { + try { + const call = await MediaCalls.findOneById(signal.callId); + if (!call) { + logger.error({ msg: 'call not found', method: 'GlobalSignalProcessor.processCallSignal', signal }); + throw new Error('invalid-call'); + } + + const isCaller = call.caller.type === 'user' && call.caller.id === uid; + const isCallee = call.callee.type === 'user' && call.callee.id === uid; + if (!isCaller && !isCallee) { + logger.error({ msg: 'actor is not part of the call', method: 'processSignal', signal }); + throw new Error('invalid-call'); + } + + // Ignore signals from different sessions + if (isCaller && call.caller.contractId && call.caller.contractId !== signal.contractId) { + return; + } + if (isCallee && call.callee.contractId && call.callee.contractId !== signal.contractId) { + return; + } + + const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); + const agent = factory?.getCallAgent(call); + if (!agent) { + logger.error({ msg: 'agent not found', method: 'processSignal', signal }); + throw new Error('invalid-call'); + } + + await agent.processSignal(signal, call); + } catch (e) { + logger.error(e); + throw e; + } + } + + private async processRegisterSignal(_uid: IUser['_id'], _signal: ClientMediaSignalRegister): Promise { + // #ToDo: client registration + // 1. Re-send signals for any pending calls involving this actor; + // 2. Hangup active calls involving the oldContractId if it's different from the new one. + } + + private async processRequestCallSignal(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise { + logger.debug({ msg: 'GlobalSignalProcessor.processNewCallSignal', signal, uid }); + + const caller = { type: 'user', id: uid } as const; + const callee = await this.mutateCallee(signal.callee); + + const existingCall = await this.getExistingRequestedCall(uid, signal, callee); + + if (existingCall) { + return; + } + + const services = signal.supportedServices ?? []; + const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); + + try { + await this.createCall({ + caller: { + ...caller, + contractId: signal.contractId, + }, + callee: await this.mutateCallee(signal.callee), + requestedCallId: signal.callId, + ...(requestedService && { requestedService }), + }); + } catch (e) { + this.sendSignal(uid, { + type: 'rejected-call-request', + callId: signal.callId, + toContractId: signal.contractId, + reason: 'unsupported', + }); + throw e; + } + } + + private async getExistingRequestedCall( + uid: IUser['_id'], + signal: ClientMediaSignalRequestCall, + callee: MediaCallActor, + ): Promise { + const { callId: requestedCallId, callee: originalCallee } = signal; + + if (!requestedCallId) { + return null; + } + + logger.debug({ msg: 'GlobalSignalProcessor.getExistingRequestedCall', uid, signal, callee }); + + const caller = { type: 'user', id: uid } as const; + const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; + + const call = await MediaCalls.findOneByIdOrCallerRequestedId(requestedCallId, caller); + + if (!call) { + return null; + } + + // if the requested id matches a real call id, we block it to avoid any potential issues as the requestedId is not expected to ever be an actual call id. + // if the call is already over, we treat it as an invalid id since it can't be reused + if (call._id === requestedCallId || call.state === 'hangup') { + this.invalidCallId(uid, rejection); + } + + const isMutatedCallee = call.callee.type === callee.type && call.callee.id === callee.id; + const isOriginalCallee = call.callee.type === originalCallee.type && call.callee.id === originalCallee.id; + const isSameCallee = isMutatedCallee || isOriginalCallee; + const isSameContract = call.caller.contractId === signal.contractId; + + if (!isSameCallee || !isSameContract) { + this.invalidCallId(uid, { ...rejection, reason: 'existing-call-id' }); + } + + if (signal.supportedServices?.length && !signal.supportedServices.includes(call.service)) { + this.invalidCallId(uid, { ...rejection, reason: 'unsupported' }); + } + + // if the call is already accepted, we won't send its signals again + if (['active'].includes(call.state)) { + this.invalidCallId(uid, { ...rejection, reason: 'already-requested' }); + } + + this.sendSignal(uid, { + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: 'caller', + contact: { + ...call.callee, + }, + requestedCallId: signal.callId, + }); + + return call; + } + + private invalidCallId(uid: IUser['_id'], rejection: Omit): never { + this.sendSignal(uid, { type: 'rejected-call-request', ...rejection }); + + throw new Error(rejection.reason ?? 'invalid-call-id'); + } +} diff --git a/packages/media-signaling-server/src/index.ts b/packages/media-signaling-server/src/index.ts index 3fff0dbb09f21..b2634059bae7e 100644 --- a/packages/media-signaling-server/src/index.ts +++ b/packages/media-signaling-server/src/index.ts @@ -1,4 +1,4 @@ -export { createCall } from './createCall'; -export { processSignal } from './processSignal'; -export { setSignalHandler } from './signalHandler'; -export { logger } from './logger'; +import type { ISignalGateway } from './global/ISignalGateway'; +import { gateway as signalGateway } from './global/SignalGateway'; + +export const gateway: ISignalGateway = signalGateway; diff --git a/packages/media-signaling-server/src/mutateCallee.ts b/packages/media-signaling-server/src/mutateCallee.ts deleted file mode 100644 index 7df3ae0824dc8..0000000000000 --- a/packages/media-signaling-server/src/mutateCallee.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { CallActorType } from '@rocket.chat/media-signaling'; -import { Users } from '@rocket.chat/models'; - -export async function mutateCallee(callee: { type: CallActorType; id: string }): Promise<{ type: CallActorType; id: string }> { - if (callee.type !== 'sip') { - return callee; - } - - const user = await Users.findOneByFreeSwitchExtension>(callee.id, { projection: { _id: 1 } }); - if (user) { - return { type: 'user', id: user._id }; - } - - return callee; -} diff --git a/packages/media-signaling-server/src/processNewCallSignal.ts b/packages/media-signaling-server/src/processNewCallSignal.ts deleted file mode 100644 index d4eb251bb670b..0000000000000 --- a/packages/media-signaling-server/src/processNewCallSignal.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; -import type { ClientMediaSignalRequestCall, ServerMediaSignalRejectedCallRequest } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { createCall } from './createCall'; -import { logger } from './logger'; -import { mutateCallee } from './mutateCallee'; -import { sendSignal } from './signalHandler'; - -function invalidCallId(uid: IUser['_id'], rejection: Omit): never { - void sendSignal(uid, { type: 'rejected-call-request', ...rejection }).catch(() => null); - - throw new Error(rejection.reason ?? 'invalid-call-id'); -} - -async function getExistingCall( - uid: IUser['_id'], - signal: ClientMediaSignalRequestCall, - callee: MediaCallActor, -): Promise { - const { callId: requestedCallId, callee: originalCallee } = signal; - - if (!requestedCallId) { - return null; - } - - logger.debug({ msg: 'getExistingCall', uid, signal, callee }); - - const caller = { type: 'user', id: uid } as const; - const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; - - const call = await MediaCalls.findOneByIdOrCallerRequestedId(requestedCallId, caller); - - if (!call) { - return null; - } - - // if the requested id matches a real call id, we block it to avoid any potential issues as the requestedId is not expected to ever be an actual call id. - // if the call is already over, we treat it as an invalid id since it can't be reused - if (call._id === requestedCallId || call.state === 'hangup') { - invalidCallId(uid, rejection); - } - - const isMutatedCallee = call.callee.type === callee.type && call.callee.id === callee.id; - const isOriginalCallee = call.callee.type === originalCallee.type && call.callee.id === originalCallee.id; - const isSameCallee = isMutatedCallee || isOriginalCallee; - const isSameContract = call.caller.contractId === signal.contractId; - - if (!isSameCallee || !isSameContract) { - invalidCallId(uid, { ...rejection, reason: 'existing-call-id' }); - } - - if (signal.supportedServices?.length && !signal.supportedServices.includes(call.service)) { - invalidCallId(uid, { ...rejection, reason: 'unsupported' }); - } - - // if the call is already accepted, we won't send its signals again - if (['active'].includes(call.state)) { - invalidCallId(uid, { ...rejection, reason: 'already-requested' }); - } - - sendSignal(uid, { - callId: call._id, - type: 'new', - service: call.service, - kind: call.kind, - role: 'caller', - contact: { - ...call.callee, - }, - requestedCallId: signal.callId, - }); - - return call; -} - -export async function processNewCallSignal(signal: ClientMediaSignalRequestCall, uid: IUser['_id']): Promise { - logger.debug({ msg: 'processNewCallSignal', signal, uid }); - - const caller = { type: 'user', id: uid } as const; - const callee = await mutateCallee(signal.callee); - - const existingCall = await getExistingCall(uid, signal, callee); - - if (existingCall) { - return; - } - - const services = signal.supportedServices ?? []; - const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); - - try { - await createCall({ - caller: { - ...caller, - contractId: signal.contractId, - }, - callee: await mutateCallee(signal.callee), - requestedCallId: signal.callId, - ...(requestedService && { requestedService }), - }); - } catch (e) { - void sendSignal(uid, { - type: 'rejected-call-request', - callId: signal.callId, - toContractId: signal.contractId, - reason: 'unsupported', - }).catch(() => null); - throw e; - } -} diff --git a/packages/media-signaling-server/src/processSignal.ts b/packages/media-signaling-server/src/processSignal.ts deleted file mode 100644 index ca371499ca8b9..0000000000000 --- a/packages/media-signaling-server/src/processSignal.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; - -import { UserAgentFactory } from './agents/users/AgentFactory'; -import { logger } from './logger'; -import { processNewCallSignal } from './processNewCallSignal'; - -export async function processSignal(signal: ClientMediaSignal, uid: IUser['_id']): Promise { - logger.debug({ msg: 'processSignal', signal, uid }); - if (signal.type === 'request-call') { - return processNewCallSignal(signal, uid); - } - - if (signal.type === 'register') { - // #ToDo: client registration - // 1. Re-send signals for any pending calls involving this actor; - // 2. Hangup active calls involving the oldContractId if it's different from the new one. - return; - } - - try { - const call = await MediaCalls.findOneById(signal.callId); - if (!call) { - logger.error({ msg: 'call not found', method: 'processSignal', signal }); - throw new Error('invalid-call'); - } - - const isCaller = call.caller.type === 'user' && call.caller.id === uid; - const isCallee = call.callee.type === 'user' && call.callee.id === uid; - if (!isCaller && !isCallee) { - logger.error({ msg: 'actor is not part of the call', method: 'processSignal', signal }); - throw new Error('invalid-call'); - } - - // Ignore signals from different sessions - if (isCaller && call.caller.contractId && call.caller.contractId !== signal.contractId) { - return; - } - if (isCallee && call.callee.contractId && call.callee.contractId !== signal.contractId) { - return; - } - - const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); - const agent = factory?.getCallAgent(call); - if (!agent) { - logger.error({ msg: 'agent not found', method: 'processSignal', signal }); - throw new Error('invalid-call'); - } - - await agent.processSignal(signal, call); - } catch (e) { - logger.error(e); - throw e; - } -} diff --git a/packages/media-signaling-server/src/signalHandler.ts b/packages/media-signaling-server/src/signalHandler.ts deleted file mode 100644 index 411e3e63858e5..0000000000000 --- a/packages/media-signaling-server/src/signalHandler.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; - -import { logger } from './logger'; - -export type SignalHandler = (uid: IUser['_id'], signal: ServerMediaSignal) => void; - -let handler: SignalHandler | null = null; - -export function setSignalHandler(handlerFn: SignalHandler): void { - handler = handlerFn; -} - -export async function sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { - logger.debug({ msg: 'sendSignal', toUid, signal }); - - if (!handler) { - throw new Error('media-signaling-server-not-configured'); - } - - return handler(toUid, signal); -} diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index baee81b3343cd..021bdfef61a92 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -25,7 +25,6 @@ ], "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/ui-kit": "workspace:~", "ajv": "^8.17.1", From ebf940ed4da72fcd813e0998007dc783053fd3fd Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 11 Aug 2025 14:12:46 -0300 Subject: [PATCH 29/72] renamed package --- apps/meteor/package.json | 2 +- apps/meteor/server/services/media-call/service.ts | 2 +- .../packages/media-calls}/.eslintrc.json | 0 .../packages/media-calls}/package.json | 4 ++-- .../packages/media-calls}/src/agents/Manager.ts | 0 .../src/agents/definition/IMediaCallAgent.ts | 0 .../media-calls}/src/agents/definition/common.ts | 0 .../packages/media-calls}/src/agents/users/Agent.ts | 0 .../media-calls}/src/agents/users/AgentFactory.ts | 0 .../packages/media-calls}/src/agents/users/BasicAgent.ts | 0 .../media-calls}/src/agents/users/NewCallAgent.ts | 0 .../media-calls}/src/agents/users/SignalProcessor.ts | 0 .../packages/media-calls}/src/global/ISignalGateway.ts | 0 .../packages/media-calls}/src/global/SignalGateway.ts | 2 +- .../packages/media-calls}/src/global/SignalProcessor.ts | 0 .../packages/media-calls}/src/index.ts | 0 .../packages/media-calls}/src/logger.ts | 0 .../packages/media-calls}/tsconfig.json | 0 packages/core-services/package.json | 2 +- yarn.lock | 9 ++++----- 20 files changed, 10 insertions(+), 11 deletions(-) rename {packages/media-signaling-server => ee/packages/media-calls}/.eslintrc.json (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/package.json (91%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/Manager.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/definition/IMediaCallAgent.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/definition/common.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/users/Agent.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/users/AgentFactory.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/users/BasicAgent.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/users/NewCallAgent.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/agents/users/SignalProcessor.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/global/ISignalGateway.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/global/SignalGateway.ts (97%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/global/SignalProcessor.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/index.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/src/logger.ts (100%) rename {packages/media-signaling-server => ee/packages/media-calls}/tsconfig.json (100%) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index ac09800e5e457..d19ec5c78d2cb 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -270,8 +270,8 @@ "@rocket.chat/log-format": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/logo": "^0.32.2", + "@rocket.chat/media-calls": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", - "@rocket.chat/media-signaling-server": "workspace:^", "@rocket.chat/memo": "~0.31.25", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/model-typings": "workspace:^", diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index b7207de1a4577..439d6a90e8edb 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -2,7 +2,7 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/ import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { gateway } from '@rocket.chat/media-signaling-server'; +import { gateway } from '@rocket.chat/media-calls'; import { MediaCalls, Users } from '@rocket.chat/models'; const logger = new Logger('media-call service'); diff --git a/packages/media-signaling-server/.eslintrc.json b/ee/packages/media-calls/.eslintrc.json similarity index 100% rename from packages/media-signaling-server/.eslintrc.json rename to ee/packages/media-calls/.eslintrc.json diff --git a/packages/media-signaling-server/package.json b/ee/packages/media-calls/package.json similarity index 91% rename from packages/media-signaling-server/package.json rename to ee/packages/media-calls/package.json index b8a2f433cdc40..f5c0ec93644b3 100644 --- a/packages/media-signaling-server/package.json +++ b/ee/packages/media-calls/package.json @@ -1,5 +1,5 @@ { - "name": "@rocket.chat/media-signaling-server", + "name": "@rocket.chat/media-calls", "version": "0.0.1", "private": true, "devDependencies": { @@ -16,7 +16,7 @@ "test": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", - "build-preview": "mkdir -p ../../.preview && cp -r ./dist ../../.preview/media-signaling-server" + "build-preview": "mkdir -p ../../.preview && cp -r ./dist ../../.preview/media-calls" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/packages/media-signaling-server/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts similarity index 100% rename from packages/media-signaling-server/src/agents/Manager.ts rename to ee/packages/media-calls/src/agents/Manager.ts diff --git a/packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts similarity index 100% rename from packages/media-signaling-server/src/agents/definition/IMediaCallAgent.ts rename to ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts diff --git a/packages/media-signaling-server/src/agents/definition/common.ts b/ee/packages/media-calls/src/agents/definition/common.ts similarity index 100% rename from packages/media-signaling-server/src/agents/definition/common.ts rename to ee/packages/media-calls/src/agents/definition/common.ts diff --git a/packages/media-signaling-server/src/agents/users/Agent.ts b/ee/packages/media-calls/src/agents/users/Agent.ts similarity index 100% rename from packages/media-signaling-server/src/agents/users/Agent.ts rename to ee/packages/media-calls/src/agents/users/Agent.ts diff --git a/packages/media-signaling-server/src/agents/users/AgentFactory.ts b/ee/packages/media-calls/src/agents/users/AgentFactory.ts similarity index 100% rename from packages/media-signaling-server/src/agents/users/AgentFactory.ts rename to ee/packages/media-calls/src/agents/users/AgentFactory.ts diff --git a/packages/media-signaling-server/src/agents/users/BasicAgent.ts b/ee/packages/media-calls/src/agents/users/BasicAgent.ts similarity index 100% rename from packages/media-signaling-server/src/agents/users/BasicAgent.ts rename to ee/packages/media-calls/src/agents/users/BasicAgent.ts diff --git a/packages/media-signaling-server/src/agents/users/NewCallAgent.ts b/ee/packages/media-calls/src/agents/users/NewCallAgent.ts similarity index 100% rename from packages/media-signaling-server/src/agents/users/NewCallAgent.ts rename to ee/packages/media-calls/src/agents/users/NewCallAgent.ts diff --git a/packages/media-signaling-server/src/agents/users/SignalProcessor.ts b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts similarity index 100% rename from packages/media-signaling-server/src/agents/users/SignalProcessor.ts rename to ee/packages/media-calls/src/agents/users/SignalProcessor.ts diff --git a/packages/media-signaling-server/src/global/ISignalGateway.ts b/ee/packages/media-calls/src/global/ISignalGateway.ts similarity index 100% rename from packages/media-signaling-server/src/global/ISignalGateway.ts rename to ee/packages/media-calls/src/global/ISignalGateway.ts diff --git a/packages/media-signaling-server/src/global/SignalGateway.ts b/ee/packages/media-calls/src/global/SignalGateway.ts similarity index 97% rename from packages/media-signaling-server/src/global/SignalGateway.ts rename to ee/packages/media-calls/src/global/SignalGateway.ts index df6ce65392307..8b00a5c1f0e6b 100644 --- a/packages/media-signaling-server/src/global/SignalGateway.ts +++ b/ee/packages/media-calls/src/global/SignalGateway.ts @@ -33,7 +33,7 @@ export class SignalGateway extends GlobalSignalProcessor implements ISignalGatew if (!this.handler) { this.debugError('The Media Signal Server tried to send a signal to a client without being configured first.', { signal }); - throw new Error('media-signaling-server-not-configured'); + throw new Error('media-calls-server-not-configured'); } try { diff --git a/packages/media-signaling-server/src/global/SignalProcessor.ts b/ee/packages/media-calls/src/global/SignalProcessor.ts similarity index 100% rename from packages/media-signaling-server/src/global/SignalProcessor.ts rename to ee/packages/media-calls/src/global/SignalProcessor.ts diff --git a/packages/media-signaling-server/src/index.ts b/ee/packages/media-calls/src/index.ts similarity index 100% rename from packages/media-signaling-server/src/index.ts rename to ee/packages/media-calls/src/index.ts diff --git a/packages/media-signaling-server/src/logger.ts b/ee/packages/media-calls/src/logger.ts similarity index 100% rename from packages/media-signaling-server/src/logger.ts rename to ee/packages/media-calls/src/logger.ts diff --git a/packages/media-signaling-server/tsconfig.json b/ee/packages/media-calls/tsconfig.json similarity index 100% rename from packages/media-signaling-server/tsconfig.json rename to ee/packages/media-calls/tsconfig.json diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 03a6de5d668d2..e3f8cbc306a77 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -36,8 +36,8 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/icons": "^0.43.0", + "@rocket.chat/media-calls": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", - "@rocket.chat/media-signaling-server": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 9ea047bac3bdd..6a2e75071bfa6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7107,8 +7107,8 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/media-calls": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" - "@rocket.chat/media-signaling-server": "workspace:^" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" @@ -7809,9 +7809,9 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/media-signaling-server@workspace:^, @rocket.chat/media-signaling-server@workspace:packages/media-signaling-server": +"@rocket.chat/media-calls@workspace:^, @rocket.chat/media-calls@workspace:ee/packages/media-calls": version: 0.0.0-use.local - resolution: "@rocket.chat/media-signaling-server@workspace:packages/media-signaling-server" + resolution: "@rocket.chat/media-calls@workspace:ee/packages/media-calls" dependencies: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/jest-presets": "workspace:~" @@ -7943,8 +7943,8 @@ __metadata: "@rocket.chat/log-format": "workspace:^" "@rocket.chat/logger": "workspace:^" "@rocket.chat/logo": "npm:^0.32.2" + "@rocket.chat/media-calls": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" - "@rocket.chat/media-signaling-server": "workspace:^" "@rocket.chat/memo": "npm:~0.31.25" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/mock-providers": "workspace:^" @@ -8772,7 +8772,6 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:~" - "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/ui-kit": "workspace:~" "@types/jest": "npm:~30.0.0" From 2c3f20e7bacbc9e321980719d4f98987b1bced5d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 11 Aug 2025 17:21:31 -0300 Subject: [PATCH 30/72] expire old calls --- apps/meteor/ee/server/configuration/voip.ts | 11 ++-- .../server/services/media-call/service.ts | 13 ++-- ee/packages/media-calls/src/agents/Manager.ts | 65 ++++++++++++++----- .../media-calls/src/global/CallMonitor.ts | 27 ++++++++ .../media-calls/src/global/SignalProcessor.ts | 5 ++ ee/packages/media-calls/src/index.ts | 2 + .../src/types/IMediaCallService.ts | 2 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 2 + .../src/models/IMediaCallsModel.ts | 11 ++-- packages/models/src/models/MediaCalls.ts | 51 ++++++++++----- 10 files changed, 136 insertions(+), 53 deletions(-) create mode 100644 ee/packages/media-calls/src/global/CallMonitor.ts diff --git a/apps/meteor/ee/server/configuration/voip.ts b/apps/meteor/ee/server/configuration/voip.ts index 1d736d8217e26..7ac475c80dac5 100644 --- a/apps/meteor/ee/server/configuration/voip.ts +++ b/apps/meteor/ee/server/configuration/voip.ts @@ -1,10 +1,11 @@ +import { MediaCall } from '@rocket.chat/core-services'; +import { cronJobs } from '@rocket.chat/cron'; import { License } from '@rocket.chat/license'; -import { Meteor } from 'meteor/meteor'; import { addSettings } from '../settings/voip'; -Meteor.startup(async () => { - License.onValidateLicense(async () => { - await addSettings(); - }); +License.onValidateLicense(async () => { + await addSettings(); + + await cronJobs.add('expire-media-calls', '* * * * *', () => MediaCall.hangupExpiredCalls()); }); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 439d6a90e8edb..f5b126dfb5928 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,9 +1,9 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; +import { gateway, MediaCallMonitor } from '@rocket.chat/media-calls'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { gateway } from '@rocket.chat/media-calls'; -import { MediaCalls, Users } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; const logger = new Logger('media-call service'); @@ -73,12 +73,9 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall return this.createInternalCall(caller, { uid: userId }); } - public async hangupEveryCall(hangupReason?: string): Promise { - // change every pending or active call state to 'hangup' with the specified reason - - await MediaCalls.hangupEveryCall({ - endedBy: { type: 'server', id: 'server' }, - reason: hangupReason || 'full-server-hangup', + public async hangupExpiredCalls(): Promise { + MediaCallMonitor.hangupExpiredCalls().catch((error) => { + logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error }); }); } diff --git a/ee/packages/media-calls/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts index 9125e4101f42e..3c2b53c4341e9 100644 --- a/ee/packages/media-calls/src/agents/Manager.ts +++ b/ee/packages/media-calls/src/agents/Manager.ts @@ -6,6 +6,8 @@ import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; import { logger } from '../logger'; import type { IMediaCallAgent, IMediaCallAgentFactory, IMediaCallBasicAgent, INewMediaCallAgent } from './definition/IMediaCallAgent'; import { UserAgentFactory } from './users/AgentFactory'; +import { MediaCallMonitor } from '../global/CallMonitor'; +import { gateway } from '../global/SignalGateway'; type FactoryFn = () => Promise; @@ -99,10 +101,37 @@ class MediaCallAgentManager { } } + public async expireCall(call: Pick): Promise { + logger.debug({ msg: 'AgentManager.expireCall', call }); + + if (!(await this.hangupByServer(call._id, 'timeout'))) { + return; + } + + // We send a signal to rocket.chat users involved on the call without instantiating a full agent + // Once a sip agent is implemented, we may review if it is worth sending them some form of notification here too. + + if (call.callee.type === 'user') { + gateway.sendSignal(call.callee.id, { + callId: call._id, + type: 'notification', + notification: 'hangup', + }); + } + + if (call.caller.type === 'user') { + gateway.sendSignal(call.caller.id, { + callId: call._id, + type: 'notification', + notification: 'hangup', + }); + } + } + public async activateCall(agent: IMediaCallAgent): Promise { logger.debug({ msg: 'AgentManager.activateCall', actor: agent.actor }); - const stateResult = await MediaCalls.activateCallById(agent.callId); + const stateResult = await MediaCalls.activateCallById(agent.callId, MediaCallMonitor.getNewExpirationTime()); if (!stateResult.modifiedCount) { return; } @@ -119,7 +148,7 @@ class MediaCallAgentManager { return; } - await MediaCalls.startRingingById(agent.callId); + await MediaCalls.startRingingById(agent.callId, MediaCallMonitor.getNewExpirationTime()); } public async acceptCall(agent: IMediaCallAgent): Promise { @@ -128,7 +157,7 @@ class MediaCallAgentManager { return; } - const stateResult = await MediaCalls.acceptCallById(agent.callId, agent.contractId); + const stateResult = await MediaCalls.acceptCallById(agent.callId, agent.contractId, MediaCallMonitor.getNewExpirationTime()); // If nothing changed, the call was no longer ringing if (!stateResult.modifiedCount) { // Notify something? @@ -152,6 +181,21 @@ class MediaCallAgentManager { otherAgent.setRemoteDescription(sdp); } + public async hangupByServer(callId: string, serverErrorCode: string, agentsToNotify?: IMediaCallBasicAgent[]): Promise { + logger.debug({ msg: 'AgentManager.hangupByServer', callId, serverErrorCode }); + + const endedBy = { type: 'server', id: 'server' } as ServerActor; + + const stateResult = await MediaCalls.hangupCallById(callId, { endedBy, reason: serverErrorCode }); + const result = Boolean(stateResult.modifiedCount); + + if (result && agentsToNotify) { + await Promise.allSettled(agentsToNotify.map((agent) => agent.notify(callId, 'hangup'))); + } + + return result; + } + private async getOppositeAgentFactory(call: IMediaCall, agent: IMediaCallAgent): Promise { logger.debug({ msg: 'AgentManager.getOppositeAgentFactory', actor: agent.actor }); switch (agent.role) { @@ -191,21 +235,6 @@ class MediaCallAgentManager { return factory.getCallAgent(call) || factory.getNewAgent(agent.oppositeRole); } - private async hangupByServer(callId: string, serverErrorCode: string, agentsToNotify?: IMediaCallBasicAgent[]): Promise { - logger.debug({ msg: 'AgentManager.hangupByServer', callId, serverErrorCode }); - - const endedBy = { type: 'server', id: 'server' } as ServerActor; - - const stateResult = await MediaCalls.hangupCallById(callId, { endedBy, reason: serverErrorCode }); - const result = Boolean(stateResult.modifiedCount); - - if (result && agentsToNotify) { - await Promise.allSettled(agentsToNotify.map((agent) => agent.notify(callId, 'hangup'))); - } - - return result; - } - private hangupAndThrow(callId: string, error: string, agents?: IMediaCallBasicAgent[]): never { logger.debug({ msg: 'AgentManager.hangupAndThrow', callId, error }); this.shieldPromise(this.hangupByServer(callId, error, agents)); diff --git a/ee/packages/media-calls/src/global/CallMonitor.ts b/ee/packages/media-calls/src/global/CallMonitor.ts new file mode 100644 index 0000000000000..d49bc6d65b795 --- /dev/null +++ b/ee/packages/media-calls/src/global/CallMonitor.ts @@ -0,0 +1,27 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; + +import { agentManager } from '../agents/Manager'; +import { logger } from '../logger'; + +export class MediaCallMonitor { + public static async hangupExpiredCalls(): Promise { + logger.debug('MediaCallMonitor.hangupExpiredCalls'); + + const result = MediaCalls.findAllExpiredCalls>({ + projection: { _id: 1, caller: 1, callee: 1 }, + }); + + for await (const call of result) { + await agentManager.expireCall(call); + } + } + + public static getNewExpirationTime(): Date { + return new Date(Date.now() + 120000); + } + + public static async renewCallId(callId: string): Promise { + await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime()); + } +} diff --git a/ee/packages/media-calls/src/global/SignalProcessor.ts b/ee/packages/media-calls/src/global/SignalProcessor.ts index c7e3dbf25d6c1..d14623f69dc2a 100644 --- a/ee/packages/media-calls/src/global/SignalProcessor.ts +++ b/ee/packages/media-calls/src/global/SignalProcessor.ts @@ -12,6 +12,7 @@ import { MediaCalls, Users } from '@rocket.chat/models'; import { agentManager } from '../agents/Manager'; import { UserAgentFactory } from '../agents/users/AgentFactory'; import { logger } from '../logger'; +import { MediaCallMonitor } from './CallMonitor'; import type { CreateCallParams } from './ISignalGateway'; export abstract class GlobalSignalProcessor { @@ -54,6 +55,8 @@ export abstract class GlobalSignalProcessor { caller, callee, + expiresAt: MediaCallMonitor.getNewExpirationTime(), + ...(requestedCallId && { callerRequestedId: requestedCallId }), }; @@ -129,6 +132,8 @@ export abstract class GlobalSignalProcessor { return; } + await MediaCallMonitor.renewCallId(call._id); + const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); const agent = factory?.getCallAgent(call); if (!agent) { diff --git a/ee/packages/media-calls/src/index.ts b/ee/packages/media-calls/src/index.ts index b2634059bae7e..77596c30c3219 100644 --- a/ee/packages/media-calls/src/index.ts +++ b/ee/packages/media-calls/src/index.ts @@ -1,4 +1,6 @@ import type { ISignalGateway } from './global/ISignalGateway'; import { gateway as signalGateway } from './global/SignalGateway'; +export * from './global/CallMonitor'; + export const gateway: ISignalGateway = signalGateway; diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index 3e89f11343384..dfa33cd58edd8 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -7,5 +7,5 @@ export interface IMediaCallService { createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; - hangupEveryCall(hangupReason?: string): Promise; + hangupExpiredCalls(): Promise; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 2801543da54a8..f3d5c530ae6cf 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -45,5 +45,7 @@ export interface IMediaCall extends IRocketChatRecord { endedAt?: Date; hangupReason?: string; + expiresAt: Date; + callerRequestedId?: string; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 61a7938e1b1d2..5671068938ff1 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,5 +1,5 @@ import type { IMediaCall, MediaCallActorType } from '@rocket.chat/core-typings'; -import type { Document, FindOptions, UpdateResult } from 'mongodb'; +import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -9,9 +9,10 @@ export interface IMediaCallsModel extends IBaseModel { caller: { type: MediaCallActorType; id: string }, options?: FindOptions, ): Promise; - startRingingById(callId: string): Promise; - acceptCallById(callId: string, calleeContractId: string): Promise; - activateCallById(callId: string): Promise; + startRingingById(callId: string, expiresAt: Date): Promise; + acceptCallById(callId: string, calleeContractId: string, expiresAt: Date): Promise; + activateCallById(callId: string, expiresAt: Date): Promise; + setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; - hangupEveryCall(params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise; + findAllExpiredCalls(options: FindOptions | undefined): FindCursor; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index c7c68e515e944..ed49d9fdc5512 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -1,6 +1,16 @@ import type { IMediaCall, RocketChatRecordDeleted, MediaCallActorType } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; -import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult, FindOptions, Document } from 'mongodb'; +import type { + IndexDescription, + Collection, + Db, + UpdateFilter, + UpdateOptions, + UpdateResult, + FindOptions, + Document, + FindCursor, +} from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -12,7 +22,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod protected modelIndexes(): IndexDescription[] { return [ { key: { createdAt: 1 }, unique: false }, - { key: { state: 1 }, unique: false }, + { key: { state: 1, expiresAt: 1 }, unique: false }, { key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 }, unique: true, sparse: true }, ]; } @@ -43,28 +53,28 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne({ _id }, update, options); } - public async startRingingById(callId: string): Promise { + public async startRingingById(callId: string, expiresAt: Date): Promise { return this.updateOne( { _id: callId, state: 'none', }, - { $set: { state: 'ringing' } }, + { $set: { state: 'ringing', expiresAt } }, ); } - public async acceptCallById(callId: string, calleeContractId: string): Promise { + public async acceptCallById(callId: string, calleeContractId: string, expiresAt: Date): Promise { return this.updateOne( { '_id': callId, 'state': { $in: ['none', 'ringing'] }, 'callee.contractId': { $exists: false }, }, - { $set: { 'state': 'accepted', 'callee.contractId': calleeContractId } }, + { $set: { 'state': 'accepted', 'callee.contractId': calleeContractId, expiresAt } }, ); } - public async activateCallById(callId: string): Promise { + public async activateCallById(callId: string, expiresAt: Date): Promise { return this.updateOne( { _id: callId, @@ -73,6 +83,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod { $set: { state: 'active', + expiresAt, }, }, ); @@ -97,21 +108,29 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async hangupEveryCall(params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { - const { endedBy, reason } = params || {}; - - return this.col.updateMany( + public async setExpiresAtById(callId: string, expiresAt: Date): Promise { + return this.updateOne( { + _id: callId, state: { $ne: 'hangup' }, }, { - $set: { - state: 'hangup', - endedAt: new Date(), - ...(endedBy && { endedBy }), - ...(reason && { hangupReason: reason }), + $set: { expiresAt }, + }, + ); + } + + public findAllExpiredCalls(options?: FindOptions): FindCursor { + return this.find( + { + state: { + $ne: 'hangup', + }, + expiresAt: { + $lt: new Date(), }, }, + options, ); } } From 739304aead339429a3df3c15d3c68de5b8396a98 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 11 Aug 2025 17:29:47 -0300 Subject: [PATCH 31/72] update comment --- ee/packages/media-calls/src/agents/users/SignalProcessor.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts index b009963d91066..5e0f36f98c3ba 100644 --- a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts +++ b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts @@ -113,8 +113,7 @@ export class UserAgentSignalProcessor { return; } - // #ToDo: Save the timestamp of the last active state so we can autodetect lost calls - + // #ToDo: Monitor other states as well if (signal.clientState === 'active') { if (channel.state === 'active' || channel.activeAt) { return; From eab5358ed6da5f3102742e8df39725de8c99a83d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 12 Aug 2025 11:04:22 -0300 Subject: [PATCH 32/72] unneeded dependency --- packages/core-services/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/core-services/package.json b/packages/core-services/package.json index e3f8cbc306a77..89a47b549fb76 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -36,7 +36,6 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/icons": "^0.43.0", - "@rocket.chat/media-calls": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 6a2e75071bfa6..54b7a30fb1c32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7107,7 +7107,6 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" - "@rocket.chat/media-calls": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/message-parser": "workspace:^" "@rocket.chat/models": "workspace:^" From f0731b65f9ac693b4584862721abe2dc7d034c7c Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 12 Aug 2025 11:35:21 -0300 Subject: [PATCH 33/72] dev dependencies --- ee/apps/ddp-streamer/package.json | 1 + packages/core-services/package.json | 2 +- packages/ddp-client/package.json | 1 + yarn.lock | 2 ++ 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index c401d55de9304..8b2e20b5ffdbd 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -47,6 +47,7 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", + "@rocket.chat/media-signaling": "workspace:^", "@types/ejson": "^2.2.2", "@types/node": "~22.16.1", "@types/polka": "^0.5.7", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 89a47b549fb76..7681de6dd6876 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -9,6 +9,7 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "babel-jest": "~30.0.5", @@ -36,7 +37,6 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/icons": "^0.43.0", - "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/ddp-client/package.json b/packages/ddp-client/package.json index 4182fce1740eb..6a7d75ec5eca5 100644 --- a/packages/ddp-client/package.json +++ b/packages/ddp-client/package.json @@ -3,6 +3,7 @@ "version": "0.3.33", "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "@types/ws": "^8.5.13", diff --git a/yarn.lock b/yarn.lock index 54b7a30fb1c32..f14c03077bd2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7189,6 +7189,7 @@ __metadata: "@rocket.chat/api-client": "workspace:^" "@rocket.chat/core-typings": "workspace:~" "@rocket.chat/jest-presets": "workspace:~" + "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" @@ -7215,6 +7216,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/instance-status": "workspace:^" "@rocket.chat/logger": "workspace:^" + "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" From d5d367386bd7e962e9f8fd8666ee9f2fc0709574 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 12 Aug 2025 16:37:44 -0300 Subject: [PATCH 34/72] removing comments --- apps/meteor/server/services/media-call/service.ts | 1 - ee/packages/media-calls/src/agents/Manager.ts | 7 ++----- ee/packages/media-calls/src/agents/users/Agent.ts | 1 - ee/packages/media-calls/src/agents/users/BasicAgent.ts | 2 -- .../media-calls/src/agents/users/SignalProcessor.ts | 8 ++------ ee/packages/media-calls/src/global/SignalGateway.ts | 6 ++++-- ee/packages/media-calls/src/global/SignalProcessor.ts | 2 +- packages/core-typings/src/mediaCalls/IMediaCall.ts | 3 --- .../media-signaling/src/definition/call/CallEvents.ts | 5 ----- .../src/definition/signals/client/answer.ts | 2 +- .../src/definition/signals/client/error.ts | 2 +- .../src/definition/signals/client/hangup.ts | 2 +- .../src/definition/signals/client/local-sdp.ts | 2 +- .../src/definition/signals/client/local-state.ts | 2 +- .../src/definition/signals/client/request-call.ts | 2 +- .../media-signaling/src/definition/signals/server/new.ts | 4 ++-- .../src/definition/signals/server/notification.ts | 8 +++++--- .../src/definition/signals/server/remote-sdp.ts | 2 +- .../src/definition/signals/server/request-offer.ts | 2 +- packages/media-signaling/src/lib/Call.ts | 5 ++--- .../media-signaling/src/lib/services/webrtc/Processor.ts | 6 +----- 21 files changed, 27 insertions(+), 47 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index f5b126dfb5928..536155af934e9 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -69,7 +69,6 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } public async callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise { - // For now every call is between two users return this.createInternalCall(caller, { uid: userId }); } diff --git a/ee/packages/media-calls/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts index 3c2b53c4341e9..28a0405bac8d7 100644 --- a/ee/packages/media-calls/src/agents/Manager.ts +++ b/ee/packages/media-calls/src/agents/Manager.ts @@ -95,8 +95,8 @@ class MediaCallAgentManager { agent.notify(agent.callId, 'hangup'); const otherAgent = await this.getAnyOppositeAgent(agent); - // #ToDo: log something if the otherAgent is not found if (otherAgent) { + logger.warn({ msg: 'oppositeAgent not found on hangup', reason }); otherAgent.notify(agent.callId, 'hangup'); } } @@ -160,13 +160,11 @@ class MediaCallAgentManager { const stateResult = await MediaCalls.acceptCallById(agent.callId, agent.contractId, MediaCallMonitor.getNewExpirationTime()); // If nothing changed, the call was no longer ringing if (!stateResult.modifiedCount) { - // Notify something? return; } agent.sign(); - // #ToDo: notify client if this throws any error return this.processAcceptedCall(agent); } @@ -208,7 +206,7 @@ class MediaCallAgentManager { } } - // This will only return an agent if a contract for the other role has already been signed + /** This will only return an agent if a contract for the other role has already been signed */ private async getOppositeAgent(agent: IMediaCallAgent): Promise { logger.debug({ msg: 'AgentManager.getOppositeAgent', actor: agent.actor }); const call = await MediaCalls.findOneById(agent.callId); @@ -275,7 +273,6 @@ class MediaCallAgentManager { return; } - // #ToDo: handle cases where an actor's service role doesn't match the call role if (offer.type !== 'offer') { logger.warn({ msg: 'caller sdp is not an offer', local: offer }); throw new Error('unexpected-state'); diff --git a/ee/packages/media-calls/src/agents/users/Agent.ts b/ee/packages/media-calls/src/agents/users/Agent.ts index a1335afcca2bb..240cec1c39242 100644 --- a/ee/packages/media-calls/src/agents/users/Agent.ts +++ b/ee/packages/media-calls/src/agents/users/Agent.ts @@ -59,7 +59,6 @@ export class UserMediaCallAgent extends UserBasicAgent implemen public async requestOffer(params: { iceRestart?: boolean }): Promise { logger.debug({ msg: 'UserMediaCallAgent.requestOffer', params, actor: this.actor, contractState: this.contractState }); - // #ToDo: this function may be called multiple times for the same call until an offer is provided; maybe consider the channel state before sending the offer await this.sendSignal({ callId: this.callId, toContractId: this.contractId, diff --git a/ee/packages/media-calls/src/agents/users/BasicAgent.ts b/ee/packages/media-calls/src/agents/users/BasicAgent.ts index 4454923c380eb..964d1d98fd19c 100644 --- a/ee/packages/media-calls/src/agents/users/BasicAgent.ts +++ b/ee/packages/media-calls/src/agents/users/BasicAgent.ts @@ -29,8 +29,6 @@ export class UserBasicAgent username, displayName, sipExtension, - // avatarUrl, - // host }; } diff --git a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts index 5e0f36f98c3ba..6c4aff2bffec5 100644 --- a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts +++ b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts @@ -33,9 +33,6 @@ export class UserAgentSignalProcessor { case 'local-sdp': await this.saveLocalDescription(params, signal.sdp); break; - case 'error': - // #ToDo - break; case 'answer': await this.processAnswer(params, signal.answer); break; @@ -65,8 +62,8 @@ export class UserAgentSignalProcessor { case 'accept': return agentManager.acceptCall(this.agent); case 'unavailable': - // return processUnavailable(call, channel); - break; + // We don't do anything on unavailable responses from clients, as a different client may still answer + return; case 'reject': return this.processReject(call); } @@ -113,7 +110,6 @@ export class UserAgentSignalProcessor { return; } - // #ToDo: Monitor other states as well if (signal.clientState === 'active') { if (channel.state === 'active' || channel.activeAt) { return; diff --git a/ee/packages/media-calls/src/global/SignalGateway.ts b/ee/packages/media-calls/src/global/SignalGateway.ts index 8b00a5c1f0e6b..6ae5698cfc800 100644 --- a/ee/packages/media-calls/src/global/SignalGateway.ts +++ b/ee/packages/media-calls/src/global/SignalGateway.ts @@ -6,8 +6,10 @@ import { logger } from '../logger'; import type { ISignalGateway, ServerSignalTransport } from './ISignalGateway'; import { GlobalSignalProcessor } from './SignalProcessor'; -// Class used as gateway to send and receive signals to/from clients -// The actual function used to send the signals needs to be set by the server +/** + * Class used as gateway to send and receive signals to/from clients + * The actual function used to send the signals needs to be set by the server + */ export class SignalGateway extends GlobalSignalProcessor implements ISignalGateway { private handler: ServerSignalTransport | null = null; diff --git a/ee/packages/media-calls/src/global/SignalProcessor.ts b/ee/packages/media-calls/src/global/SignalProcessor.ts index d14623f69dc2a..b7c430ff672d3 100644 --- a/ee/packages/media-calls/src/global/SignalProcessor.ts +++ b/ee/packages/media-calls/src/global/SignalProcessor.ts @@ -149,7 +149,7 @@ export abstract class GlobalSignalProcessor { } private async processRegisterSignal(_uid: IUser['_id'], _signal: ClientMediaSignalRegister): Promise { - // #ToDo: client registration + // #TODO: client registration // 1. Re-send signals for any pending calls involving this actor; // 2. Hangup active calls involving the oldContractId if it's different from the new one. } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index f3d5c530ae6cf..942e8a8fb9464 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -20,9 +20,6 @@ export type MediaCallContactInformation = { displayName?: string; username?: string; sipExtension?: string; - - // avatarUrl?: string; - // host?: string; }; export type MediaCallContact = T & MediaCallContactInformation; diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 38d78a654b854..fb39075e3ff8d 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -2,13 +2,8 @@ import type { ClientState } from '../client'; import type { CallState } from './IClientMediaCall'; export type CallEvents = { - // triggered when the state of a call changes (only for states known by the server); receives the old state as a param stateChange: CallState; - - // the client state is more detailed than the general call state, it includes sub-states that might not be relevant to the user or the UI clientStateChange: ClientState; - - // contactUpdate may be triggered even if nothing actually changed contactUpdate: void; initialized: void; diff --git a/packages/media-signaling/src/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts index f3d27c01868bb..06e6427892296 100644 --- a/packages/media-signaling/src/definition/signals/client/answer.ts +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -2,7 +2,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallAnswer } from '../../call'; -// Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached +/** Client is saying that the user accepted or rejected a call, or simply reporting that the user can or can't be reached */ export type ClientMediaSignalAnswer = { callId: string; type: 'answer'; diff --git a/packages/media-signaling/src/definition/signals/client/error.ts b/packages/media-signaling/src/definition/signals/client/error.ts index 79e18a15e7512..404a148e9b023 100644 --- a/packages/media-signaling/src/definition/signals/client/error.ts +++ b/packages/media-signaling/src/definition/signals/client/error.ts @@ -1,6 +1,6 @@ import type { JSONSchemaType } from 'ajv'; -// Client is reporting an error +/** Client is reporting an error */ export type ClientMediaSignalError = { callId: string; contractId: string; diff --git a/packages/media-signaling/src/definition/signals/client/hangup.ts b/packages/media-signaling/src/definition/signals/client/hangup.ts index 859b07e9cff9b..ef8f23c51c0b2 100644 --- a/packages/media-signaling/src/definition/signals/client/hangup.ts +++ b/packages/media-signaling/src/definition/signals/client/hangup.ts @@ -2,7 +2,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallHangupReason } from '../../call'; -// Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error +/** Client is saying they hanged up from a call. The reason specifies if its a clean hangup or an error */ export type ClientMediaSignalHangup = { callId: string; contractId: string; diff --git a/packages/media-signaling/src/definition/signals/client/local-sdp.ts b/packages/media-signaling/src/definition/signals/client/local-sdp.ts index cad916f74492f..b8c684db6f70e 100644 --- a/packages/media-signaling/src/definition/signals/client/local-sdp.ts +++ b/packages/media-signaling/src/definition/signals/client/local-sdp.ts @@ -1,6 +1,6 @@ import type { JSONSchemaType } from 'ajv'; -// Client is sending the local session description to the server +/** Client is sending the local session description to the server */ export type ClientMediaSignalLocalSDP = { callId: string; contractId: string; diff --git a/packages/media-signaling/src/definition/signals/client/local-state.ts b/packages/media-signaling/src/definition/signals/client/local-state.ts index 9c27af4b7a5d6..d2f8090e2fc8e 100644 --- a/packages/media-signaling/src/definition/signals/client/local-state.ts +++ b/packages/media-signaling/src/definition/signals/client/local-state.ts @@ -3,7 +3,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallState } from '../../call'; import type { ClientContractState, ClientState } from '../../client'; -// Client is sending their local call state +/** Client is sending their local call state */ export type ClientMediaSignalLocalState = { callId: string; contractId: string; diff --git a/packages/media-signaling/src/definition/signals/client/request-call.ts b/packages/media-signaling/src/definition/signals/client/request-call.ts index c11546953d92f..710f20f30225c 100644 --- a/packages/media-signaling/src/definition/signals/client/request-call.ts +++ b/packages/media-signaling/src/definition/signals/client/request-call.ts @@ -3,7 +3,7 @@ import type { JSONSchemaType } from 'ajv'; import type { CallService } from '../../call'; export type ClientMediaSignalRequestCall = { - // the callId on this signal is temporary and is never propagated to other agents + /** the callId on this signal is temporary and is never propagated to other agents */ callId: string; contractId: string; type: 'request-call'; diff --git a/packages/media-signaling/src/definition/signals/server/new.ts b/packages/media-signaling/src/definition/signals/server/new.ts index 93bb05184227b..e935925594c07 100644 --- a/packages/media-signaling/src/definition/signals/server/new.ts +++ b/packages/media-signaling/src/definition/signals/server/new.ts @@ -1,6 +1,6 @@ import type { CallContact, CallRole, CallService } from '../../call'; -// Sent by the server to notify an agent that there's a new call for their actor +/** Sent by the server to notify an agent that there's a new call for their actor */ export type ServerMediaSignalNewCall = { callId: string; type: 'new'; @@ -10,6 +10,6 @@ export type ServerMediaSignalNewCall = { role: CallRole; contact: CallContact; - // This will only be sent to the caller, with the id it used to request this call + /** This will only be sent to the caller, with the id it used to request this call */ requestedCallId?: string; }; diff --git a/packages/media-signaling/src/definition/signals/server/notification.ts b/packages/media-signaling/src/definition/signals/server/notification.ts index b492bcaf218a6..5633e5bf85123 100644 --- a/packages/media-signaling/src/definition/signals/server/notification.ts +++ b/packages/media-signaling/src/definition/signals/server/notification.ts @@ -1,12 +1,14 @@ import type { CallNotification } from '../../call'; -// Server is sending a notification about the call state +/** Server is sending a notification about the call state */ export type ServerMediaSignalNotification = { callId: string; type: 'notification'; notification: CallNotification; - // Used to inform which contract was signed by the server, without targeting the signal to that contract alone - // Optional in general, but at least one notification must be sent with it before the callee can join the call + /** + * Used to inform which contract was signed by the server, without targeting the signal to that contract alone + * Optional in general, but at least one notification must be sent with it before the callee can join the call + */ signedContractId?: string; }; diff --git a/packages/media-signaling/src/definition/signals/server/remote-sdp.ts b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts index 8f13ea14e47ce..e62b069aaaea9 100644 --- a/packages/media-signaling/src/definition/signals/server/remote-sdp.ts +++ b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts @@ -1,4 +1,4 @@ -// Server is sending the other actor's sdp +/** Server is sending the other actor's sdp */ export type ServerMediaSignalRemoteSDP = { callId: string; toContractId: string; diff --git a/packages/media-signaling/src/definition/signals/server/request-offer.ts b/packages/media-signaling/src/definition/signals/server/request-offer.ts index b649e52ad9fd8..578d92208d609 100644 --- a/packages/media-signaling/src/definition/signals/server/request-offer.ts +++ b/packages/media-signaling/src/definition/signals/server/request-offer.ts @@ -1,4 +1,4 @@ -// Server is requesting a webrtc offer +/** Server is requesting a webrtc offer */ export type ServerMediaSignalRequestOffer = { callId: string; toContractId: string; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 04208acb492ed..96b8f434d409a 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -707,9 +707,9 @@ export class ClientMediaCall implements IClientMediaCall { } if (!this.acceptedLocally) { - // #ToDo: test this situation; remove exception, read this response on the server this.config.transporter.sendError(this.callId, 'not-accepted'); - this.throwError('Trying to activate a call that was not yet accepted locally.'); + this.config.logger?.error('Trying to activate a call that was not yet accepted locally.'); + return; } if (this.contractState === 'proposed') { @@ -825,7 +825,6 @@ export class ClientMediaCall implements IClientMediaCall { } break; case 'disconnected': - // #ToDo: Attempt to reconnect when possible if (this.state === 'active') { this.hangup('service-error'); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index ec67ccc175a58..b58c7690bae77 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -58,8 +58,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.restartIce(); } - // #ToDo: direction changes - const offer = await this.peer.createOffer(); await this.peer.setLocalDescription(offer); @@ -74,7 +72,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { await this.initializeLocalMediaStream(); - // #ToDo: direction changes if (this.peer.remoteDescription?.sdp !== sdp.sdp) { this.peer.setRemoteDescription(sdp); } @@ -89,7 +86,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription'); await this.initializeLocalMediaStream(); - // #ToDo: validate this.peer.signalingState ? this.peer.setRemoteDescription(sdp); } @@ -126,6 +122,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { await this.waitForIceGathering(); // always wait a little extra to ensure all relevant events have been fired + // 30ms is low enough that it won't be noticeable by users, but is also enough time to process a full `host` candidate await new Promise((resolve) => setTimeout(resolve, 30)); const sdp = this.peer.localDescription; @@ -216,7 +213,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onNegotiationNeeded'); - // #ToDo negotiation needed } private onTrack(peer: RTCPeerConnection, event: RTCTrackEvent): void { From b3bb38ada63993c751581053e61a6a5de771562d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 12 Aug 2025 16:43:30 -0300 Subject: [PATCH 35/72] missed a couple --- ee/packages/media-calls/src/agents/Manager.ts | 1 - .../media-signaling/src/definition/signals/client/register.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ee/packages/media-calls/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts index 28a0405bac8d7..1486ac4a6ef75 100644 --- a/ee/packages/media-calls/src/agents/Manager.ts +++ b/ee/packages/media-calls/src/agents/Manager.ts @@ -109,7 +109,6 @@ class MediaCallAgentManager { } // We send a signal to rocket.chat users involved on the call without instantiating a full agent - // Once a sip agent is implemented, we may review if it is worth sending them some form of notification here too. if (call.callee.type === 'user') { gateway.sendSignal(call.callee.id, { diff --git a/packages/media-signaling/src/definition/signals/client/register.ts b/packages/media-signaling/src/definition/signals/client/register.ts index a1544d7092136..528e6b3dfe1fb 100644 --- a/packages/media-signaling/src/definition/signals/client/register.ts +++ b/packages/media-signaling/src/definition/signals/client/register.ts @@ -1,6 +1,6 @@ import type { JSONSchemaType } from 'ajv'; -// Client is reporting a brand new session +/** Client is reporting a brand new session */ export type ClientMediaSignalRegister = { type: 'register'; contractId: string; From 2f606ec3a2e23e6b959971000ab448dee69d1e1a Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 13 Aug 2025 11:42:57 -0300 Subject: [PATCH 36/72] rename and move token function --- packages/media-signaling/src/lib/Session.ts | 26 ++++++++++++------- .../src/lib/utils/createRandomToken.ts | 8 ------ 2 files changed, 17 insertions(+), 17 deletions(-) delete mode 100644 packages/media-signaling/src/lib/utils/createRandomToken.ts diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index c221471882f0e..1404a5ffe4bc8 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -10,7 +10,6 @@ import type { MediaStreamFactory, ServerMediaSignal, } from '../definition'; -import { createRandomToken } from './utils/createRandomToken'; import type { IClientMediaCall, CallState, CallActorType, CallContact } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; @@ -49,6 +48,8 @@ export class MediaSignalingSession extends Emitter { private recurringStateReportHandler: ReturnType | null; + private callCount = 0; + public get sessionId(): string { return this._sessionId; } @@ -60,7 +61,7 @@ export class MediaSignalingSession extends Emitter { constructor(private config: MediaSignalingSessionConfig) { super(); this._userId = config.userId; - this._sessionId = createRandomToken(8); + this._sessionId = this.createWeakToken(); this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); @@ -152,13 +153,8 @@ export class MediaSignalingSession extends Emitter { } private createTemporaryCallId(): string { - const callId = createRandomToken(20); - - if (this.knownCalls.has(callId)) { - return this.createTemporaryCallId(); - } - - return callId; + this.callCount++; + return `${this._sessionId}-${this.callCount}-${Date.now()}`; } private isSignalTargetingAnotherSession(signal: ServerMediaSignal): boolean { @@ -259,6 +255,18 @@ export class MediaSignalingSession extends Emitter { return call; } + private createWeakToken(): string { + const base = 32; + const size = 8; + + let token = ''; + for (let i = 0; i < size; i++) { + const r: number = Math.floor(Math.random() * base); + token += r.toString(base); + } + return `${Date.now()}-${token}`; + } + private onCallContactUpdate(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onCallContactUpdate'); if (call.hidden) { diff --git a/packages/media-signaling/src/lib/utils/createRandomToken.ts b/packages/media-signaling/src/lib/utils/createRandomToken.ts deleted file mode 100644 index 1961a06a936d3..0000000000000 --- a/packages/media-signaling/src/lib/utils/createRandomToken.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function createRandomToken(size: number, base = 32): string { - let token = ''; - for (let i = 0; i < size; i++) { - const r: number = Math.floor(Math.random() * base); - token += r.toString(base); - } - return token; -} From 79b882bdb90ad806c8342daeeac98e9d9571343b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 13 Aug 2025 11:43:07 -0300 Subject: [PATCH 37/72] clarify cronjob interval with a comment --- apps/meteor/ee/server/configuration/voip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/ee/server/configuration/voip.ts b/apps/meteor/ee/server/configuration/voip.ts index 7ac475c80dac5..c0777861930b4 100644 --- a/apps/meteor/ee/server/configuration/voip.ts +++ b/apps/meteor/ee/server/configuration/voip.ts @@ -7,5 +7,6 @@ import { addSettings } from '../settings/voip'; License.onValidateLicense(async () => { await addSettings(); + // Run every minute await cronJobs.add('expire-media-calls', '* * * * *', () => MediaCall.hangupExpiredCalls()); }); From 5ea0a46779d9e79d7eb0baa04b73e49cd1ed6f88 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 13 Aug 2025 12:05:14 -0300 Subject: [PATCH 38/72] remove token function --- packages/media-signaling/src/lib/Session.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 1404a5ffe4bc8..7020f0d3c5134 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -34,6 +34,7 @@ export type MediaSignalingSessionConfig = { }; const STATE_REPORT_INTERVAL = 60000; +let sessionCount = 0; export class MediaSignalingSession extends Emitter { private _userId: string; @@ -60,8 +61,9 @@ export class MediaSignalingSession extends Emitter { constructor(private config: MediaSignalingSessionConfig) { super(); + sessionCount++; this._userId = config.userId; - this._sessionId = this.createWeakToken(); + this._sessionId = `${sessionCount}-${Date.now().toString()}`; this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); @@ -154,7 +156,7 @@ export class MediaSignalingSession extends Emitter { private createTemporaryCallId(): string { this.callCount++; - return `${this._sessionId}-${this.callCount}-${Date.now()}`; + return `${this._sessionId}-${this.callCount}-${Date.now().toString()}`; } private isSignalTargetingAnotherSession(signal: ServerMediaSignal): boolean { @@ -255,18 +257,6 @@ export class MediaSignalingSession extends Emitter { return call; } - private createWeakToken(): string { - const base = 32; - const size = 8; - - let token = ''; - for (let i = 0; i < size; i++) { - const r: number = Math.floor(Math.random() * base); - token += r.toString(base); - } - return `${Date.now()}-${token}`; - } - private onCallContactUpdate(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onCallContactUpdate'); if (call.hidden) { From 2a1e9dde4958551d634cf069166de5bcc864de10 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 13 Aug 2025 12:05:23 -0300 Subject: [PATCH 39/72] remove unnecessary warn --- ee/packages/media-calls/src/agents/Manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ee/packages/media-calls/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts index 1486ac4a6ef75..85e5a44d79614 100644 --- a/ee/packages/media-calls/src/agents/Manager.ts +++ b/ee/packages/media-calls/src/agents/Manager.ts @@ -96,7 +96,6 @@ class MediaCallAgentManager { agent.notify(agent.callId, 'hangup'); const otherAgent = await this.getAnyOppositeAgent(agent); if (otherAgent) { - logger.warn({ msg: 'oppositeAgent not found on hangup', reason }); otherAgent.notify(agent.callId, 'hangup'); } } From fb08a4dff6eddb647cc8060f9ddd6f9331ae7bfe Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 13 Aug 2025 15:30:30 -0300 Subject: [PATCH 40/72] use setTimeout instead of cronjob --- apps/meteor/ee/server/configuration/voip.ts | 4 +--- .../server/services/media-call/service.ts | 8 +++++-- .../media-calls/src/global/CallMonitor.ts | 23 +++++++++++++++---- .../src/models/IMediaCallsModel.ts | 1 + packages/models/src/models/MediaCalls.ts | 5 ++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/meteor/ee/server/configuration/voip.ts b/apps/meteor/ee/server/configuration/voip.ts index c0777861930b4..76dd0d175bc62 100644 --- a/apps/meteor/ee/server/configuration/voip.ts +++ b/apps/meteor/ee/server/configuration/voip.ts @@ -1,5 +1,4 @@ import { MediaCall } from '@rocket.chat/core-services'; -import { cronJobs } from '@rocket.chat/cron'; import { License } from '@rocket.chat/license'; import { addSettings } from '../settings/voip'; @@ -7,6 +6,5 @@ import { addSettings } from '../settings/voip'; License.onValidateLicense(async () => { await addSettings(); - // Run every minute - await cronJobs.add('expire-media-calls', '* * * * *', () => MediaCall.hangupExpiredCalls()); + await MediaCall.hangupExpiredCalls(); }); diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 536155af934e9..d56e7f15b4f11 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -3,7 +3,7 @@ import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { gateway, MediaCallMonitor } from '@rocket.chat/media-calls'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { Users } from '@rocket.chat/models'; +import { Users, MediaCalls } from '@rocket.chat/models'; const logger = new Logger('media-call service'); @@ -73,9 +73,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall } public async hangupExpiredCalls(): Promise { - MediaCallMonitor.hangupExpiredCalls().catch((error) => { + await MediaCallMonitor.hangupExpiredCalls().catch((error) => { logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error }); }); + + if (await MediaCalls.hasUnfinishedCalls()) { + MediaCallMonitor.scheduleExpirationCheck(); + } } private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise { diff --git a/ee/packages/media-calls/src/global/CallMonitor.ts b/ee/packages/media-calls/src/global/CallMonitor.ts index d49bc6d65b795..2ba450cbe3f7a 100644 --- a/ee/packages/media-calls/src/global/CallMonitor.ts +++ b/ee/packages/media-calls/src/global/CallMonitor.ts @@ -4,8 +4,11 @@ import { MediaCalls } from '@rocket.chat/models'; import { agentManager } from '../agents/Manager'; import { logger } from '../logger'; -export class MediaCallMonitor { - public static async hangupExpiredCalls(): Promise { +const EXPIRATION_TIME = 120000; +const EXPIRATION_CHECK_DELAY = 1000; + +class Monitor { + public async hangupExpiredCalls(): Promise { logger.debug('MediaCallMonitor.hangupExpiredCalls'); const result = MediaCalls.findAllExpiredCalls>({ @@ -17,11 +20,21 @@ export class MediaCallMonitor { } } - public static getNewExpirationTime(): Date { - return new Date(Date.now() + 120000); + public getNewExpirationTime(): Date { + return new Date(Date.now() + EXPIRATION_TIME); } - public static async renewCallId(callId: string): Promise { + public async renewCallId(callId: string): Promise { await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime()); + this.scheduleExpirationCheck(); + } + + public scheduleExpirationCheck(): void { + setTimeout( + () => this.hangupExpiredCalls().catch((error) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error })), + EXPIRATION_TIME + EXPIRATION_CHECK_DELAY, + ); } } + +export const MediaCallMonitor = new Monitor(); diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 5671068938ff1..93ec221bc9202 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -15,4 +15,5 @@ export interface IMediaCallsModel extends IBaseModel { setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; + hasUnfinishedCalls(): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index ed49d9fdc5512..cdbb25e256b6c 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -133,4 +133,9 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod options, ); } + + public async hasUnfinishedCalls(): Promise { + const count = await this.countDocuments({ state: { $ne: 'hangup' } }, { limit: 1 }); + return count > 0; + } } From 65f6f50254adc9c5330a828bd13d4e829ea5201b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 27 Aug 2025 14:48:01 -0300 Subject: [PATCH 41/72] reorganization to better accomodate sip integration --- .../server/services/media-call/service.ts | 85 +++-- ee/packages/media-calls/package.json | 1 + ee/packages/media-calls/src/agents/Manager.ts | 297 ------------------ .../src/agents/definition/IMediaCallAgent.ts | 85 ----- .../src/agents/definition/common.ts | 1 - .../media-calls/src/agents/users/Agent.ts | 88 ------ .../src/agents/users/AgentFactory.ts | 81 ----- .../src/agents/users/BasicAgent.ts | 48 --- .../src/agents/users/NewCallAgent.ts | 34 -- .../src/agents/users/SignalProcessor.ts | 124 -------- ee/packages/media-calls/src/base/BaseAgent.ts | 152 +++++++++ .../media-calls/src/base/BaseCallProvider.ts | 9 + .../src/definition/IMediaCallAgent.ts | 24 ++ .../src/definition/IMediaCallCastDirector.ts | 34 ++ .../src/definition/IMediaCallServer.ts | 35 +++ .../media-calls/src/definition/common.ts | 16 + .../media-calls/src/global/CallMonitor.ts | 40 --- .../media-calls/src/global/ISignalGateway.ts | 17 - .../media-calls/src/global/SignalGateway.ts | 54 ---- ee/packages/media-calls/src/index.ts | 7 +- .../src/internal/InternalCallProvider.ts | 38 +++ .../{global => internal}/SignalProcessor.ts | 205 ++++++------ .../internal/agents/BaseSignalProcessor.ts | 176 +++++++++++ .../src/internal/agents/BaseUserAgent.ts | 68 ++++ .../src/internal/agents/CalleeAgent.ts | 39 +++ .../internal/agents/CalleeSignalProcessor.ts | 51 +++ .../src/internal/agents/CallerAgent.ts | 56 ++++ .../internal/agents/CallerSignalProcessor.ts | 34 ++ .../media-calls/src/server/CallDirector.ts | 285 +++++++++++++++++ .../media-calls/src/server/CastDirector.ts | 194 ++++++++++++ .../media-calls/src/server/MediaCallServer.ts | 91 ++++++ .../media-calls/src/server/configuration.ts | 9 + .../src/server/getDefaultSettings.ts | 14 + .../media-calls/src/server/injection.ts | 32 ++ .../src/types/IMediaCallService.ts | 5 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 9 +- .../src/mediaCalls/IMediaCallChannel.ts | 1 + .../src/models/IMediaCallChannelsModel.ts | 11 +- .../src/models/IMediaCallsModel.ts | 2 + .../models/src/models/MediaCallChannels.ts | 47 ++- packages/models/src/models/MediaCalls.ts | 29 +- yarn.lock | 1 + 42 files changed, 1584 insertions(+), 1045 deletions(-) delete mode 100644 ee/packages/media-calls/src/agents/Manager.ts delete mode 100644 ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts delete mode 100644 ee/packages/media-calls/src/agents/definition/common.ts delete mode 100644 ee/packages/media-calls/src/agents/users/Agent.ts delete mode 100644 ee/packages/media-calls/src/agents/users/AgentFactory.ts delete mode 100644 ee/packages/media-calls/src/agents/users/BasicAgent.ts delete mode 100644 ee/packages/media-calls/src/agents/users/NewCallAgent.ts delete mode 100644 ee/packages/media-calls/src/agents/users/SignalProcessor.ts create mode 100644 ee/packages/media-calls/src/base/BaseAgent.ts create mode 100644 ee/packages/media-calls/src/base/BaseCallProvider.ts create mode 100644 ee/packages/media-calls/src/definition/IMediaCallAgent.ts create mode 100644 ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts create mode 100644 ee/packages/media-calls/src/definition/IMediaCallServer.ts create mode 100644 ee/packages/media-calls/src/definition/common.ts delete mode 100644 ee/packages/media-calls/src/global/CallMonitor.ts delete mode 100644 ee/packages/media-calls/src/global/ISignalGateway.ts delete mode 100644 ee/packages/media-calls/src/global/SignalGateway.ts create mode 100644 ee/packages/media-calls/src/internal/InternalCallProvider.ts rename ee/packages/media-calls/src/{global => internal}/SignalProcessor.ts (53%) create mode 100644 ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts create mode 100644 ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts create mode 100644 ee/packages/media-calls/src/internal/agents/CalleeAgent.ts create mode 100644 ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts create mode 100644 ee/packages/media-calls/src/internal/agents/CallerAgent.ts create mode 100644 ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts create mode 100644 ee/packages/media-calls/src/server/CallDirector.ts create mode 100644 ee/packages/media-calls/src/server/CastDirector.ts create mode 100644 ee/packages/media-calls/src/server/MediaCallServer.ts create mode 100644 ee/packages/media-calls/src/server/configuration.ts create mode 100644 ee/packages/media-calls/src/server/getDefaultSettings.ts create mode 100644 ee/packages/media-calls/src/server/injection.ts diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index d56e7f15b4f11..22501eae8f824 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -1,9 +1,11 @@ import { api, ServiceClassInternal, type IMediaCallService } from '@rocket.chat/core-services'; -import type { IUser, IMediaCall } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { gateway, MediaCallMonitor } from '@rocket.chat/media-calls'; +import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls'; import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling'; -import { Users, MediaCalls } from '@rocket.chat/models'; +import { MediaCalls } from '@rocket.chat/models'; + +import { settings } from '../../../app/settings/server'; const logger = new Logger('media-call service'); @@ -12,13 +14,21 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall constructor() { super(); - gateway.setSignalHandler(this.sendSignal.bind(this)); + callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal)); + + this.onEvent('watch.settings', async ({ setting }): Promise => { + if (setting._id.startsWith('VoIP_TeamCollab_')) { + setImmediate(() => this.configureMediaCallServer()); + } + }); + + this.configureMediaCallServer(); } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { logger.debug({ msg: 'new client signal', signal, uid }); - gateway.receiveSignal(uid, signal); + callServer.receiveSignal(uid, signal); } catch (error) { logger.error({ msg: 'failed to process client signal', error, signal, uid }); } @@ -30,55 +40,19 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall const deserialized = await this.deserializeClientSignal(signal); - gateway.receiveSignal(uid, deserialized); + callServer.receiveSignal(uid, deserialized); } catch (error) { logger.error({ msg: 'failed to process client signal', error, signal, uid }); } } - public async createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise { - const user = await Users.findOneActiveById(callee.uid, { projection: { _id: 1 } }); - if (!user) { - throw new Error('invalid-user'); - } - - return gateway.createCall({ - caller: { - type: 'user', - id: caller.uid, - contractId: caller.contractId, - }, - callee: { type: 'user', id: callee.uid }, - }); - } - - public async callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise { - const user = await Users.findOneByFreeSwitchExtension(extension, { projection: { _id: 1 } }); - if (!user) { - throw new Error('invalid-user'); - } - - return gateway.createCall({ - caller: { - type: 'user', - id: caller.uid, - contractId: caller.contractId, - }, - callee: { type: 'user', id: user._id }, - }); - } - - public async callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise { - return this.createInternalCall(caller, { uid: userId }); - } - public async hangupExpiredCalls(): Promise { - await MediaCallMonitor.hangupExpiredCalls().catch((error) => { - logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error }); + await callServer.hangupExpiredCalls().catch((error) => { + logger.error({ msg: 'Media Call Server failed to hangup expired calls', error }); }); if (await MediaCalls.hasUnfinishedCalls()) { - MediaCallMonitor.scheduleExpirationCheck(); + callServer.scheduleExpirationCheck(); } } @@ -86,6 +60,27 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall void api.broadcast('user.media-signal', { userId: toUid, signal }); } + private configureMediaCallServer(): void { + callServer.configure(this.getMediaServerSettings()); + } + + private getMediaServerSettings(): IMediaCallServerSettings { + const enabled = settings.get('VoIP_TeamCollab_Enabled') ?? false; + const sipEnabled = false; + const forceSip = false; + + return { + enabled, + internalCalls: { + requireExtensions: forceSip, + routeExternally: forceSip ? 'always' : 'never', + }, + sip: { + enabled: sipEnabled, + }, + }; + } + private async deserializeClientSignal(serialized: string): Promise { try { const signal = JSON.parse(serialized); diff --git a/ee/packages/media-calls/package.json b/ee/packages/media-calls/package.json index f5c0ec93644b3..7a2c65e07f620 100644 --- a/ee/packages/media-calls/package.json +++ b/ee/packages/media-calls/package.json @@ -25,6 +25,7 @@ ], "dependencies": { "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/logger": "workspace:^", "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/models": "workspace:^" diff --git a/ee/packages/media-calls/src/agents/Manager.ts b/ee/packages/media-calls/src/agents/Manager.ts deleted file mode 100644 index 85e5a44d79614..0000000000000 --- a/ee/packages/media-calls/src/agents/Manager.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallActor, ServerActor } from '@rocket.chat/core-typings'; -import type { CallHangupReason, CallRole, CallNotification } from '@rocket.chat/media-signaling'; -import type { InsertionModel } from '@rocket.chat/model-typings'; -import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; - -import { logger } from '../logger'; -import type { IMediaCallAgent, IMediaCallAgentFactory, IMediaCallBasicAgent, INewMediaCallAgent } from './definition/IMediaCallAgent'; -import { UserAgentFactory } from './users/AgentFactory'; -import { MediaCallMonitor } from '../global/CallMonitor'; -import { gateway } from '../global/SignalGateway'; - -type FactoryFn = () => Promise; - -class MediaCallAgentManager { - public async getAgentFactoryForActor(actor: MediaCallActor): Promise { - logger.debug({ msg: 'AgentManager.getAgentFactoryForActor', actor }); - if (actor.type === 'user') { - return UserAgentFactory.getAgentFactoryForActor(actor); - } - - if (actor.type === 'sip') { - return null; - } - - return null; - } - - public async getNewAgentForActor(actor: MediaCallActor, role: CallRole): Promise { - return this.getNewAgent(() => this.getAgentFactoryForActor(actor), role); - } - - public async getAgentForCallActor(call: IMediaCall, actor: MediaCallActor): Promise { - return this.getCallAgent(() => this.getAgentFactoryForActor(actor), call); - } - - public getRoleForCallActor(call: IMediaCall, actor: Pick): CallRole | null { - if (call.callee.type === actor.type && call.callee.id === actor.id) { - return 'callee'; - } - - if (call.caller.type === actor.type && call.caller.id === actor.id) { - return 'caller'; - } - - return null; - } - - public async getOrCreateContract( - callId: string, - agent: IMediaCallBasicAgent, - params?: Pick, - ): Promise { - const { role, actorType, actorId, contractId } = agent; - logger.debug({ msg: 'AgentManager.getOrCreateContract', callId, params, role, actorType, actorId, contractId }); - - if (!contractId) { - throw new Error('error-invalid-contract'); - } - - const contact = await agent.getContactInfo(); - - const newChannel: InsertionModel = { - callId, - state: 'none', - role, - acknowledged: Boolean(params?.acknowledged) ?? false, - contact, - contractId, - actorType, - actorId, - }; - - // Create this channel if it doesn't yet exist, or update ack if it does and needs it - const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); - if (!insertedChannel) { - throw new Error('failed-to-insert-channel'); - } - - // This shouldn't be possible unless something tried to switch the roles of the call's actors - if (insertedChannel.role !== role) { - throw new Error('invalid-channel-data'); - } - - return insertedChannel; - } - - public async hangupCall(agent: IMediaCallAgent, reason: CallHangupReason): Promise { - logger.debug({ msg: 'AgentManager.hangupCall', reason, actor: agent.actor }); - const endedBy = agent.actor; - - const stateResult = await MediaCalls.hangupCallById(agent.callId, { endedBy, reason }); - if (!stateResult.modifiedCount) { - return; - } - - agent.notify(agent.callId, 'hangup'); - const otherAgent = await this.getAnyOppositeAgent(agent); - if (otherAgent) { - otherAgent.notify(agent.callId, 'hangup'); - } - } - - public async expireCall(call: Pick): Promise { - logger.debug({ msg: 'AgentManager.expireCall', call }); - - if (!(await this.hangupByServer(call._id, 'timeout'))) { - return; - } - - // We send a signal to rocket.chat users involved on the call without instantiating a full agent - - if (call.callee.type === 'user') { - gateway.sendSignal(call.callee.id, { - callId: call._id, - type: 'notification', - notification: 'hangup', - }); - } - - if (call.caller.type === 'user') { - gateway.sendSignal(call.caller.id, { - callId: call._id, - type: 'notification', - notification: 'hangup', - }); - } - } - - public async activateCall(agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.activateCall', actor: agent.actor }); - - const stateResult = await MediaCalls.activateCallById(agent.callId, MediaCallMonitor.getNewExpirationTime()); - if (!stateResult.modifiedCount) { - return; - } - - const otherAgent = await this.getAnyOppositeAgent(agent); - if (otherAgent) { - otherAgent.notify(agent.callId, 'active'); - } - } - - public async acknowledgeCallee(agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.acknowledgeCallee', actor: agent.actor }); - if (agent.role !== 'callee') { - return; - } - - await MediaCalls.startRingingById(agent.callId, MediaCallMonitor.getNewExpirationTime()); - } - - public async acceptCall(agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.acceptCall', actor: agent.actor }); - if (agent.role !== 'callee') { - return; - } - - const stateResult = await MediaCalls.acceptCallById(agent.callId, agent.contractId, MediaCallMonitor.getNewExpirationTime()); - // If nothing changed, the call was no longer ringing - if (!stateResult.modifiedCount) { - return; - } - - agent.sign(); - - return this.processAcceptedCall(agent); - } - - public async setLocalDescription(agent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'AgentManager.setLocalDescription', actor: agent.actor, sdp }); - const otherAgent = await this.getOppositeAgent(agent); - if (!otherAgent) { - logger.debug({ msg: 'Opposite agent not found', method: 'AgentManager.setLocalDescription', role: agent.role, actor: agent.actor }); - return; - } - - otherAgent.setRemoteDescription(sdp); - } - - public async hangupByServer(callId: string, serverErrorCode: string, agentsToNotify?: IMediaCallBasicAgent[]): Promise { - logger.debug({ msg: 'AgentManager.hangupByServer', callId, serverErrorCode }); - - const endedBy = { type: 'server', id: 'server' } as ServerActor; - - const stateResult = await MediaCalls.hangupCallById(callId, { endedBy, reason: serverErrorCode }); - const result = Boolean(stateResult.modifiedCount); - - if (result && agentsToNotify) { - await Promise.allSettled(agentsToNotify.map((agent) => agent.notify(callId, 'hangup'))); - } - - return result; - } - - private async getOppositeAgentFactory(call: IMediaCall, agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.getOppositeAgentFactory', actor: agent.actor }); - switch (agent.role) { - case 'caller': - return this.getAgentFactoryForActor(call.callee); - case 'callee': - return this.getAgentFactoryForActor(call.caller); - default: - return null; - } - } - - /** This will only return an agent if a contract for the other role has already been signed */ - private async getOppositeAgent(agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.getOppositeAgent', actor: agent.actor }); - const call = await MediaCalls.findOneById(agent.callId); - if (!call) { - logger.debug({ msg: 'call not found', method: 'AgentManager.getOppositeAgent' }); - return null; - } - - return this.getCallAgent(() => this.getOppositeAgentFactory(call, agent), call); - } - - private async getAnyOppositeAgent(agent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.getAnyOppositeAgent', actor: agent.actor }); - const call = await MediaCalls.findOneById(agent.callId); - if (!call) { - return null; - } - - const factory = await this.getOppositeAgentFactory(call, agent); - if (!factory) { - return null; - } - - return factory.getCallAgent(call) || factory.getNewAgent(agent.oppositeRole); - } - - private hangupAndThrow(callId: string, error: string, agents?: IMediaCallBasicAgent[]): never { - logger.debug({ msg: 'AgentManager.hangupAndThrow', callId, error }); - this.shieldPromise(this.hangupByServer(callId, error, agents)); - - throw new Error(error); - } - - private shieldPromise(promiseOrFn: Promise | (() => Promise)): void { - const promise = typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn; - void promise.catch(() => null); - } - - private async notifyAll(agents: IMediaCallBasicAgent[], callId: string, notification: CallNotification): Promise { - logger.debug({ msg: 'AgentManager.notifyAll', callId, notification }); - await Promise.all(agents.map((agent) => agent.notify(callId, notification))); - } - - private async processAcceptedCall(calleeAgent: IMediaCallAgent): Promise { - logger.debug({ msg: 'AgentManager.processAcceptedCall', callee: calleeAgent.actor }); - const { callId } = calleeAgent; - - const call = await MediaCalls.findOneById(callId); - if (!call) { - throw new Error('error-failed-to-accept-call'); - } - - const callerAgent = await this.getAgentForCallActor(call, call.caller); - if (!callerAgent) { - this.hangupAndThrow(callId, 'unable-to-identify-caller-agent', [calleeAgent]); - } - - // We send the accepted notification immediately to report on the call state - await this.notifyAll([calleeAgent, callerAgent], callId, 'accepted'); - - const offer = await callerAgent.getLocalDescription(); - if (!offer) { - await callerAgent.requestOffer({}); - return; - } - - if (offer.type !== 'offer') { - logger.warn({ msg: 'caller sdp is not an offer', local: offer }); - throw new Error('unexpected-state'); - } - - calleeAgent.setRemoteDescription(offer); - } - - private async getNewAgent( - factoryFn: FactoryFn, - ...params: Parameters - ): Promise { - return (await factoryFn())?.getNewAgent(...params) ?? null; - } - - private async getCallAgent( - factoryFn: FactoryFn, - ...params: Parameters - ): Promise { - return (await factoryFn())?.getCallAgent(...params) ?? null; - } -} - -export const agentManager = new MediaCallAgentManager(); diff --git a/ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts deleted file mode 100644 index 1a7ea33d2aa47..0000000000000 --- a/ee/packages/media-calls/src/agents/definition/IMediaCallAgent.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { IMediaCall, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; -import type { CallContact, CallRole, CallNotification } from '@rocket.chat/media-signaling'; - -// The basic agent may be instantiated to send notifications to actors with no need for a contract -export interface IMediaCallBasicAgent { - readonly actorType: MediaCallActorType; - readonly actorId: string; - // on new calls, contractId will only be defined for the caller; - // the callee only has a contractId when the agent is instantiated for a specific contract or when a callee contract is signed. - readonly contractId?: string; - readonly role: CallRole; - readonly oppositeRole: CallRole; - - isRepresentingActor(actor: MediaCallActor): boolean; - getContactInfo(): Promise; - notify(callId: string, notification: CallNotification): Promise; -} - -// The New Call Agent may be instantiated without a call; it is used to start new calls -export interface INewMediaCallAgent extends IMediaCallBasicAgent { - onNewCall(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise; -} - -// The full agent will always be instantiated for a specific call and contract; it is used to manage the call state -export interface IMediaCallAgent extends IMediaCallBasicAgent { - readonly callId: string; - readonly contractId: string; - readonly signed: boolean; - - readonly actor: MediaCallSignedActor; - - setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise; - getLocalDescription(): Promise; - requestOffer(params: { iceRestart?: boolean }): Promise; - sign(): Promise; -} - -// The agent factory is an object with functions to instantiate the two agent types -export interface IMediaCallAgentFactory { - // getNewAgent may never return null; if the agent can't handle a call, the whole factory should be null instead. - getNewAgent(role: CallRole): INewMediaCallAgent; - // getCallAgent may return null if the actor is not part of the call, if it doesn't have a contract, or if a different contract is already signed - getCallAgent(call: IMediaCall): IMediaCallAgent | null; -} - -export abstract class MediaCallBasicAgent implements IMediaCallBasicAgent { - public readonly actorType: T['actorType']; - - public readonly actorId: T['actorId']; - - public readonly contractId: T['contractId']; - - public readonly role: CallRole; - - public get actor(): { type: T['actorType']; id: T['actorId']; contractId: T['contractId'] } { - return { - type: 'user', - id: this.actorId, - contractId: this.contractId, - }; - } - - public get oppositeRole(): CallRole { - return ({ callee: 'caller', caller: 'callee' } as const)[this.role]; - } - - constructor(data: MediaCallActor & { role: CallRole }) { - this.actorType = data.type; - this.actorId = data.id; - this.role = data.role; - this.contractId = data.contractId; - } - - public isRepresentingActor(actor: MediaCallActor): boolean { - return actor.type === this.actorType && actor.id === this.actorId; - } - - public isRepresentingContract(actor: MediaCallActor, contractId: string): boolean { - return this.isRepresentingActor(actor) && this.contractId === contractId; - } - - public abstract getContactInfo(): Promise; - - public abstract notify(callId: string, notification: CallNotification): Promise; -} diff --git a/ee/packages/media-calls/src/agents/definition/common.ts b/ee/packages/media-calls/src/agents/definition/common.ts deleted file mode 100644 index ba2c623e49e19..0000000000000 --- a/ee/packages/media-calls/src/agents/definition/common.ts +++ /dev/null @@ -1 +0,0 @@ -export type AgentContractState = 'proposed' | 'signed' | 'ignored'; diff --git a/ee/packages/media-calls/src/agents/users/Agent.ts b/ee/packages/media-calls/src/agents/users/Agent.ts deleted file mode 100644 index 240cec1c39242..0000000000000 --- a/ee/packages/media-calls/src/agents/users/Agent.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, CallRole, CallNotification } from '@rocket.chat/media-signaling'; - -import { UserBasicAgent, type MinimalUserData } from './BasicAgent'; -import { UserAgentSignalProcessor } from './SignalProcessor'; -import { logger } from '../../logger'; -import { agentManager } from '../Manager'; -import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; -import type { AgentContractState } from '../definition/common'; - -export class UserMediaCallAgent extends UserBasicAgent implements IMediaCallAgent { - public readonly callId: string; - - public readonly contractId: string; - - protected contractState: AgentContractState; - - protected signalProcessor: UserAgentSignalProcessor; - - public get signed(): boolean { - return this.contractState === 'signed'; - } - - public get ignored(): boolean { - return this.contractState === 'ignored'; - } - - constructor(user: MinimalUserData, data: { role: CallRole; callId: string; contractId: string; contractState?: AgentContractState }) { - const { callId, contractState, ...params } = data; - super(user, params); - this.contractId = data.contractId; - this.callId = callId; - this.contractState = (this.contractId && contractState) || 'proposed'; - - this.signalProcessor = new UserAgentSignalProcessor(this); - } - - public async processSignal(signal: ClientMediaSignal, call: IMediaCall): Promise { - return this.signalProcessor.processSignal(signal, call); - } - - public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserMediaCallAgent.setRemoteDescription', sdp }); - await this.sendSignal({ - callId: this.callId, - toContractId: this.contractId, - type: 'remote-sdp', - sdp, - }); - } - - public async getLocalDescription(): Promise { - logger.debug({ msg: 'UserMediaCallAgent.getRemoteDescription' }); - const channel = await agentManager.getOrCreateContract(this.callId, this); - - return channel?.localDescription ?? null; - } - - public async requestOffer(params: { iceRestart?: boolean }): Promise { - logger.debug({ msg: 'UserMediaCallAgent.requestOffer', params, actor: this.actor, contractState: this.contractState }); - - await this.sendSignal({ - callId: this.callId, - toContractId: this.contractId, - type: 'request-offer', - ...params, - }); - } - - public async notify(callId: string, notification: CallNotification, signedContractId?: string): Promise { - // If we have been ignored, we should not be notifying anyone - if (this.ignored) { - return; - } - - // If we know we're signed, inject our contractId into all notifications we send to the client - const signedId = signedContractId || (this.signed && this.contractId) || undefined; - return super.notify(callId, notification, signedId); - } - - public async sign(): Promise { - if (this.contractState !== 'proposed') { - throw new Error(`Can't sign a contract that is not pending.`); - } - - this.contractState = 'signed'; - } -} diff --git a/ee/packages/media-calls/src/agents/users/AgentFactory.ts b/ee/packages/media-calls/src/agents/users/AgentFactory.ts deleted file mode 100644 index 84fe82901df5d..0000000000000 --- a/ee/packages/media-calls/src/agents/users/AgentFactory.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; -import type { CallRole } from '@rocket.chat/media-signaling'; -import { Users } from '@rocket.chat/models'; - -import type { MinimalUserData } from './BasicAgent'; -import { agentManager } from '../Manager'; -import { UserMediaCallAgent } from './Agent'; -import { UserNewCallAgent } from './NewCallAgent'; -import { logger } from '../../logger'; -import type { IMediaCallAgentFactory } from '../definition/IMediaCallAgent'; -import type { AgentContractState } from '../definition/common'; - -// Overriding the interface to use the same factory for the AgentManager class and the processSignal function -export interface IUserAgentFactory extends IMediaCallAgentFactory { - getNewAgent(role: CallRole): UserNewCallAgent; - getCallAgent(call: IMediaCall): UserMediaCallAgent | null; -} - -// Agent that handles all 'user' actors -export class UserAgentFactory { - public static async getAgentFactoryForUser(userId: string, contractId?: string): Promise { - const user = await Users.findOneActiveById(userId, { - projection: { username: 1, name: 1, freeSwitchExtension: 1 }, - }); - - if (!user?.username) { - logger.debug({ msg: 'invalid user or no username', method: 'UserAgentFactory.getAgentFactoryForUser', userId, contractId }); - return null; - } - - const userData = user as MinimalUserData; - - return { - getNewAgent(role: CallRole) { - return new UserNewCallAgent(userData, { role, contractId }); - }, - getCallAgent(call: IMediaCall) { - const { _id: callId } = call; - - if (!contractId) { - logger.debug({ msg: 'no contractId', method: 'UserAgentFactory.getCallAgent', userId, contractId, callId }); - return null; - } - - const role = agentManager.getRoleForCallActor(call, { type: 'user', id: userId }); - if (!role) { - logger.debug({ msg: 'no role', method: 'UserAgentFactory.getCallAgent', userId, contractId, callId }); - return null; - } - - const { [role]: callActor } = call; - let contractState: AgentContractState = 'proposed'; - if (callActor.contractId) { - contractState = callActor.contractId === contractId ? 'signed' : 'ignored'; - } - - // If the role is already signed with a different contract, do not create the agent - if (contractState === 'ignored') { - logger.debug({ - msg: 'signed by another contract', - method: 'UserAgentFactory.getAgentFactoryForUser', - userId, - contractId, - callActor, - }); - return null; - } - - return new UserMediaCallAgent(userData, { role, callId, contractId, contractState }); - }, - }; - } - - public static async getAgentFactoryForActor(actor: MediaCallActor): Promise { - if (actor.type !== 'user') { - return null; - } - - return this.getAgentFactoryForUser(actor.id, actor.contractId); - } -} diff --git a/ee/packages/media-calls/src/agents/users/BasicAgent.ts b/ee/packages/media-calls/src/agents/users/BasicAgent.ts deleted file mode 100644 index 964d1d98fd19c..0000000000000 --- a/ee/packages/media-calls/src/agents/users/BasicAgent.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { IUser, MediaCallActor } from '@rocket.chat/core-typings'; -import type { CallContact, CallRole, ServerMediaSignal, CallNotification } from '@rocket.chat/media-signaling'; - -import { gateway } from '../../global/SignalGateway'; -import { logger } from '../../logger'; -import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; -import { MediaCallBasicAgent } from '../definition/IMediaCallAgent'; - -export type MinimalUserData = Pick & Pick, 'username'>; - -export class UserBasicAgent extends MediaCallBasicAgent { - constructor( - protected readonly user: MinimalUserData, - data: { role: CallRole; contractId?: string }, - ) { - super({ type: 'user', id: user._id, ...data }); - } - - public isRepresentingActor(actor: MediaCallActor): actor is MediaCallActor<'user'> { - return super.isRepresentingActor(actor); - } - - public async getContactInfo(): Promise { - const { _id: id, username, name: displayName, freeSwitchExtension: sipExtension } = this.user; - - return { - type: 'user', - id, - username, - displayName, - sipExtension, - }; - } - - public async notify(callId: string, notification: CallNotification, signedContractId?: string): Promise { - logger.debug({ msg: 'UserBasicAgent.notify', callId, notification }); - return this.sendSignal({ - callId, - type: 'notification', - notification, - ...(signedContractId && { signedContractId }), - }); - } - - protected async sendSignal(signal: ServerMediaSignal): Promise { - gateway.sendSignal(this.user._id, signal); - } -} diff --git a/ee/packages/media-calls/src/agents/users/NewCallAgent.ts b/ee/packages/media-calls/src/agents/users/NewCallAgent.ts deleted file mode 100644 index d858f700908d8..0000000000000 --- a/ee/packages/media-calls/src/agents/users/NewCallAgent.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; - -import { UserBasicAgent } from './BasicAgent'; -import { logger } from '../../logger'; -import { agentManager } from '../Manager'; -import type { IMediaCallBasicAgent, INewMediaCallAgent } from '../definition/IMediaCallAgent'; - -export class UserNewCallAgent extends UserBasicAgent implements INewMediaCallAgent { - public async onNewCall(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { - logger.debug({ msg: 'UserNewCallAgent.onNewCall', call, contractId: this.contractId }); - // If we have a contract id, ensure the contract is registered - if (this.contractId) { - await agentManager.getOrCreateContract(call._id, this); - } - - await this.sendNewSignal(call, otherAgent); - } - - private async sendNewSignal(call: IMediaCall, otherAgent: IMediaCallBasicAgent): Promise { - logger.debug({ msg: 'UserNewCallAgent.sendNewSignal', call }); - const contact = await otherAgent.getContactInfo(); - - return this.sendSignal({ - callId: call._id, - type: 'new', - service: call.service, - kind: call.kind, - role: this.role, - contact, - - ...(this.role === 'caller' && call.callerRequestedId && { requestedCallId: call.callerRequestedId }), - }); - } -} diff --git a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts b/ee/packages/media-calls/src/agents/users/SignalProcessor.ts deleted file mode 100644 index 6c4aff2bffec5..0000000000000 --- a/ee/packages/media-calls/src/agents/users/SignalProcessor.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; -import type { CallAnswer, ClientMediaSignal, ClientMediaSignalLocalState } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; - -import type { UserMediaCallAgent } from './Agent'; -import { logger } from '../../logger'; -import { agentManager } from '../Manager'; - -type ChannelFunctionParams = { - call: IMediaCall; - channel: IMediaCallChannel; -}; - -export class UserAgentSignalProcessor { - constructor(private readonly agent: UserMediaCallAgent) {} - - public async processSignal(signal: ClientMediaSignal, call: IMediaCall): Promise { - logger.debug({ msg: 'UserAgentSignalProcessor.processSignal', call, signal }); - - const channel = await agentManager.getOrCreateContract(call._id, this.agent, { acknowledged: true }); - if (!channel) { - throw new Error('invalid-call'); - } - - // The code will only reach this point if one of the three following conditions are true: - // 1. the signal came from the exact user session where the caller initiated the call - // 2. the signal came from the exact user session where the callee accepted the call - // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee - - const params = { channel, call }; - - switch (signal.type) { - case 'local-sdp': - await this.saveLocalDescription(params, signal.sdp); - break; - case 'answer': - await this.processAnswer(params, signal.answer); - break; - case 'hangup': - await agentManager.hangupCall(this.agent, signal.reason); - break; - case 'local-state': - await this.reviewLocalState(params, signal); - break; - } - } - - private async saveLocalDescription({ channel }: ChannelFunctionParams, sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserAgentSignalProcessor.saveLocalDescription', sdp }); - await MediaCallChannels.setLocalDescription(channel._id, sdp); - - await agentManager.setLocalDescription(this.agent, sdp); - } - - private async processAnswer(params: ChannelFunctionParams, answer: CallAnswer): Promise { - logger.debug({ msg: 'UserAgentSignalProcessor.processAnswer', params, answer }); - const { call } = params; - - switch (answer) { - case 'ack': - return this.processACK(params); - case 'accept': - return agentManager.acceptCall(this.agent); - case 'unavailable': - // We don't do anything on unavailable responses from clients, as a different client may still answer - return; - case 'reject': - return this.processReject(call); - } - } - - private async processReject(call: IMediaCall): Promise { - logger.debug({ msg: 'UserAgentSignalProcessor.processReject', call }); - if (this.agent.role !== 'callee') { - return; - } - - if (!['none', 'ringing'].includes(call.state)) { - return; - } - - return agentManager.hangupCall(this.agent, 'rejected'); - } - - private async processACK({ channel }: ChannelFunctionParams): Promise { - logger.debug({ msg: 'UserAgentSignalProcessor.processACK' }); - switch (this.agent.role) { - case 'callee': - // Change the call state from 'none' to 'ringing' when any callee session is found - await agentManager.acknowledgeCallee(this.agent); - break; - case 'caller': - // When the caller ack, we ask them to start creating an offer - await this.requestChannelOffer(channel, {}); - break; - } - } - - private async requestChannelOffer(channel: IMediaCallChannel, params: { iceRestart?: boolean }): Promise { - // If the channel already has a local Sdp, no need to request its offer unless we're restarting ICE - if (channel.localDescription?.sdp && !params.iceRestart) { - return; - } - - await this.agent.requestOffer(params); - } - - private async reviewLocalState({ channel }: ChannelFunctionParams, signal: ClientMediaSignalLocalState): Promise { - if (!this.agent.signed) { - return; - } - - if (signal.clientState === 'active') { - if (channel.state === 'active' || channel.activeAt) { - return; - } - - const result = await MediaCallChannels.setActiveById(channel._id); - if (result.modifiedCount) { - await agentManager.activateCall(this.agent); - } - } - } -} diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts new file mode 100644 index 0000000000000..9b0a68c5270ec --- /dev/null +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -0,0 +1,152 @@ +import type { + IMediaCall, + IMediaCallChannel, + MediaCallActor, + MediaCallActorType, + MediaCallContact, + MediaCallSignedActor, +} from '@rocket.chat/core-typings'; +import type { CallContact, CallRole } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; +import { logger } from '../logger'; + +export abstract class BaseMediaCallAgent implements IMediaCallAgent { + public readonly actorType: MediaCallActorType; + + public readonly actorId: string; + + public oppositeAgent: IMediaCallAgent | null; + + public get actor(): MediaCallActor { + return { + type: this.actorType, + id: this.actorId, + }; + } + + public get oppositeRole(): CallRole { + return ({ callee: 'caller', caller: 'callee' } as const)[this.role]; + } + + protected localDescription: RTCSessionDescriptionInit | null; + + constructor( + protected readonly contact: MediaCallContact, + public readonly role: CallRole, + ) { + this.actorType = contact.type; + this.actorId = contact.id; + this.localDescription = null; + this.oppositeAgent = null; + } + + public isRepresentingActor(actor: MediaCallActor): boolean { + return actor.type === this.actorType && actor.id === this.actorId; + } + + public getMyCallActor(call: IMediaCall): MediaCallActor { + return call[this.role]; + } + + public getOtherCallActor(call: IMediaCall): MediaCallActor { + return call[this.oppositeRole]; + } + + public getSignedActor(contractId: string): MediaCallSignedActor { + return { + ...this.actor, + contractId, + }; + } + + public abstract onCallAccepted(callId: string, signedContractId: string): Promise; + + public abstract onCallActive(callId: string): Promise; + + public abstract onCallEnded(callId: string): Promise; + + public abstract onWebrtcAnswer(callId: string): Promise; + + public async getOrCreateChannel( + call: IMediaCall, + contractId: string, + params?: Pick, + ): Promise { + return this.createOrUpdateChannel(call, this.getSignedActor(contractId), this.getMyCallActor(call), params); + } + + public abstract onCallCreated(call: IMediaCall): Promise; + + public async onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'BaseMediaCallAgent.onRemoteDescriptionChanged', callId, description }); + await MediaCallChannels.setRemoteDescriptionByCallIdAndActor(callId, this.actor, description); + } + + protected async createOrUpdateChannel( + call: IMediaCall, + actor: MediaCallSignedActor, + contact: CallContact, + params?: Pick, + ): Promise { + if (!actor.contractId) { + throw new Error('error-invalid-contract'); + } + + const localDescription = params?.localDescription || this.localDescription || undefined; + + const newChannel: InsertionModel = { + callId: call._id, + state: 'none', + role: this.role, + acknowledged: Boolean(params?.acknowledged) ?? false, + ...(localDescription && { localDescription }), + contact, + contractId: actor.contractId, + actorType: actor.type, + actorId: actor.id, + }; + + // Create this channel if it doesn't yet exist, or update ack/sdp if it does and needs it + const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); + if (!insertedChannel) { + throw new Error('failed-to-insert-channel'); + } + + // This shouldn't be possible unless something tried to switch the roles of the call's actors + if (insertedChannel.role !== this.role) { + throw new Error('invalid-channel-data'); + } + + return insertedChannel; + } + + protected async autoDetectRemoteDescription(call: IMediaCall, localChannel: IMediaCallChannel): Promise { + // If our channel already has a remote description, we don't need to check anything + if (localChannel.remoteDescription) { + return; + } + + // If the other actor is not signed, we don't know what channel to check + const otherActor = this.getOtherCallActor(call); + if (!otherActor.contractId) { + return; + } + + const remoteChannel = await MediaCallChannels.findOneByCallIdAndSignedActor( + { + ...(otherActor as MediaCallSignedActor), + callId: call._id, + }, + { projection: { localDescription: 1 } }, + ); + + if (!remoteChannel?.localDescription) { + return; + } + + await this.onRemoteDescriptionChanged(call._id, remoteChannel.localDescription); + } +} diff --git a/ee/packages/media-calls/src/base/BaseCallProvider.ts b/ee/packages/media-calls/src/base/BaseCallProvider.ts new file mode 100644 index 0000000000000..820c725f811a8 --- /dev/null +++ b/ee/packages/media-calls/src/base/BaseCallProvider.ts @@ -0,0 +1,9 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +export class BaseCallProvider { + public get callId(): string { + return this.call._id; + } + + constructor(public readonly call: IMediaCall) {} +} diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts new file mode 100644 index 0000000000000..ca8e9a6ade004 --- /dev/null +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -0,0 +1,24 @@ +import type { IMediaCall, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; + +export interface IMediaCallAgent { + readonly actorType: MediaCallActorType; + readonly actorId: string; + readonly actor: MediaCallActor; + readonly role: CallRole; + readonly oppositeRole: CallRole; + + oppositeAgent: IMediaCallAgent | null; + + onCallEnded(callId: string): Promise; + /* Called when the call was accepted, even if the webrtc negotiation is pending */ + onCallAccepted(callId: string, signedContractId: string): Promise; + onCallActive(callId: string): Promise; + onCallCreated(call: IMediaCall): Promise; + /* Called when the opposite agent has a webrtc description available, even if the call was not yet accepted */ + onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; + /* Called when the call was accepted and the webrtc answer is available */ + onWebrtcAnswer(callId: string): Promise; + + getMyCallActor(call: IMediaCall): MediaCallActor; +} diff --git a/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts b/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts new file mode 100644 index 0000000000000..a2cdf060497c5 --- /dev/null +++ b/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts @@ -0,0 +1,34 @@ +import type { AtLeast, IMediaCall, MediaCallActor, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; + +import type { IMediaCallAgent } from './IMediaCallAgent'; +import type { GetActorContactOptions, MinimalUserData } from './common'; + +export type MediaCallHeader = AtLeast; + +export interface IMediaCallCastDirector { + getAgentsFromCall(call: MediaCallHeader): Promise<{ caller: IMediaCallAgent; callee: IMediaCallAgent }>; + getAgentFromCall(call: MediaCallHeader, role: CallRole): Promise; + getContactForActor( + actor: MediaCallActor, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise; + getContactForUser( + user: MinimalUserData, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): MediaCallContact | null; + getContactForUserId( + userId: string, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise; + getContactForExtensionNumber( + sipExtension: string, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise; + + getAgentForActorAndRole(actor: MediaCallContact, role: CallRole): Promise; +} diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts new file mode 100644 index 0000000000000..1de988932b762 --- /dev/null +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -0,0 +1,35 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import type { Emitter } from '@rocket.chat/emitter'; +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; + +export type MediaCallServerEvents = { + signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; +}; + +export interface IMediaCallServerSettings { + enabled: boolean; + + internalCalls: { + requireExtensions: boolean; + routeExternally: 'never' | 'preferrably' | 'always'; + }; + + sip: { + enabled: boolean; + }; +} + +export interface IMediaCallServer { + emitter: Emitter; + + // functions that trigger events + sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; + + // functions that are run on events + receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; + + // extra functions available to the service + hangupExpiredCalls(): Promise; + scheduleExpirationCheck(): void; + configure(settings: IMediaCallServerSettings): void; +} diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts new file mode 100644 index 0000000000000..5ee36534c8294 --- /dev/null +++ b/ee/packages/media-calls/src/definition/common.ts @@ -0,0 +1,16 @@ +import type { IUser, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { CallService } from '@rocket.chat/media-signaling'; + +export type MinimalUserData = Pick; + +export type GetActorContactOptions = { + requiredType?: MediaCallActorType; + preferredType?: MediaCallActorType; +}; + +export type InternalCallParams = { + caller: MediaCallSignedActor; + callee: MediaCallActor; + requestedCallId?: string; + requestedService?: CallService; +}; diff --git a/ee/packages/media-calls/src/global/CallMonitor.ts b/ee/packages/media-calls/src/global/CallMonitor.ts deleted file mode 100644 index 2ba450cbe3f7a..0000000000000 --- a/ee/packages/media-calls/src/global/CallMonitor.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; - -import { agentManager } from '../agents/Manager'; -import { logger } from '../logger'; - -const EXPIRATION_TIME = 120000; -const EXPIRATION_CHECK_DELAY = 1000; - -class Monitor { - public async hangupExpiredCalls(): Promise { - logger.debug('MediaCallMonitor.hangupExpiredCalls'); - - const result = MediaCalls.findAllExpiredCalls>({ - projection: { _id: 1, caller: 1, callee: 1 }, - }); - - for await (const call of result) { - await agentManager.expireCall(call); - } - } - - public getNewExpirationTime(): Date { - return new Date(Date.now() + EXPIRATION_TIME); - } - - public async renewCallId(callId: string): Promise { - await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime()); - this.scheduleExpirationCheck(); - } - - public scheduleExpirationCheck(): void { - setTimeout( - () => this.hangupExpiredCalls().catch((error) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error })), - EXPIRATION_TIME + EXPIRATION_CHECK_DELAY, - ); - } -} - -export const MediaCallMonitor = new Monitor(); diff --git a/ee/packages/media-calls/src/global/ISignalGateway.ts b/ee/packages/media-calls/src/global/ISignalGateway.ts deleted file mode 100644 index a08d4b1dad979..0000000000000 --- a/ee/packages/media-calls/src/global/ISignalGateway.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { IMediaCall, IUser, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; -import type { CallService, ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; - -export type CreateCallParams = { - caller: MediaCallSignedActor; - callee: MediaCallActor; - requestedCallId?: string; - requestedService?: CallService; -}; - -export type ServerSignalTransport = (uid: IUser['_id'], signal: ServerMediaSignal) => void; - -export interface ISignalGateway { - createCall(params: CreateCallParams): Promise; - setSignalHandler(handlerFn: ServerSignalTransport): void; - receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void; -} diff --git a/ee/packages/media-calls/src/global/SignalGateway.ts b/ee/packages/media-calls/src/global/SignalGateway.ts deleted file mode 100644 index 6ae5698cfc800..0000000000000 --- a/ee/packages/media-calls/src/global/SignalGateway.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { isClientMediaSignal } from '@rocket.chat/media-signaling'; -import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; - -import { logger } from '../logger'; -import type { ISignalGateway, ServerSignalTransport } from './ISignalGateway'; -import { GlobalSignalProcessor } from './SignalProcessor'; - -/** - * Class used as gateway to send and receive signals to/from clients - * The actual function used to send the signals needs to be set by the server - */ -export class SignalGateway extends GlobalSignalProcessor implements ISignalGateway { - private handler: ServerSignalTransport | null = null; - - public setSignalHandler(handlerFn: ServerSignalTransport): void { - this.handler = handlerFn; - } - - public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { - logger.debug({ msg: 'SignalGateway.receiveSignal', signal, fromUid }); - - if (!isClientMediaSignal(signal)) { - this.debugError('The Media Signal Server received an invalid client signal object', { signal }); - throw new Error('invalid-signal'); - } - - this.processSignal(fromUid, signal).catch((error) => - this.debugError('Failed to process client signal', { signal }, { error, type: signal.type }), - ); - } - - public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { - logger.debug({ msg: 'SignalGateway.sendSignal', toUid, signal }); - - if (!this.handler) { - this.debugError('The Media Signal Server tried to send a signal to a client without being configured first.', { signal }); - throw new Error('media-calls-server-not-configured'); - } - - try { - this.handler(toUid, signal); - } catch (error) { - this.debugError(`SignalGateway's handler threw an exception`, { toUid, signal }, { error }); - } - } - - private debugError(msg: string, debugInfo: Record, errorInfo?: Record): void { - logger.error({ msg, ...errorInfo }); - logger.debug({ msg: `${msg} - debug info`, ...debugInfo }); - } -} - -export const gateway = new SignalGateway(); diff --git a/ee/packages/media-calls/src/index.ts b/ee/packages/media-calls/src/index.ts index 77596c30c3219..eacac00750002 100644 --- a/ee/packages/media-calls/src/index.ts +++ b/ee/packages/media-calls/src/index.ts @@ -1,6 +1,3 @@ -import type { ISignalGateway } from './global/ISignalGateway'; -import { gateway as signalGateway } from './global/SignalGateway'; +export * from './definition/IMediaCallServer'; -export * from './global/CallMonitor'; - -export const gateway: ISignalGateway = signalGateway; +export { callServer } from './server/configuration'; diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts new file mode 100644 index 0000000000000..f891c71e49abc --- /dev/null +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -0,0 +1,38 @@ +import type { IMediaCall } from '@rocket.chat/core-typings'; + +import { BaseCallProvider } from '../base/BaseCallProvider'; +import type { InternalCallParams } from '../definition/common'; +import { logger } from '../logger'; +import { MediaCallDirector } from '../server/CallDirector'; + +export class InternalCallProvider extends BaseCallProvider { + public static async createCall(params: InternalCallParams): Promise { + logger.debug({ msg: 'InternalCallProvider.createCall', params }); + const callerAgent = await MediaCallDirector.cast.getAgentForActorAndRole(params.caller, 'caller'); + const calleeAgent = await MediaCallDirector.cast.getAgentForActorAndRole(params.callee, 'callee'); + + if (!callerAgent) { + throw new Error('invalid-caller'); + } + if (!calleeAgent) { + throw new Error('invalid-callee'); + } + + callerAgent.oppositeAgent = calleeAgent; + calleeAgent.oppositeAgent = callerAgent; + + const call = await MediaCallDirector.createCall({ + ...params, + callerAgent, + calleeAgent, + }); + + // If the caller agent fails, this will automatically hangup the call and throw an error + await MediaCallDirector.runOnCallCreatedForAgent(call, callerAgent); + + // If the callee agent fails, this will automatically hangup the call, notify the caller agent and then throw an error + await MediaCallDirector.runOnCallCreatedForAgent(call, calleeAgent, callerAgent); + + return call; + } +} diff --git a/ee/packages/media-calls/src/global/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts similarity index 53% rename from ee/packages/media-calls/src/global/SignalProcessor.ts rename to ee/packages/media-calls/src/internal/SignalProcessor.ts index b7c430ff672d3..2479c57782d81 100644 --- a/ee/packages/media-calls/src/global/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -1,82 +1,37 @@ import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; import type { - CallActorType, ClientMediaSignal, ClientMediaSignalRegister, ClientMediaSignalRequestCall, ServerMediaSignal, ServerMediaSignalRejectedCallRequest, } from '@rocket.chat/media-signaling'; -import { MediaCalls, Users } from '@rocket.chat/models'; +import { MediaCalls } from '@rocket.chat/models'; -import { agentManager } from '../agents/Manager'; -import { UserAgentFactory } from '../agents/users/AgentFactory'; +import type { GetActorContactOptions, InternalCallParams } from '../definition/common'; import { logger } from '../logger'; -import { MediaCallMonitor } from './CallMonitor'; -import type { CreateCallParams } from './ISignalGateway'; +import { MediaCallDirector } from '../server/CallDirector'; +import { UserActorAgent } from './agents/BaseUserAgent'; +import type { IMediaCallServerSettings } from '../definition/IMediaCallServer'; +import { getDefaultSettings } from '../server/getDefaultSettings'; -export abstract class GlobalSignalProcessor { - protected abstract sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void; +export type SignalProcessorEvents = { + signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; + callRequest: { fromUid: IUser['_id']; params: InternalCallParams }; +}; - public async createCall(params: CreateCallParams): Promise { - logger.debug({ msg: 'GlobalSignalProcessor.createCall', params }); - const { caller, callee, requestedCallId, requestedService } = params; +export class GlobalSignalProcessor { + public readonly emitter: Emitter; - // The caller must always have a contract to create the call - if (!caller.contractId) { - throw new Error('invalid-caller'); - } - - const callerAgent = await agentManager.getNewAgentForActor(caller, 'caller'); - if (!callerAgent) { - throw new Error('invalid-caller'); - } - - const calleeAgent = await agentManager.getNewAgentForActor(callee, 'callee'); - if (!calleeAgent) { - throw new Error('invalid-callee'); - } - - const service = requestedService || 'webrtc'; - - // webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call - if (service !== 'webrtc') { - throw new Error('invalid-call-service'); - } - - const call: Omit = { - service, - kind: 'direct', - state: 'none', - - createdBy: caller, - createdAt: new Date(), - - caller, - callee, + private settings: IMediaCallServerSettings; - expiresAt: MediaCallMonitor.getNewExpirationTime(), - - ...(requestedCallId && { callerRequestedId: requestedCallId }), - }; - - const insertResult = await MediaCalls.insertOne(call); - if (!insertResult.insertedId) { - throw new Error('failed-to-create-call'); - } - - const newCall = await MediaCalls.findOneById(insertResult.insertedId); - if (!newCall) { - throw new Error('failed-to-create-call'); - } - - await callerAgent.onNewCall(newCall, calleeAgent); - await calleeAgent.onNewCall(newCall, callerAgent); - - return newCall; + constructor() { + this.emitter = new Emitter(); + this.settings = getDefaultSettings(); } - protected async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { + public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal, uid }); switch (signal.type) { @@ -93,17 +48,16 @@ export abstract class GlobalSignalProcessor { logger.error({ msg: 'Unrecognized media signal', signal }); } - protected async mutateCallee(callee: { type: CallActorType; id: string }): Promise<{ type: CallActorType; id: string }> { - if (callee.type !== 'sip') { - return callee; - } + public configure(settings: IMediaCallServerSettings): void { + this.settings = settings; + } - const user = await Users.findOneByFreeSwitchExtension>(callee.id, { projection: { _id: 1 } }); - if (user) { - return { type: 'user', id: user._id }; - } + protected sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { + this.emitter.emit('signalRequest', { toUid, signal }); + } - return callee; + protected createCall(fromUid: IUser['_id'], params: InternalCallParams): void { + this.emitter.emit('callRequest', { fromUid, params }); } private async processCallSignal( @@ -119,29 +73,31 @@ export abstract class GlobalSignalProcessor { const isCaller = call.caller.type === 'user' && call.caller.id === uid; const isCallee = call.callee.type === 'user' && call.callee.id === uid; - if (!isCaller && !isCallee) { - logger.error({ msg: 'actor is not part of the call', method: 'processSignal', signal }); + + // The user must be either the caller or the callee, if its none or both, we can't process it + if (isCaller === isCallee) { + logger.error({ msg: 'failed to identify actor role in the call', method: 'processSignal', signal, isCaller, isCallee }); throw new Error('invalid-call'); } - // Ignore signals from different sessions - if (isCaller && call.caller.contractId && call.caller.contractId !== signal.contractId) { - return; - } - if (isCallee && call.callee.contractId && call.callee.contractId !== signal.contractId) { + const role = isCaller ? 'caller' : 'callee'; + const callActor = call[role]; + + // Ignore signals from different sessions if the actor is already signed + if (callActor.contractId && callActor.contractId !== signal.contractId) { return; } - await MediaCallMonitor.renewCallId(call._id); + await MediaCallDirector.renewCallId(call._id); - const factory = await UserAgentFactory.getAgentFactoryForUser(uid, signal.contractId); - const agent = factory?.getCallAgent(call); - if (!agent) { - logger.error({ msg: 'agent not found', method: 'processSignal', signal }); - throw new Error('invalid-call'); + const agents = await MediaCallDirector.cast.getAgentsFromCall(call); + const { [role]: agent } = agents; + + if (!(agent instanceof UserActorAgent)) { + throw new Error('Actor agent is not prepared to process signals'); } - await agent.processSignal(signal, call); + await agent.processSignal(call, signal); } catch (e) { logger.error(e); throw e; @@ -154,11 +110,52 @@ export abstract class GlobalSignalProcessor { // 2. Hangup active calls involving the oldContractId if it's different from the new one. } + private getCalleeContactOptions(): GetActorContactOptions { + if (!this.settings.sip.enabled) { + return { + requiredType: 'user', + }; + } + + switch (this.settings.internalCalls.routeExternally) { + case 'always': + // Will only make sip calls + return { + requiredType: 'sip', + }; + case 'never': + // Will not use sip when calling an user or an extension assigned to an user, but will use sip when calling an unassigned extension + return { + preferredType: 'user', + }; + case 'preferrably': + // Will only skip sip for users that don't have an assigned extension (or not call at all if `requireExtensions` is true) + return { + preferredType: 'sip', + }; + } + + return {}; + } + private async processRequestCallSignal(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise { - logger.debug({ msg: 'GlobalSignalProcessor.processNewCallSignal', signal, uid }); + logger.debug({ msg: 'GlobalSignalProcessor.processRequestCallSignal', signal, uid }); - const caller = { type: 'user', id: uid } as const; - const callee = await this.mutateCallee(signal.callee); + // The caller contact should always be from type = 'user' when the call was initiated from rocket.chat + const caller = await MediaCallDirector.cast.getContactForUserId(uid, { requiredType: 'user' }); + if (!caller) { + throw new Error('Failed to load caller contact information'); + } + + // The callee contact type will determine if the call is going to go through SIP or directly to another rocket.chat user + const callee = await MediaCallDirector.cast.getContactForActor(signal.callee, this.getCalleeContactOptions()); + if (!callee) { + throw new Error('Failed to load callee contact information'); + } + + if (this.settings.internalCalls.requireExtensions && !callee.sipExtension) { + throw new Error('Invalid target user'); + } const existingCall = await this.getExistingRequestedCall(uid, signal, callee); @@ -169,25 +166,17 @@ export abstract class GlobalSignalProcessor { const services = signal.supportedServices ?? []; const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); - try { - await this.createCall({ - caller: { - ...caller, - contractId: signal.contractId, - }, - callee: await this.mutateCallee(signal.callee), - requestedCallId: signal.callId, - ...(requestedService && { requestedService }), - }); - } catch (e) { - this.sendSignal(uid, { - type: 'rejected-call-request', - callId: signal.callId, - toContractId: signal.contractId, - reason: 'unsupported', - }); - throw e; - } + const params: InternalCallParams = { + caller: { + ...caller, + contractId: signal.contractId, + }, + callee, + requestedCallId: signal.callId, + ...(requestedService && { requestedService }), + }; + + this.emitter.emit('callRequest', { fromUid: uid, params }); } private async getExistingRequestedCall( diff --git a/ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts new file mode 100644 index 0000000000000..84884c23fb9d4 --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts @@ -0,0 +1,176 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { + CallAnswer, + CallHangupReason, + CallRole, + ClientMediaSignal, + ClientMediaSignalLocalState, + ServerMediaSignal, +} from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; +import { logger } from '../../logger'; +import { MediaCallDirector } from '../../server/CallDirector'; +import { getMediaCallServer } from '../../server/injection'; + +export abstract class UserActorSignalProcessor { + public get contractId(): string { + return this.channel.contractId; + } + + public get callId(): string { + return this.channel.callId; + } + + public get actorId(): string { + return this.channel.actorId; + } + + public get actorType(): MediaCallActorType { + return this.channel.actorType; + } + + public get role(): CallRole { + return this.channel.role; + } + + public get actor(): MediaCallActor { + return { + type: this.actorType, + id: this.actorId, + contractId: this.contractId, + }; + } + + public readonly signed: boolean; + + public readonly ignored: boolean; + + constructor( + protected readonly agent: IMediaCallAgent, + protected readonly call: IMediaCall, + protected readonly channel: IMediaCallChannel, + ) { + const actor = call[channel.role]; + + this.signed = Boolean(actor.contractId && actor.contractId === channel.contractId); + this.ignored = Boolean(actor.contractId && actor.contractId !== channel.contractId); + } + + public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.setRemoteDescription', sdp }); + await this.sendSignal({ + callId: this.callId, + toContractId: this.contractId, + type: 'remote-sdp', + sdp, + }); + + try { + await MediaCallChannels.setRemoteDescription(this.channel._id, sdp); + } catch (error) { + logger.error('Failed to save remote SDP on call channel.'); + } + } + + public async getLocalDescription(): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.getRemoteDescription' }); + + const channel = await MediaCallChannels.findOneById(this.channel._id); + return channel?.localDescription ?? null; + } + + public async requestWebRTCOffer(params: { iceRestart?: boolean }): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.requestOffer', actor: this.actor }); + + await this.sendSignal({ + callId: this.callId, + toContractId: this.contractId, + type: 'request-offer', + ...params, + }); + } + + public async processSignal(signal: ClientMediaSignal): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal }); + + // The code will only reach this point if one of the three following conditions are true: + // 1. the signal came from the exact user session where the caller initiated the call + // 2. the signal came from the exact user session where the callee accepted the call + // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee + switch (signal.type) { + case 'local-sdp': + return this.saveLocalDescription(signal.sdp); + case 'answer': + return this.processAnswer(signal.answer); + case 'hangup': + return this.hangup(signal.reason); + case 'local-state': + return this.reviewLocalState(signal); + } + } + + protected async hangup(reason: CallHangupReason): Promise { + return MediaCallDirector.hangup(this.call, this.agent, reason); + } + + protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp }); + + await MediaCallChannels.setLocalDescription(this.channel._id, sdp); + await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp); + } + + private async processAnswer(answer: CallAnswer): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.processAnswer', answer }); + + switch (answer) { + case 'ack': + return this.clientIsReachable(); + case 'accept': + return this.clientHasAccepted(); + case 'unavailable': + return this.clientIsUnavailable(); + case 'reject': + return this.clientHasRejected(); + } + } + + protected abstract clientIsReachable(): Promise; + + protected abstract clientHasRejected(): Promise; + + protected abstract clientHasAccepted(): Promise; + + protected abstract clientIsUnavailable(): Promise; + + protected async clientIsActive(): Promise { + const result = await MediaCallChannels.setActiveById(this.channel._id); + if (result.modifiedCount) { + await MediaCallDirector.activate(this.call, this.agent); + } + } + + protected async sendSignal(signal: ServerMediaSignal): Promise { + getMediaCallServer().sendSignal(this.actorId, signal); + } + + protected isCallPending(): boolean { + return ['none', 'ringing'].includes(this.call.state); + } + + private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise { + if (!this.signed) { + return; + } + + if (signal.clientState === 'active') { + if (this.channel.state === 'active' || this.channel.activeAt) { + return; + } + + await this.clientIsActive(); + } + } +} diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts new file mode 100644 index 0000000000000..32f8b33f90f70 --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts @@ -0,0 +1,68 @@ +import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, ServerMediaSignal, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; + +import { BaseMediaCallAgent } from '../../base/BaseAgent'; +import { logger } from '../../logger'; +import { getMediaCallServer } from '../../server/injection'; + +export abstract class UserActorAgent extends BaseMediaCallAgent { + public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { + const channel = await this.getOrCreateChannel(call, signal.contractId, { acknowledged: true }); + + return this.doProcessSignal(call, channel, signal); + } + + public async sendSignal(signal: ServerMediaSignal): Promise { + getMediaCallServer().sendSignal(this.actorId, signal); + } + + public async onCallAccepted(callId: string, signedContractId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onCallAccepted', callId }); + + return this.sendSignal({ + callId, + type: 'notification', + notification: 'accepted', + signedContractId, + }); + } + + public async onCallEnded(callId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onCallEnded', callId }); + + return this.sendSignal({ + callId, + type: 'notification', + notification: 'hangup', + }); + } + + public async onCallActive(callId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onCallActive', callId }); + + return this.sendSignal({ + callId, + type: 'notification', + notification: 'active', + }); + } + + public async onCallCreated(call: IMediaCall): Promise { + logger.debug({ msg: 'UserActorAgent.onCallCreated', call }); + + await this.sendSignal(this.buildNewCallSignal(call)); + } + + protected abstract doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise; + + protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { + return { + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: this.role, + contact: this.getOtherCallActor(call), + }; + } +} diff --git a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts new file mode 100644 index 0000000000000..7599793680e9c --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts @@ -0,0 +1,39 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; +import { MediaCallChannels } from '@rocket.chat/models'; + +import { UserActorAgent } from './BaseUserAgent'; +import { UserActorCalleeSignalProcessor } from './CalleeSignalProcessor'; + +export class UserActorCalleeAgent extends UserActorAgent { + constructor(contact: MediaCallContact) { + super(contact, 'callee'); + } + + protected doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise { + const signalProcessor = new UserActorCalleeSignalProcessor(this, call, channel); + return signalProcessor.processSignal(signal); + } + + public async onCallAccepted(callId: string, signedContractId: string): Promise { + await super.onCallAccepted(callId, signedContractId); + + const channel = await MediaCallChannels.findOneByCallIdAndSignedActor({ callId, ...this.actor, contractId: signedContractId }); + if (!channel) { + return; + } + + if (channel.remoteDescription) { + await this.sendSignal({ + callId, + toContractId: signedContractId, + type: 'remote-sdp', + sdp: channel.remoteDescription, + }); + } + } + + public async onWebrtcAnswer(_callId: string): Promise { + // The callee doesn't need to do anything when the answer is ready + } +} diff --git a/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts new file mode 100644 index 0000000000000..92cc7e1de9da8 --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts @@ -0,0 +1,51 @@ +import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; + +import { UserActorSignalProcessor } from './BaseSignalProcessor'; +import { logger } from '../../logger'; +import { MediaCallDirector } from '../../server/CallDirector'; + +export class UserActorCalleeSignalProcessor extends UserActorSignalProcessor { + protected async clientIsReachable(): Promise { + logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientIsReachable' }); + if (this.call.state !== 'none') { + return; + } + + // Change the call state from 'none' to 'ringing' when any callee session is found + await MediaCalls.startRingingById(this.call._id, MediaCallDirector.getNewExpirationTime()); + } + + protected async clientHasRejected(): Promise { + logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientHasRejected' }); + + if (!this.isCallPending()) { + return; + } + + return MediaCallDirector.hangup(this.call, this.agent, 'rejected'); + } + + protected async clientIsUnavailable(): Promise { + logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientIsUnavailable' }); + + // We don't do anything on unavailable responses from clients, as a different client may still answer + } + + protected async clientHasAccepted(): Promise { + logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientHasAccepted' }); + + if (!this.isCallPending()) { + return; + } + + await MediaCallDirector.acceptCall(this.call, this.agent, this.contractId); + } + + protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'UserActorCalleeSignalProcessor.saveLocalDescription', sdp }); + + await MediaCallChannels.setLocalDescription(this.channel._id, sdp); + + await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp); + } +} diff --git a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts new file mode 100644 index 0000000000000..911ccbbb2fcd7 --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts @@ -0,0 +1,56 @@ +import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; +import type { ClientMediaSignal, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { UserActorAgent } from './BaseUserAgent'; +import { UserActorCallerSignalProcessor } from './CallerSignalProcessor'; +import { logger } from '../../logger'; + +export class UserActorCallerAgent extends UserActorAgent { + constructor(contact: MediaCallContact) { + super(contact, 'caller'); + } + + public async onCallCreated(call: IMediaCall): Promise { + logger.debug({ msg: 'UserActorCallerAgent.onCallCreated', call }); + const { caller } = call; + + if (!caller.contractId) { + throw new Error('error-invalid-contract'); + } + + // Pre-create the channel for the contractId that requested the call + await this.getOrCreateChannel(call, caller.contractId); + + return super.onCallCreated(call); + } + + protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { + return { + ...super.buildNewCallSignal(call), + // Send back the caller requested Id so the client can match this call to its request + ...(call.callerRequestedId && { requestedCallId: call.callerRequestedId }), + }; + } + + protected doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise { + const signalProcessor = new UserActorCallerSignalProcessor(this, call, channel); + return signalProcessor.processSignal(signal); + } + + public async onWebrtcAnswer(callId: string): Promise { + const call = await MediaCalls.findOneById(callId); + + if (call?.state !== 'accepted' || !call.webrtcAnswer) { + logger.error({ msg: 'Invalid call state', call }); + return; + } + + await this.sendSignal({ + callId, + toContractId: call.caller.contractId, + type: 'remote-sdp', + sdp: call.webrtcAnswer, + }); + } +} diff --git a/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts new file mode 100644 index 0000000000000..5de20e8521f4f --- /dev/null +++ b/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts @@ -0,0 +1,34 @@ +import { UserActorSignalProcessor } from './BaseSignalProcessor'; +import { logger } from '../../logger'; + +export class UserActorCallerSignalProcessor extends UserActorSignalProcessor { + protected async clientIsReachable(): Promise { + logger.debug({ msg: 'UserActorCallerAgent.clientIsReachable' }); + + // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state + if (!this.signed) { + return; + } + + // When the signed caller's client is reached, we immediatelly send the first offer request + await this.requestWebRTCOffer({ iceRestart: false }); + } + + protected async clientHasRejected(): Promise { + logger.debug({ msg: 'UserActorCallerAgent.clientHasRejected' }); + + // Ignore rejections coming from the caller's client + } + + protected async clientIsUnavailable(): Promise { + logger.debug({ msg: 'UserActorCallerAgent.clientIsUnavailable' }); + + // Ignore unavailable caller clients + } + + protected async clientHasAccepted(): Promise { + logger.debug({ msg: 'UserActorCallerAgent.clientHasAccepted' }); + + // The caller has nothing to accept + } +} diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts new file mode 100644 index 0000000000000..7eb5371b8745a --- /dev/null +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -0,0 +1,285 @@ +import type { AtLeast, IMediaCall, MediaCallActor, MediaCallSignedActor, ServerActor } from '@rocket.chat/core-typings'; +import type { CallHangupReason, CallService } from '@rocket.chat/media-signaling'; +import { MediaCalls } from '@rocket.chat/models'; + +import { getCastDirector } from './injection'; +import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; +import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; +import { logger } from '../logger'; + +const EXPIRATION_TIME = 120000; +const EXPIRATION_CHECK_DELAY = 1000; + +export type CreateCallParams = { + caller: MediaCallSignedActor; + callee: MediaCallActor; + requestedCallId?: string; + requestedService?: CallService; + + callerAgent: IMediaCallAgent; + calleeAgent: IMediaCallAgent; + + webrtcOffer?: RTCSessionDescriptionInit; +}; + +export class MediaCallDirector { + public static async hangup(call: IMediaCall, actorAgent: IMediaCallAgent, reason: CallHangupReason): Promise { + const { actor: endedBy } = actorAgent; + logger.debug({ msg: 'MediaCallDirector.hangup', callId: call._id, reason, endedBy }); + + const modified = await this.hangupCallById(call._id, { endedBy, reason }); + if (modified) { + await actorAgent.onCallEnded(call._id); + await actorAgent.oppositeAgent?.onCallEnded(call._id); + } + } + + public static async hangupByServer(call: AtLeast, serverErrorCode: string): Promise { + logger.debug({ msg: 'MediaCallDirector.hangupByServer', callId: call._id, serverErrorCode }); + + const endedBy = { type: 'server', id: 'server' } as ServerActor; + + const modified = await this.hangupCallById(call._id, { endedBy, reason: serverErrorCode }); + if (!modified) { + return false; + } + + // Try to notify the agents but there's no guarantee they are reachable + try { + const agents = await this.cast.getAgentsFromCall(call); + for (const agent of Object.values(agents)) { + agent?.onCallEnded(call._id).catch(() => null); + } + } catch { + // Ignore errors on the ended event + } + + return modified; + } + + public static async activate(call: IMediaCall, actorAgent: IMediaCallAgent): Promise { + logger.debug({ msg: 'MediaCallDirector.activateCall' }); + + const stateResult = await MediaCalls.activateCallById(call._id, this.getNewExpirationTime()); + if (!stateResult.modifiedCount) { + return; + } + + return actorAgent.oppositeAgent?.onCallActive(call._id); + } + + public static async acceptCall(call: IMediaCall, calleeAgent: IMediaCallAgent, contractId: string): Promise { + logger.debug({ msg: 'MediaCallDirector.acceptCall' }); + + const stateResult = await MediaCalls.acceptCallById(call._id, contractId, this.getNewExpirationTime()); + // If nothing changed, the call was no longer ringing + if (!stateResult.modifiedCount) { + return; + } + + await calleeAgent.onCallAccepted(call._id, contractId); + await calleeAgent.oppositeAgent?.onCallAccepted(call._id, call.caller.contractId); + } + + public static get cast(): IMediaCallCastDirector { + try { + return getCastDirector(); + } catch (error) { + logger.error({ msg: 'Failed to access castDirector', error }); + throw error; + } + } + + public static async saveWebrtcSession(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + if (sdp.type === 'offer') { + if (fromAgent.role !== 'caller') { + throw new Error('invalid-sdp'); + } + + return this.saveWebrtcOffer(call, fromAgent, sdp); + } + + if (fromAgent.role !== 'callee') { + throw new Error('invalid-sdp'); + } + + return this.saveWebrtcAnswer(call, fromAgent, sdp); + } + + private static async saveWebrtcOffer(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'MediaCallDirector.saveWebrtcOffer', callId: call?._id }); + const result = await MediaCalls.setWebrtcOfferById(call._id, sdp, this.getNewExpirationTime()); + if (!result.modifiedCount) { + return; + } + + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); + } + + private static async saveWebrtcAnswer(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'MediaCallDirector.saveWebrtcAnswer', callId: call?._id }); + const result = await MediaCalls.setWebrtcAnswerById(call._id, sdp, this.getNewExpirationTime()); + if (!result.modifiedCount) { + return; + } + + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); + + fromAgent.onWebrtcAnswer(call._id); + await fromAgent.oppositeAgent?.onWebrtcAnswer(call._id); + } + + public static async createCall(params: CreateCallParams): Promise { + logger.debug({ msg: 'MediaCallDirector.createCall', params }); + const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, webrtcOffer } = params; + + // The caller must always have a contract to create the call + if (!caller.contractId) { + throw new Error('invalid-caller'); + } + + const service = requestedService || 'webrtc'; + + // webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call + if (service !== 'webrtc') { + throw new Error('invalid-call-service'); + } + + if (!callerAgent) { + throw new Error('invalid-caller'); + } + + if (!calleeAgent) { + throw new Error('invalid-callee'); + } + + callerAgent.oppositeAgent = calleeAgent; + calleeAgent.oppositeAgent = callerAgent; + + const call: Omit = { + service, + kind: 'direct', + state: 'none', + + createdBy: caller, + createdAt: new Date(), + + caller, + callee, + + expiresAt: MediaCallDirector.getNewExpirationTime(), + + ...(requestedCallId && { callerRequestedId: requestedCallId }), + ...(webrtcOffer && { webrtcOffer }), + }; + + logger.debug({ msg: 'creating call', call }); + + const insertResult = await MediaCalls.insertOne(call); + if (!insertResult.insertedId) { + throw new Error('failed-to-create-call'); + } + + const newCall = await MediaCalls.findOneById(insertResult.insertedId); + if (!newCall) { + throw new Error('failed-to-retrieve-call'); + } + + return newCall; + } + + public static async hangupExpiredCalls(): Promise { + logger.debug('MediaCallDirector.hangupExpiredCalls'); + + const result = MediaCalls.findAllExpiredCalls>({ + projection: { _id: 1, caller: 1, callee: 1 }, + }); + + for await (const call of result) { + await this.hangupByServer(call, 'timeout'); + } + } + + public static getNewExpirationTime(): Date { + return new Date(Date.now() + EXPIRATION_TIME); + } + + public static async renewCallId(callId: string): Promise { + await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime()); + this.scheduleExpirationCheck(); + } + + public static scheduleExpirationCheck(): void { + setTimeout( + () => this.hangupExpiredCalls().catch((error) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error })), + EXPIRATION_TIME + EXPIRATION_CHECK_DELAY, + ); + } + + public static async runOnCallCreatedForAgent( + call: IMediaCall, + agent: IMediaCallAgent, + agentToNotifyIfItFails?: IMediaCallAgent, + ): Promise { + try { + await agent.onCallCreated(call); + } catch (error) { + // If the agent failed, we assume they cleaned up after themselves and just hangup the call + // We then notify the other agent that the call has ended, but only if it the agent was already notified about this call in the first place + logger.error({ + msg: 'Agent failed to process a new call.', + error, + agentRole: agent.role, + callerType: call.caller.type, + calleeType: call.callee.type, + }); + await this.hangupCallByIdAndNotifyAgents(call._id, agentToNotifyIfItFails ? [agentToNotifyIfItFails] : [], { + endedBy: agent.getMyCallActor(call), + reason: 'error', + }); + throw error; + } + } + + public static async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { + // Ensure we don't pass along anything more than the three basic actor attributes + const endedBy = + params?.endedBy?.type === 'server' + ? { type: params.endedBy.type, id: params.endedBy.id } + : params?.endedBy && { type: params.endedBy.type, id: params.endedBy.id, contractId: params.endedBy.contractId }; + + const cleanedParams = params && { + ...params, + ...(endedBy && { endedBy }), + }; + + const result = await MediaCalls.hangupCallById(callId, cleanedParams).catch((error) => { + logger.error({ + msg: 'Failed to hangup a call.', + callId, + hangupError: error, + hangupReason: params?.reason, + hangupActor: params?.endedBy, + }); + throw error; + }); + return Boolean(result.modifiedCount); + } + + private static async hangupCallByIdAndNotifyAgents( + callId: string, + agents: IMediaCallAgent[], + params?: { endedBy?: IMediaCall['endedBy']; reason?: string }, + ): Promise { + const modified = await this.hangupCallById(callId, params).catch(() => false); + if (!modified || !agents?.length) { + return; + } + + await Promise.allSettled( + agents.map(async (agent) => + agent.onCallEnded(callId).catch((error) => logger.error({ msg: 'Failed to notify agent of a hangup', error, actor: agent.actor })), + ), + ); + } +} diff --git a/ee/packages/media-calls/src/server/CastDirector.ts b/ee/packages/media-calls/src/server/CastDirector.ts new file mode 100644 index 0000000000000..65321debfa9d6 --- /dev/null +++ b/ee/packages/media-calls/src/server/CastDirector.ts @@ -0,0 +1,194 @@ +import type { IUser, MediaCallActor, MediaCallActorType, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings'; +import type { CallRole } from '@rocket.chat/media-signaling'; +import { Users } from '@rocket.chat/models'; + +import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; +import type { IMediaCallCastDirector, MediaCallHeader } from '../definition/IMediaCallCastDirector'; +import type { GetActorContactOptions, MinimalUserData } from '../definition/common'; +import type { UserActorAgent } from '../internal/agents/BaseUserAgent'; +import { UserActorCalleeAgent } from '../internal/agents/CalleeAgent'; +import { UserActorCallerAgent } from '../internal/agents/CallerAgent'; +import { logger } from '../logger'; + +type ContactList = Record; + +export class MediaCallCastDirector implements IMediaCallCastDirector { + public async getAgentsFromCall(call: MediaCallHeader): Promise<{ caller: IMediaCallAgent; callee: IMediaCallAgent }> { + logger.debug({ msg: 'MediaCallCastDirector.getAgentsFromCall', callId: call?._id }); + + const callerAgent = await this.getAgentFromCall(call, 'caller'); + if (!callerAgent) { + throw new Error('Unable to find caller agent'); + } + + const calleeAgent = await this.getAgentFromCall(call, 'callee'); + if (!calleeAgent) { + throw new Error('Unable to find callee agent'); + } + + callerAgent.oppositeAgent = calleeAgent; + calleeAgent.oppositeAgent = callerAgent; + + return { + caller: callerAgent, + callee: calleeAgent, + }; + } + + public async getAgentFromCall(call: MediaCallHeader, role: CallRole): Promise { + logger.debug({ msg: 'MediaCallCastDirector.getAgentFromCall', callId: call?._id, role }); + + const { [role]: actor } = call; + + return this.getAgentForActorAndRole(actor, role); + } + + public async getContactForActor( + actor: MediaCallActor, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise { + logger.debug({ msg: 'MediaCallCastDirector.getContactForActor', actor, options, defaultContactInfo }); + + if (actor.type === 'user') { + return this.getContactForUserId(actor.id, options, defaultContactInfo); + } + + if (actor.type === 'sip') { + return this.getContactForExtensionNumber(actor.id, options, defaultContactInfo); + } + + return null; + } + + public getContactForUser( + user: MinimalUserData, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): MediaCallContact | null { + logger.debug({ msg: 'MediaCallCastDirector.getContactForUser', user, options, defaultContactInfo }); + + const actors = this.buildContactListForUser(user, defaultContactInfo); + return this.getContactFromList(actors, options); + } + + public async getContactForUserId( + userId: string, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise { + logger.debug({ msg: 'MediaCallCastDirector.getContactForUserId', userId, options, defaultContactInfo }); + + const user = await Users.findOneById>(userId, { + projection: { name: 1, username: 1, freeSwitchExtension: 1 }, + }); + if (!user) { + throw new Error('invalid-callee'); + } + + return this.getContactForUser(user, options, defaultContactInfo); + } + + public async getContactForExtensionNumber( + sipExtension: string, + options: GetActorContactOptions, + defaultContactInfo?: MediaCallContactInformation, + ): Promise { + logger.debug({ msg: 'MediaCallCastDirector.getContactForExtensionNumber', sipExtension, options, defaultContactInfo }); + + const user = await Users.findOneByFreeSwitchExtension>(sipExtension, { + projection: { name: 1, username: 1, freeSwitchExtension: 1 }, + }); + + const list = user + ? this.buildContactListForUser(user, defaultContactInfo) + : this.buildContactListForExtension(sipExtension, defaultContactInfo); + + return this.getContactFromList(list, options); + } + + public async getAgentForActorAndRole(actor: MediaCallContact, role: CallRole): Promise { + logger.debug({ msg: 'MediaCallCastDirector.getAgentForActorAndRole', actor, role }); + + if (actor.type === 'user') { + return this.getAgentForUserActorAndRole(actor, role); + } + + if (actor.type === 'sip') { + return this.getAgentForSipActorAndRole(actor, role); + } + + return null; + } + + protected async getAgentForUserActorAndRole(actor: MediaCallContact, role: CallRole): Promise { + if (role === 'caller') { + return new UserActorCallerAgent(actor); + } + + if (role === 'callee') { + return new UserActorCalleeAgent(actor); + } + + return null; + } + + protected async getAgentForSipActorAndRole(_actor: MediaCallContact, _role: CallRole): Promise { + // #TODO: SIP Agents + + return null; + } + + protected buildContactListForUser(user: MinimalUserData, defaultContactInfo?: MediaCallContactInformation): ContactList { + const { name: displayName, username, freeSwitchExtension: sipExtension, _id: id } = user; + + const data: Partial = { + ...defaultContactInfo, + ...(displayName && { displayName }), + ...(username && { username }), + ...(sipExtension && { sipExtension }), + }; + + return { + user: { + ...data, + type: 'user', + id, + }, + sip: sipExtension + ? { + ...data, + type: 'sip', + id: sipExtension, + } + : null, + }; + } + + protected buildContactListForExtension(sipExtension: string, defaultContactInfo?: MediaCallContactInformation): ContactList { + const data: Partial = { + ...defaultContactInfo, + ...(sipExtension && { sipExtension }), + }; + + return { + user: null, + sip: { + ...data, + type: 'sip', + id: sipExtension, + }, + }; + } + + protected getContactFromList(list: ContactList, options: GetActorContactOptions): MediaCallContact | null { + logger.debug({ msg: 'MediaCallCastDirector.getContactFromList', list, options }); + + if (options.requiredType) { + return list[options.requiredType] ?? null; + } + + const preferredActor = options.preferredType && list[options.preferredType]; + return preferredActor || list.user || list.sip || null; + } +} diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts new file mode 100644 index 0000000000000..8ea6398999f40 --- /dev/null +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -0,0 +1,91 @@ +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { isClientMediaSignal } from '@rocket.chat/media-signaling'; +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; + +import { MediaCallDirector } from './CallDirector'; +import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; +import type { InternalCallParams } from '../definition/common'; +import { InternalCallProvider } from '../internal/InternalCallProvider'; +import { GlobalSignalProcessor } from '../internal/SignalProcessor'; +import { logger } from '../logger'; + +/** + * Class used as gateway to send and receive signals to/from clients + * The actual function used to send the signals needs to be set by the server + */ +export class MediaCallServer implements IMediaCallServer { + private signalProcessor: GlobalSignalProcessor; + + public emitter: Emitter; + + constructor() { + this.emitter = new Emitter(); + this.signalProcessor = new GlobalSignalProcessor(); + + this.signalProcessor.emitter.on('signalRequest', ({ toUid, signal }) => { + this.sendSignal(toUid, signal); + }); + this.signalProcessor.emitter.on('callRequest', ({ fromUid, params }) => { + this.onCallRequest(fromUid, params); + }); + } + + public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { + logger.debug({ msg: 'MediaCallServer.receiveSignal', signal, fromUid }); + + if (!isClientMediaSignal(signal)) { + logger.error({ msg: 'The Media Signal Server received an invalid client signal object' }); + throw new Error('invalid-signal'); + } + + this.signalProcessor.processSignal(fromUid, signal).catch((error) => { + logger.error({ msg: 'Failed to process client signal', error, type: signal.type }); + }); + } + + public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { + logger.debug({ msg: 'MediaCallServer.sendSignal', toUid, signal }); + + this.emitter.emit('signalRequest', { toUid, signal }); + } + + public async createCall(params: InternalCallParams): Promise { + logger.debug({ msg: 'MediaCallServer.createCall', params }); + + if (params.callee.type === 'sip') { + throw new Error('Outgoing SIP calls are not yet implemented.'); + } + + return InternalCallProvider.createCall(params); + } + + public async hangupExpiredCalls(): Promise { + return MediaCallDirector.hangupExpiredCalls(); + } + + public scheduleExpirationCheck(): void { + return MediaCallDirector.scheduleExpirationCheck(); + } + + public configure(settings: IMediaCallServerSettings): void { + logger.debug({ msg: 'Media Server Configuration', settings }); + this.signalProcessor.configure(settings); + } + + private onCallRequest(fromUid: IUser['_id'], params: InternalCallParams): void { + this.createCall(params).catch((error) => { + logger.error({ msg: 'Failed to create a call requested by a signal', fromUid, params, error }); + + if (params.requestedCallId) { + this.sendSignal(fromUid, { + type: 'rejected-call-request', + callId: params.requestedCallId, + toContractId: params.caller.contractId, + reason: 'unsupported', + }); + } + throw error; + }); + } +} diff --git a/ee/packages/media-calls/src/server/configuration.ts b/ee/packages/media-calls/src/server/configuration.ts new file mode 100644 index 0000000000000..121872c82755b --- /dev/null +++ b/ee/packages/media-calls/src/server/configuration.ts @@ -0,0 +1,9 @@ +import { MediaCallCastDirector } from './CastDirector'; +import { MediaCallServer } from './MediaCallServer'; +import { setCastDirector, setMediaCallServer } from './injection'; + +export const castDirector = new MediaCallCastDirector(); +export const callServer = new MediaCallServer(); + +setCastDirector(castDirector); +setMediaCallServer(callServer); diff --git a/ee/packages/media-calls/src/server/getDefaultSettings.ts b/ee/packages/media-calls/src/server/getDefaultSettings.ts new file mode 100644 index 0000000000000..2824b2425bb1d --- /dev/null +++ b/ee/packages/media-calls/src/server/getDefaultSettings.ts @@ -0,0 +1,14 @@ +import type { IMediaCallServerSettings } from '../definition/IMediaCallServer'; + +export function getDefaultSettings(): IMediaCallServerSettings { + return { + enabled: false, + internalCalls: { + requireExtensions: false, + routeExternally: 'never', + }, + sip: { + enabled: false, + }, + }; +} diff --git a/ee/packages/media-calls/src/server/injection.ts b/ee/packages/media-calls/src/server/injection.ts new file mode 100644 index 0000000000000..8b8300dda7ed0 --- /dev/null +++ b/ee/packages/media-calls/src/server/injection.ts @@ -0,0 +1,32 @@ +import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; +import type { IMediaCallServer } from '../definition/IMediaCallServer'; +import { logger } from '../logger'; + +let castDirectorInstance: IMediaCallCastDirector | null = null; +let mediaCallServerInstance: IMediaCallServer | null = null; + +export function setCastDirector(director: IMediaCallCastDirector): void { + castDirectorInstance = director; +} + +export function getCastDirector(): IMediaCallCastDirector { + if (!castDirectorInstance) { + logger.error({ msg: 'cast director instance was not set.', stack: new Error().stack }); + throw new Error('cast director instance was not set.'); + } + + return castDirectorInstance; +} + +export function setMediaCallServer(server: IMediaCallServer): void { + mediaCallServerInstance = server; +} + +export function getMediaCallServer(): IMediaCallServer { + if (!mediaCallServerInstance) { + logger.error({ msg: 'media call server instance was not set.', stack: new Error().stack }); + throw new Error('media call server instance was not set.'); + } + + return mediaCallServerInstance; +} diff --git a/packages/core-services/src/types/IMediaCallService.ts b/packages/core-services/src/types/IMediaCallService.ts index dfa33cd58edd8..a00e76a034d82 100644 --- a/packages/core-services/src/types/IMediaCallService.ts +++ b/packages/core-services/src/types/IMediaCallService.ts @@ -1,11 +1,8 @@ -import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; export interface IMediaCallService { processSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): Promise; processSerializedSignal(fromUid: IUser['_id'], signal: string): Promise; - createInternalCall(caller: { uid: IUser['_id']; contractId: string }, callee: { uid: IUser['_id'] }): Promise; - callExtension(caller: { uid: IUser['_id']; contractId: string }, extension: string): Promise; - callUser(caller: { uid: IUser['_id']; contractId: string }, userId: IUser['_id']): Promise; hangupExpiredCalls(): Promise; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 942e8a8fb9464..665ac859e7ab2 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -24,7 +24,11 @@ export type MediaCallContactInformation = { export type MediaCallContact = T & MediaCallContactInformation; -export type MediaCallSignedContact = MediaCallContact; +export type TypedMediaCallContact = MediaCallContact>; + +export type MediaCallSignedContact = MediaCallContact< + MediaCallSignedActor> +>; export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; @@ -45,4 +49,7 @@ export interface IMediaCall extends IRocketChatRecord { expiresAt: Date; callerRequestedId?: string; + + webrtcOffer?: RTCSessionDescriptionInit; + webrtcAnswer?: RTCSessionDescriptionInit; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index bb2e5daa4f842..cdf939df1d788 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -24,6 +24,7 @@ export interface IMediaCallChannel extends IRocketChatRecord { leftAt?: Date; localDescription?: RTCSessionDescriptionInit; + remoteDescription?: RTCSessionDescriptionInit; // for rocket.chat users, acknowledged means that a client user session was reached by the signaling acknowledged: boolean; diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index 1cd5a2c2b85b1..bdfbac9f28c0f 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -1,12 +1,17 @@ -import type { IMediaCallChannel, MediaCallActor } from '@rocket.chat/core-typings'; -import type { UpdateResult } from 'mongodb'; +import type { IMediaCallChannel, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { Document, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel, InsertionModel } from './IBaseModel'; export interface IMediaCallChannelsModel extends IBaseModel { createOrUpdateChannel(channel: InsertionModel): Promise; - findOneByCallIdAndActor(callId: string, actor: Required): Promise; + findOneByCallIdAndSignedActor( + params: MediaCallSignedActor & { callId: string }, + options?: FindOptions, + ): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; setActiveById(_id: string): Promise; setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise; + setRemoteDescription(_id: string, remoteDescription: RTCSessionDescriptionInit): Promise; + setRemoteDescriptionByCallIdAndActor(callId: string, actor: MediaCallActor, remoteDescription: RTCSessionDescriptionInit): Promise; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 93ec221bc9202..39576891ef647 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -16,4 +16,6 @@ export interface IMediaCallsModel extends IBaseModel { hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; hasUnfinishedCalls(): Promise; + setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; + setWebrtcAnswerById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index 96f291a13661e..664e72f6df076 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -1,6 +1,6 @@ -import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallSignedActor, MediaCallActor } from '@rocket.chat/core-typings'; import type { IMediaCallChannelsModel, InsertionModel } from '@rocket.chat/model-typings'; -import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; +import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult, FindOptions, Document } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -18,13 +18,20 @@ export class MediaCallChannelsRaw extends BaseRaw implements ]; } - public async findOneByCallIdAndActor(callId: string, actor: Required): Promise { - return this.findOne({ - callId, - actorType: actor.type, - actorId: actor.id, - contractId: actor.contractId, - }); + public findOneByCallIdAndSignedActor( + params: MediaCallSignedActor & { callId: string }, + options?: FindOptions, + ): Promise { + const { callId, type: actorType, id: actorId, contractId } = params; + return this.findOne( + { + callId, + actorType, + actorId, + contractId, + }, + options, + ); } public updateOneById( @@ -75,4 +82,26 @@ export class MediaCallChannelsRaw extends BaseRaw implements public async setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise { return this.updateOneById(_id, { $set: { localDescription } }); } + + public async setRemoteDescription(_id: string, remoteDescription: RTCSessionDescriptionInit): Promise { + return this.updateOneById(_id, { $set: { remoteDescription } }); + } + + public async setRemoteDescriptionByCallIdAndActor( + callId: string, + actor: MediaCallActor, + remoteDescription: RTCSessionDescriptionInit, + ): Promise { + await this.updateMany( + { + callId, + actorId: actor.id, + actorType: actor.type, + ...(actor.contractId && { contractId: actor.contractId }), + }, + { + $set: { remoteDescription }, + }, + ); + } } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index cdbb25e256b6c..36dd442d33304 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -23,7 +23,10 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return [ { key: { createdAt: 1 }, unique: false }, { key: { state: 1, expiresAt: 1 }, unique: false }, - { key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 }, unique: true, sparse: true }, + { + key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 }, + sparse: true, + }, ]; } @@ -138,4 +141,28 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod const count = await this.countDocuments({ state: { $ne: 'hangup' } }, { limit: 1 }); return count > 0; } + + public async setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise { + return this.updateOne( + { + _id: callId, + webrtcOffer: { $exists: false }, + }, + { + $set: { webrtcOffer: offer, expiresAt }, + }, + ); + } + + public async setWebrtcAnswerById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise { + return this.updateOne( + { + _id: callId, + webrtcAnswer: { $exists: false }, + }, + { + $set: { webrtcAnswer: offer, expiresAt }, + }, + ); + } } diff --git a/yarn.lock b/yarn.lock index 13df491824a39..76fc5cd7b0543 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7815,6 +7815,7 @@ __metadata: resolution: "@rocket.chat/media-calls@workspace:ee/packages/media-calls" dependencies: "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/emitter": "npm:~0.31.25" "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/logger": "workspace:^" "@rocket.chat/media-signaling": "workspace:^" From c71572db8665230a2c88a6e3da879e1ddd348dab Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 27 Aug 2025 15:33:57 -0300 Subject: [PATCH 42/72] ice configuration --- .../src/definition/services/webrtc/IWebRTCProcessor.ts | 2 ++ packages/media-signaling/src/lib/Call.ts | 5 ++++- .../media-signaling/src/lib/services/webrtc/Processor.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index f6e9b1bbba3d1..fb8216b87d0c4 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -26,7 +26,9 @@ export interface IWebRTCProcessor extends IServiceProcessor IWebRTCProcessor; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 96b8f434d409a..fb9359075f5f7 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -29,6 +29,8 @@ export interface IClientMediaCallConfig { transporter: MediaSignalTransportWrapper; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; + + iceGatheringTimeout?: number; } const TIMEOUT_TO_ACCEPT = 30000; @@ -870,13 +872,14 @@ export class ClientMediaCall implements IClientMediaCall { mediaStreamFactory, logger, processorFactories: { webrtc: webrtcFactory }, + iceGatheringTimeout = 500, } = this.config; if (!webrtcFactory) { this.throwError('webrtc-not-implemented'); } - this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger }); + this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger, iceGatheringTimeout }); this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index b58c7690bae77..81f9b404ed25b 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -41,7 +41,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.localStream = new LocalStream(this.localMediaStream); this.remoteStream = new RemoteStream(this.remoteMediaStream); - this.peer = new RTCPeerConnection(); + this.peer = new RTCPeerConnection(config.rtc); this.emitter = new Emitter(); this.registerPeerEvents(); } @@ -162,7 +162,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.iceGatheringTimedOut = true; this.changeInternalState('iceUntrickler'); } - }, 500); + }, this.config.iceGatheringTimeout); this.iceGatheringWaiters.add(iceGatheringData); this.changeInternalState('iceUntrickler'); From d0e2279b49ecb3800ee37fbc257735ee71f825aa Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 27 Aug 2025 16:13:27 -0300 Subject: [PATCH 43/72] ice gathering timeout on session --- packages/media-signaling/src/lib/Call.ts | 4 ++-- packages/media-signaling/src/lib/Session.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index fb9359075f5f7..fa1b35685333f 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -30,7 +30,7 @@ export interface IClientMediaCallConfig { processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; - iceGatheringTimeout?: number; + iceGatheringTimeout: number; } const TIMEOUT_TO_ACCEPT = 30000; @@ -872,7 +872,7 @@ export class ClientMediaCall implements IClientMediaCall { mediaStreamFactory, logger, processorFactories: { webrtc: webrtcFactory }, - iceGatheringTimeout = 500, + iceGatheringTimeout, } = this.config; if (!webrtcFactory) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 7020f0d3c5134..ccf0a087c796b 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -31,6 +31,7 @@ export type MediaSignalingSessionConfig = { processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; transport: MediaSignalTransport; + iceGatheringTimeout?: number; }; const STATE_REPORT_INTERVAL = 60000; @@ -154,6 +155,10 @@ export class MediaSignalingSession extends Emitter { await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } + public setIceGatheringTimeout(newTimeout: number): void { + this.config.iceGatheringTimeout = newTimeout; + } + private createTemporaryCallId(): string { this.callCount++; return `${this._sessionId}-${this.callCount}-${Date.now().toString()}`; @@ -240,6 +245,7 @@ export class MediaSignalingSession extends Emitter { transporter: this.transporter, processorFactories: this.config.processorFactories, mediaStreamFactory: this.config.mediaStreamFactory, + iceGatheringTimeout: this.config.iceGatheringTimeout || 500, }; const call = new ClientMediaCall(config, callId); From e42bca5868b96d13425261c36477e58bb5bf1e74 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Wed, 27 Aug 2025 22:25:36 -0300 Subject: [PATCH 44/72] support accepting a call after the webrtc negotiation is complete --- .../src/definition/IMediaCallCastDirector.ts | 6 +- .../media-calls/src/definition/common.ts | 4 +- .../internal/agents/CalleeSignalProcessor.ts | 2 +- .../media-calls/src/server/CallDirector.ts | 62 ++++++++++++------- .../media-calls/src/server/CastDirector.ts | 4 +- .../src/models/IMediaCallsModel.ts | 8 ++- packages/models/src/models/MediaCalls.ts | 26 +++++--- 7 files changed, 72 insertions(+), 40 deletions(-) diff --git a/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts b/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts index a2cdf060497c5..744cb0067e22a 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallCastDirector.ts @@ -1,10 +1,8 @@ -import type { AtLeast, IMediaCall, MediaCallActor, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings'; +import type { MediaCallActor, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings'; import type { CallRole } from '@rocket.chat/media-signaling'; import type { IMediaCallAgent } from './IMediaCallAgent'; -import type { GetActorContactOptions, MinimalUserData } from './common'; - -export type MediaCallHeader = AtLeast; +import type { GetActorContactOptions, MediaCallHeader, MinimalUserData } from './common'; export interface IMediaCallCastDirector { getAgentsFromCall(call: MediaCallHeader): Promise<{ caller: IMediaCallAgent; callee: IMediaCallAgent }>; diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index 5ee36534c8294..9ed167c0e57ad 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -1,4 +1,4 @@ -import type { IUser, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { AtLeast, IMediaCall, IUser, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { CallService } from '@rocket.chat/media-signaling'; export type MinimalUserData = Pick; @@ -14,3 +14,5 @@ export type InternalCallParams = { requestedCallId?: string; requestedService?: CallService; }; + +export type MediaCallHeader = AtLeast; diff --git a/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts index 92cc7e1de9da8..e2357cbb24b8b 100644 --- a/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts @@ -38,7 +38,7 @@ export class UserActorCalleeSignalProcessor extends UserActorSignalProcessor { return; } - await MediaCallDirector.acceptCall(this.call, this.agent, this.contractId); + await MediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId }); } protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 7eb5371b8745a..7845fb529b062 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -5,6 +5,7 @@ import { MediaCalls } from '@rocket.chat/models'; import { getCastDirector } from './injection'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; +import type { MediaCallHeader } from '../definition/common'; import { logger } from '../logger'; const EXPIRATION_TIME = 120000; @@ -34,27 +35,28 @@ export class MediaCallDirector { } } - public static async hangupByServer(call: AtLeast, serverErrorCode: string): Promise { + public static async hangupByServer(call: AtLeast, serverErrorCode: string): Promise { logger.debug({ msg: 'MediaCallDirector.hangupByServer', callId: call._id, serverErrorCode }); + try { + const endedBy = { type: 'server', id: 'server' } as ServerActor; - const endedBy = { type: 'server', id: 'server' } as ServerActor; - - const modified = await this.hangupCallById(call._id, { endedBy, reason: serverErrorCode }); - if (!modified) { - return false; - } + const modified = await this.hangupCallById(call._id, { endedBy, reason: serverErrorCode }); + if (!modified) { + return; + } - // Try to notify the agents but there's no guarantee they are reachable - try { - const agents = await this.cast.getAgentsFromCall(call); - for (const agent of Object.values(agents)) { - agent?.onCallEnded(call._id).catch(() => null); + // Try to notify the agents but there's no guarantee they are reachable + try { + const agents = await this.cast.getAgentsFromCall(call); + for (const agent of Object.values(agents)) { + agent?.onCallEnded(call._id).catch(() => null); + } + } catch { + // Ignore errors on the ended event } - } catch { - // Ignore errors on the ended event + } catch (error) { + logger.error({ msg: 'Failed to terminate call.', error, callId: call._id, serverErrorCode }); } - - return modified; } public static async activate(call: IMediaCall, actorAgent: IMediaCallAgent): Promise { @@ -68,17 +70,27 @@ export class MediaCallDirector { return actorAgent.oppositeAgent?.onCallActive(call._id); } - public static async acceptCall(call: IMediaCall, calleeAgent: IMediaCallAgent, contractId: string): Promise { + public static async acceptCall( + call: MediaCallHeader, + calleeAgent: IMediaCallAgent, + data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit }, + ): Promise { logger.debug({ msg: 'MediaCallDirector.acceptCall' }); - const stateResult = await MediaCalls.acceptCallById(call._id, contractId, this.getNewExpirationTime()); + const stateResult = await MediaCalls.acceptCallById(call._id, data, this.getNewExpirationTime()); // If nothing changed, the call was no longer ringing if (!stateResult.modifiedCount) { - return; + return false; } - await calleeAgent.onCallAccepted(call._id, contractId); + await calleeAgent.onCallAccepted(call._id, data.calleeContractId); await calleeAgent.oppositeAgent?.onCallAccepted(call._id, call.caller.contractId); + + if (data.webrtcAnswer) { + await this.onWebRtcAnswer(call._id, calleeAgent, data.webrtcAnswer); + } + + return true; } public static get cast(): IMediaCallCastDirector { @@ -123,10 +135,14 @@ export class MediaCallDirector { return; } - await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); + await this.onWebRtcAnswer(call._id, fromAgent, sdp); + } + + private static async onWebRtcAnswer(callId: string, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(callId, sdp); - fromAgent.onWebrtcAnswer(call._id); - await fromAgent.oppositeAgent?.onWebrtcAnswer(call._id); + fromAgent.onWebrtcAnswer(callId); + await fromAgent.oppositeAgent?.onWebrtcAnswer(callId); } public static async createCall(params: CreateCallParams): Promise { diff --git a/ee/packages/media-calls/src/server/CastDirector.ts b/ee/packages/media-calls/src/server/CastDirector.ts index 65321debfa9d6..339841a7581c5 100644 --- a/ee/packages/media-calls/src/server/CastDirector.ts +++ b/ee/packages/media-calls/src/server/CastDirector.ts @@ -3,8 +3,8 @@ import type { CallRole } from '@rocket.chat/media-signaling'; import { Users } from '@rocket.chat/models'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; -import type { IMediaCallCastDirector, MediaCallHeader } from '../definition/IMediaCallCastDirector'; -import type { GetActorContactOptions, MinimalUserData } from '../definition/common'; +import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; +import type { GetActorContactOptions, MinimalUserData, MediaCallHeader } from '../definition/common'; import type { UserActorAgent } from '../internal/agents/BaseUserAgent'; import { UserActorCalleeAgent } from '../internal/agents/CalleeAgent'; import { UserActorCallerAgent } from '../internal/agents/CallerAgent'; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 39576891ef647..770fe9570bfe1 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -10,12 +10,16 @@ export interface IMediaCallsModel extends IBaseModel { options?: FindOptions, ): Promise; startRingingById(callId: string, expiresAt: Date): Promise; - acceptCallById(callId: string, calleeContractId: string, expiresAt: Date): Promise; + acceptCallById( + callId: string, + data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit }, + expiresAt: Date, + ): Promise; activateCallById(callId: string, expiresAt: Date): Promise; setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; hasUnfinishedCalls(): Promise; setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; - setWebrtcAnswerById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; + setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 36dd442d33304..3cea1227ba8d2 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -66,14 +66,26 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async acceptCallById(callId: string, calleeContractId: string, expiresAt: Date): Promise { + public async acceptCallById( + callId: string, + data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit }, + expiresAt: Date, + ): Promise { + const { calleeContractId, webrtcAnswer } = data; + return this.updateOne( { - '_id': callId, - 'state': { $in: ['none', 'ringing'] }, - 'callee.contractId': { $exists: false }, + _id: callId, + state: { $in: ['none', 'ringing'] }, + }, + { + $set: { + 'state': 'accepted', + 'callee.contractId': calleeContractId, + expiresAt, + ...(webrtcAnswer && { webrtcAnswer }), + }, }, - { $set: { 'state': 'accepted', 'callee.contractId': calleeContractId, expiresAt } }, ); } @@ -154,14 +166,14 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async setWebrtcAnswerById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise { + public async setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise { return this.updateOne( { _id: callId, webrtcAnswer: { $exists: false }, }, { - $set: { webrtcAnswer: offer, expiresAt }, + $set: { webrtcAnswer: answer, expiresAt }, }, ); } From 79025c861d0ff972366e355459f3703da9abbf1c Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 28 Aug 2025 00:33:25 -0300 Subject: [PATCH 45/72] removing some dead code --- ee/packages/media-calls/package.json | 2 +- ee/packages/media-calls/src/base/BaseAgent.ts | 67 +++---------------- .../src/definition/IMediaCallAgent.ts | 7 +- .../src/internal/agents/BaseUserAgent.ts | 10 +-- ...nalProcessor.ts => CallSignalProcessor.ts} | 61 +++++++++++------ .../src/internal/agents/CalleeAgent.ts | 45 +++++++------ .../internal/agents/CalleeSignalProcessor.ts | 51 -------------- .../src/internal/agents/CallerAgent.ts | 12 +--- .../internal/agents/CallerSignalProcessor.ts | 34 ---------- .../media-calls/src/server/CallDirector.ts | 32 ++++++--- .../src/mediaCalls/IMediaCallChannel.ts | 10 +-- packages/media-signaling/package.json | 2 +- .../src/models/IMediaCallChannelsModel.ts | 5 +- .../src/models/IMediaCallsModel.ts | 1 + .../models/src/models/MediaCallChannels.ts | 39 +---------- packages/models/src/models/MediaCalls.ts | 5 ++ yarn.lock | 4 +- 17 files changed, 122 insertions(+), 265 deletions(-) rename ee/packages/media-calls/src/internal/agents/{BaseSignalProcessor.ts => CallSignalProcessor.ts} (72%) delete mode 100644 ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts delete mode 100644 ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts diff --git a/ee/packages/media-calls/package.json b/ee/packages/media-calls/package.json index 7a2c65e07f620..60dc02a5e336b 100644 --- a/ee/packages/media-calls/package.json +++ b/ee/packages/media-calls/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index 9b0a68c5270ec..b9336ddb8c18e 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -6,12 +6,11 @@ import type { MediaCallContact, MediaCallSignedActor, } from '@rocket.chat/core-typings'; -import type { CallContact, CallRole } from '@rocket.chat/media-signaling'; +import type { CallRole } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallChannels } from '@rocket.chat/models'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; -import { logger } from '../logger'; export abstract class BaseMediaCallAgent implements IMediaCallAgent { public readonly actorType: MediaCallActorType; @@ -68,48 +67,29 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallEnded(callId: string): Promise; - public abstract onWebrtcAnswer(callId: string): Promise; - - public async getOrCreateChannel( - call: IMediaCall, - contractId: string, - params?: Pick, - ): Promise { - return this.createOrUpdateChannel(call, this.getSignedActor(contractId), this.getMyCallActor(call), params); + public async getOrCreateChannel(call: IMediaCall, contractId: string): Promise { + return this.createOrUpdateChannel(call, contractId); } public abstract onCallCreated(call: IMediaCall): Promise; - public async onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'BaseMediaCallAgent.onRemoteDescriptionChanged', callId, description }); - await MediaCallChannels.setRemoteDescriptionByCallIdAndActor(callId, this.actor, description); - } + public abstract onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; - protected async createOrUpdateChannel( - call: IMediaCall, - actor: MediaCallSignedActor, - contact: CallContact, - params?: Pick, - ): Promise { - if (!actor.contractId) { + protected async createOrUpdateChannel(call: IMediaCall, contractId: string): Promise { + if (!contractId) { throw new Error('error-invalid-contract'); } - const localDescription = params?.localDescription || this.localDescription || undefined; - const newChannel: InsertionModel = { callId: call._id, state: 'none', role: this.role, - acknowledged: Boolean(params?.acknowledged) ?? false, - ...(localDescription && { localDescription }), - contact, - contractId: actor.contractId, - actorType: actor.type, - actorId: actor.id, + contractId, + actorType: this.actorType, + actorId: this.actorId, }; - // Create this channel if it doesn't yet exist, or update ack/sdp if it does and needs it + // Create this channel if it doesn't yet exist const insertedChannel = await MediaCallChannels.createOrUpdateChannel(newChannel); if (!insertedChannel) { throw new Error('failed-to-insert-channel'); @@ -122,31 +102,4 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { return insertedChannel; } - - protected async autoDetectRemoteDescription(call: IMediaCall, localChannel: IMediaCallChannel): Promise { - // If our channel already has a remote description, we don't need to check anything - if (localChannel.remoteDescription) { - return; - } - - // If the other actor is not signed, we don't know what channel to check - const otherActor = this.getOtherCallActor(call); - if (!otherActor.contractId) { - return; - } - - const remoteChannel = await MediaCallChannels.findOneByCallIdAndSignedActor( - { - ...(otherActor as MediaCallSignedActor), - callId: call._id, - }, - { projection: { localDescription: 1 } }, - ); - - if (!remoteChannel?.localDescription) { - return; - } - - await this.onRemoteDescriptionChanged(call._id, remoteChannel.localDescription); - } } diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index ca8e9a6ade004..609e822b0afee 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -15,10 +15,11 @@ export interface IMediaCallAgent { onCallAccepted(callId: string, signedContractId: string): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; - /* Called when the opposite agent has a webrtc description available, even if the call was not yet accepted */ + /* + * Called when the sdp of the other actor is available AND the call has been accepted + * The server will automatically delay this event if the sdp becomes available before the call is accepted + */ onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; - /* Called when the call was accepted and the webrtc answer is available */ - onWebrtcAnswer(callId: string): Promise; getMyCallActor(call: IMediaCall): MediaCallActor; } diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts index 32f8b33f90f70..96c9b5033ed88 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts @@ -1,15 +1,17 @@ -import type { IMediaCall, IMediaCallChannel } from '@rocket.chat/core-typings'; +import type { IMediaCall } from '@rocket.chat/core-typings'; import type { ClientMediaSignal, ServerMediaSignal, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; +import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; import { logger } from '../../logger'; import { getMediaCallServer } from '../../server/injection'; export abstract class UserActorAgent extends BaseMediaCallAgent { public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { - const channel = await this.getOrCreateChannel(call, signal.contractId, { acknowledged: true }); + const channel = await this.getOrCreateChannel(call, signal.contractId); - return this.doProcessSignal(call, channel, signal); + const signalProcessor = new UserActorSignalProcessor(this, call, channel); + return signalProcessor.processSignal(signal); } public async sendSignal(signal: ServerMediaSignal): Promise { @@ -53,8 +55,6 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { await this.sendSignal(this.buildNewCallSignal(call)); } - protected abstract doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise; - protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { return { callId: call._id, diff --git a/ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts similarity index 72% rename from ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts rename to ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 84884c23fb9d4..e262ba0e6f16d 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -7,14 +7,14 @@ import type { ClientMediaSignalLocalState, ServerMediaSignal, } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; +import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; import { MediaCallDirector } from '../../server/CallDirector'; import { getMediaCallServer } from '../../server/injection'; -export abstract class UserActorSignalProcessor { +export class UserActorSignalProcessor { public get contractId(): string { return this.channel.contractId; } @@ -66,19 +66,6 @@ export abstract class UserActorSignalProcessor { type: 'remote-sdp', sdp, }); - - try { - await MediaCallChannels.setRemoteDescription(this.channel._id, sdp); - } catch (error) { - logger.error('Failed to save remote SDP on call channel.'); - } - } - - public async getLocalDescription(): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.getRemoteDescription' }); - - const channel = await MediaCallChannels.findOneById(this.channel._id); - return channel?.localDescription ?? null; } public async requestWebRTCOffer(params: { iceRestart?: boolean }): Promise { @@ -118,8 +105,7 @@ export abstract class UserActorSignalProcessor { protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp }); - await MediaCallChannels.setLocalDescription(this.channel._id, sdp); - await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp); + await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp, this.contractId); } private async processAnswer(answer: CallAnswer): Promise { @@ -137,13 +123,46 @@ export abstract class UserActorSignalProcessor { } } - protected abstract clientIsReachable(): Promise; + protected async clientIsReachable(): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.clientIsReachable', role: this.role, uid: this.actorId }); - protected abstract clientHasRejected(): Promise; + if (this.role === 'callee' && this.call.state === 'none') { + // Change the call state from 'none' to 'ringing' when any callee session is found + await MediaCalls.startRingingById(this.call._id, MediaCallDirector.getNewExpirationTime()); + } - protected abstract clientHasAccepted(): Promise; + // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state + if (this.role === 'caller' && this.signed) { + // When the signed caller's client is reached, we immediatelly send the first offer request + await this.requestWebRTCOffer({ iceRestart: false }); + } + } + + protected async clientHasRejected(): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.clientHasRejected', role: this.role, uid: this.actorId }); + if (!this.isCallPending()) { + return; + } - protected abstract clientIsUnavailable(): Promise; + if (this.role === 'callee') { + return MediaCallDirector.hangup(this.call, this.agent, 'rejected'); + } + } + + protected async clientIsUnavailable(): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.clientIsUnavailable', role: this.role, uid: this.actorId }); + } + + protected async clientHasAccepted(): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.clientHasAccepted', role: this.role, uid: this.actorId }); + if (!this.isCallPending()) { + return; + } + + if (this.role === 'callee') { + await MediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId }); + } + } protected async clientIsActive(): Promise { const result = await MediaCallChannels.setActiveById(this.channel._id); diff --git a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts index 7599793680e9c..e3d9f1df7f384 100644 --- a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts @@ -1,39 +1,42 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal } from '@rocket.chat/media-signaling'; -import { MediaCallChannels } from '@rocket.chat/models'; +import type { MediaCallContact } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; import { UserActorAgent } from './BaseUserAgent'; -import { UserActorCalleeSignalProcessor } from './CalleeSignalProcessor'; +import { logger } from '../../logger'; export class UserActorCalleeAgent extends UserActorAgent { constructor(contact: MediaCallContact) { super(contact, 'callee'); } - protected doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise { - const signalProcessor = new UserActorCalleeSignalProcessor(this, call, channel); - return signalProcessor.processSignal(signal); - } - public async onCallAccepted(callId: string, signedContractId: string): Promise { await super.onCallAccepted(callId, signedContractId); - const channel = await MediaCallChannels.findOneByCallIdAndSignedActor({ callId, ...this.actor, contractId: signedContractId }); - if (!channel) { + const call = await MediaCalls.findOneById(callId); + if (!call?.webrtcOffer) { return; } - if (channel.remoteDescription) { - await this.sendSignal({ - callId, - toContractId: signedContractId, - type: 'remote-sdp', - sdp: channel.remoteDescription, - }); - } + await this.sendSignal({ + callId, + toContractId: signedContractId, + type: 'remote-sdp', + sdp: call.webrtcOffer, + }); } - public async onWebrtcAnswer(_callId: string): Promise { - // The callee doesn't need to do anything when the answer is ready + public async onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise { + const call = await MediaCalls.findOneById(callId); + if (call?.state !== 'accepted' || !call.webrtcOffer || !call.callee.contractId) { + logger.error({ msg: 'Invalid call state', call }); + return; + } + + await this.sendSignal({ + callId, + toContractId: call.callee.contractId, + type: 'remote-sdp', + sdp: description, + }); } } diff --git a/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts deleted file mode 100644 index e2357cbb24b8b..0000000000000 --- a/ee/packages/media-calls/src/internal/agents/CalleeSignalProcessor.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; - -import { UserActorSignalProcessor } from './BaseSignalProcessor'; -import { logger } from '../../logger'; -import { MediaCallDirector } from '../../server/CallDirector'; - -export class UserActorCalleeSignalProcessor extends UserActorSignalProcessor { - protected async clientIsReachable(): Promise { - logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientIsReachable' }); - if (this.call.state !== 'none') { - return; - } - - // Change the call state from 'none' to 'ringing' when any callee session is found - await MediaCalls.startRingingById(this.call._id, MediaCallDirector.getNewExpirationTime()); - } - - protected async clientHasRejected(): Promise { - logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientHasRejected' }); - - if (!this.isCallPending()) { - return; - } - - return MediaCallDirector.hangup(this.call, this.agent, 'rejected'); - } - - protected async clientIsUnavailable(): Promise { - logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientIsUnavailable' }); - - // We don't do anything on unavailable responses from clients, as a different client may still answer - } - - protected async clientHasAccepted(): Promise { - logger.debug({ msg: 'UserActorCalleeSignalProcessor.clientHasAccepted' }); - - if (!this.isCallPending()) { - return; - } - - await MediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId }); - } - - protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserActorCalleeSignalProcessor.saveLocalDescription', sdp }); - - await MediaCallChannels.setLocalDescription(this.channel._id, sdp); - - await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp); - } -} diff --git a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts index 911ccbbb2fcd7..2f2593d5708f3 100644 --- a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts @@ -1,9 +1,8 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallContact } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; +import type { IMediaCall, MediaCallContact } from '@rocket.chat/core-typings'; +import type { ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; import { UserActorAgent } from './BaseUserAgent'; -import { UserActorCallerSignalProcessor } from './CallerSignalProcessor'; import { logger } from '../../logger'; export class UserActorCallerAgent extends UserActorAgent { @@ -33,12 +32,7 @@ export class UserActorCallerAgent extends UserActorAgent { }; } - protected doProcessSignal(call: IMediaCall, channel: IMediaCallChannel, signal: ClientMediaSignal): Promise { - const signalProcessor = new UserActorCallerSignalProcessor(this, call, channel); - return signalProcessor.processSignal(signal); - } - - public async onWebrtcAnswer(callId: string): Promise { + public async onRemoteDescriptionChanged(callId: string, _description: RTCSessionDescriptionInit): Promise { const call = await MediaCalls.findOneById(callId); if (call?.state !== 'accepted' || !call.webrtcAnswer) { diff --git a/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts deleted file mode 100644 index 5de20e8521f4f..0000000000000 --- a/ee/packages/media-calls/src/internal/agents/CallerSignalProcessor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UserActorSignalProcessor } from './BaseSignalProcessor'; -import { logger } from '../../logger'; - -export class UserActorCallerSignalProcessor extends UserActorSignalProcessor { - protected async clientIsReachable(): Promise { - logger.debug({ msg: 'UserActorCallerAgent.clientIsReachable' }); - - // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state - if (!this.signed) { - return; - } - - // When the signed caller's client is reached, we immediatelly send the first offer request - await this.requestWebRTCOffer({ iceRestart: false }); - } - - protected async clientHasRejected(): Promise { - logger.debug({ msg: 'UserActorCallerAgent.clientHasRejected' }); - - // Ignore rejections coming from the caller's client - } - - protected async clientIsUnavailable(): Promise { - logger.debug({ msg: 'UserActorCallerAgent.clientIsUnavailable' }); - - // Ignore unavailable caller clients - } - - protected async clientHasAccepted(): Promise { - logger.debug({ msg: 'UserActorCallerAgent.clientHasAccepted' }); - - // The caller has nothing to accept - } -} diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 7845fb529b062..d05acc891fe6a 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -87,7 +87,7 @@ export class MediaCallDirector { await calleeAgent.oppositeAgent?.onCallAccepted(call._id, call.caller.contractId); if (data.webrtcAnswer) { - await this.onWebRtcAnswer(call._id, calleeAgent, data.webrtcAnswer); + await calleeAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, data.webrtcAnswer); } return true; @@ -102,11 +102,19 @@ export class MediaCallDirector { } } - public static async saveWebrtcSession(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { + public static async saveWebrtcSession( + call: IMediaCall, + fromAgent: IMediaCallAgent, + sdp: RTCSessionDescriptionInit, + contractId: string, + ): Promise { if (sdp.type === 'offer') { if (fromAgent.role !== 'caller') { throw new Error('invalid-sdp'); } + if (contractId !== call.caller.contractId) { + throw new Error('invalid-contract'); + } return this.saveWebrtcOffer(call, fromAgent, sdp); } @@ -114,6 +122,9 @@ export class MediaCallDirector { if (fromAgent.role !== 'callee') { throw new Error('invalid-sdp'); } + if (contractId !== call.callee.contractId || !call.callee.contractId) { + throw new Error('invalid-contract'); + } return this.saveWebrtcAnswer(call, fromAgent, sdp); } @@ -125,6 +136,14 @@ export class MediaCallDirector { return; } + // If the call was already accepted in the old data, we need to trigger the event + // If the call was not yet accepted then but is now flagged as accepted on mongo, we may also need to trigger the event, + // if the state and the offer were saved at the same time, the event may end up being called twice, but that's better than risk never calling it + const isAccepted = call.state === 'accepted' || (await MediaCalls.isInStateById(call._id, 'accepted')); + if (!isAccepted) { + return; + } + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); } @@ -135,14 +154,7 @@ export class MediaCallDirector { return; } - await this.onWebRtcAnswer(call._id, fromAgent, sdp); - } - - private static async onWebRtcAnswer(callId: string, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { - await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(callId, sdp); - - fromAgent.onWebrtcAnswer(callId); - await fromAgent.oppositeAgent?.onWebrtcAnswer(callId); + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); } public static async createCall(params: CreateCallParams): Promise { diff --git a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts index cdf939df1d788..31372c2e69e58 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallChannel.ts @@ -1,5 +1,5 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; -import type { MediaCallActorType, MediaCallContact } from './IMediaCall'; +import type { MediaCallActorType } from './IMediaCall'; export interface IMediaCallChannel extends IRocketChatRecord { callId: string; @@ -10,8 +10,6 @@ export interface IMediaCallChannel extends IRocketChatRecord { actorId: string; - contact: Partial; - role: 'caller' | 'callee'; state: 'none' | 'ringing' | 'joining' | 'active' | 'left'; @@ -22,10 +20,4 @@ export interface IMediaCallChannel extends IRocketChatRecord { activeAt?: Date; // The moment when the user left the call or hanged up leftAt?: Date; - - localDescription?: RTCSessionDescriptionInit; - remoteDescription?: RTCSessionDescriptionInit; - - // for rocket.chat users, acknowledged means that a client user session was reached by the signaling - acknowledged: boolean; } diff --git a/packages/media-signaling/package.json b/packages/media-signaling/package.json index 1dd13deddee49..ca2f25cf48bd1 100644 --- a/packages/media-signaling/package.json +++ b/packages/media-signaling/package.json @@ -8,7 +8,7 @@ "@types/jest": "~30.0.0", "eslint": "~8.45.0", "jest": "~30.0.5", - "typescript": "~5.8.3" + "typescript": "~5.9.2" }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", diff --git a/packages/model-typings/src/models/IMediaCallChannelsModel.ts b/packages/model-typings/src/models/IMediaCallChannelsModel.ts index bdfbac9f28c0f..055fa199ab60e 100644 --- a/packages/model-typings/src/models/IMediaCallChannelsModel.ts +++ b/packages/model-typings/src/models/IMediaCallChannelsModel.ts @@ -1,4 +1,4 @@ -import type { IMediaCallChannel, MediaCallActor, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { Document, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel, InsertionModel } from './IBaseModel'; @@ -11,7 +11,4 @@ export interface IMediaCallChannelsModel extends IBaseModel { ): Promise; setState(_id: string, state: IMediaCallChannel['state']): Promise; setActiveById(_id: string): Promise; - setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise; - setRemoteDescription(_id: string, remoteDescription: RTCSessionDescriptionInit): Promise; - setRemoteDescriptionByCallIdAndActor(callId: string, actor: MediaCallActor, remoteDescription: RTCSessionDescriptionInit): Promise; } diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 770fe9570bfe1..7a05cb29164f6 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -22,4 +22,5 @@ export interface IMediaCallsModel extends IBaseModel { hasUnfinishedCalls(): Promise; setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise; + isInStateById(callId: string, state: IMediaCall['state']): Promise; } diff --git a/packages/models/src/models/MediaCallChannels.ts b/packages/models/src/models/MediaCallChannels.ts index 664e72f6df076..47d1bd11a821c 100644 --- a/packages/models/src/models/MediaCallChannels.ts +++ b/packages/models/src/models/MediaCallChannels.ts @@ -1,4 +1,4 @@ -import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallSignedActor, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCallChannel, RocketChatRecordDeleted, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { IMediaCallChannelsModel, InsertionModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult, FindOptions, Document } from 'mongodb'; @@ -43,14 +43,6 @@ export class MediaCallChannelsRaw extends BaseRaw implements } public createOrUpdateChannel(channel: InsertionModel): Promise { - const { acknowledged, localDescription, ...channelData } = channel; - - const dataToUpdate = { - // once acknowledged, a channel never unsets this flag - ...(acknowledged && { acknowledged: true }), - ...(localDescription && { localDescription }), - } as const; - return this.findOneAndUpdate( { callId: channel.callId, @@ -60,9 +52,8 @@ export class MediaCallChannelsRaw extends BaseRaw implements }, { $setOnInsert: { - ...channelData, + ...channel, }, - ...(Object.keys(dataToUpdate).length > 0 && { $set: dataToUpdate }), }, { upsert: true, @@ -78,30 +69,4 @@ export class MediaCallChannelsRaw extends BaseRaw implements public async setActiveById(_id: string): Promise { return this.updateOne({ _id, activeAt: { $exists: false } }, { $set: { state: 'active', activeAt: new Date() } }); } - - public async setLocalDescription(_id: string, localDescription: RTCSessionDescriptionInit): Promise { - return this.updateOneById(_id, { $set: { localDescription } }); - } - - public async setRemoteDescription(_id: string, remoteDescription: RTCSessionDescriptionInit): Promise { - return this.updateOneById(_id, { $set: { remoteDescription } }); - } - - public async setRemoteDescriptionByCallIdAndActor( - callId: string, - actor: MediaCallActor, - remoteDescription: RTCSessionDescriptionInit, - ): Promise { - await this.updateMany( - { - callId, - actorId: actor.id, - actorType: actor.type, - ...(actor.contractId && { contractId: actor.contractId }), - }, - { - $set: { remoteDescription }, - }, - ); - } } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 3cea1227ba8d2..f88af4df868a6 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -177,4 +177,9 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, ); } + + public async isInStateById(callId: string, state: IMediaCall['state']): Promise { + const count = await this.countDocuments({ _id: callId, state }, { limit: 1 }); + return count > 0; + } } diff --git a/yarn.lock b/yarn.lock index f9720dded1d32..2b9e16418afa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7824,7 +7824,7 @@ __metadata: "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -7839,7 +7839,7 @@ __metadata: ajv: "npm:^8.17.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" - typescript: "npm:~5.8.3" + typescript: "npm:~5.9.2" languageName: unknown linkType: soft From 808db2f471b2e45d4b7a07b417492c7f51d50898 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 28 Aug 2025 03:01:53 -0300 Subject: [PATCH 46/72] importing fixes from sip branch --- ee/packages/media-calls/src/definition/IMediaCallAgent.ts | 5 +---- .../media-calls/src/internal/agents/CalleeAgent.ts | 4 +++- .../media-calls/src/internal/agents/CallerAgent.ts | 2 +- ee/packages/media-calls/src/server/CallDirector.ts | 8 -------- ee/packages/media-calls/src/server/CastDirector.ts | 2 -- packages/model-typings/src/models/IMediaCallsModel.ts | 1 - packages/models/src/models/MediaCalls.ts | 5 ----- 7 files changed, 5 insertions(+), 22 deletions(-) diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index 609e822b0afee..e0ac7a8dab324 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -15,10 +15,7 @@ export interface IMediaCallAgent { onCallAccepted(callId: string, signedContractId: string): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; - /* - * Called when the sdp of the other actor is available AND the call has been accepted - * The server will automatically delay this event if the sdp becomes available before the call is accepted - */ + /* Called when the sdp of the other actor is available, regardless of call state */ onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; getMyCallActor(call: IMediaCall): MediaCallActor; diff --git a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts index e3d9f1df7f384..f2eb19f53c963 100644 --- a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts @@ -14,6 +14,7 @@ export class UserActorCalleeAgent extends UserActorAgent { const call = await MediaCalls.findOneById(callId); if (!call?.webrtcOffer) { + logger.debug('The call was accepted but the webrtc offer is not yet available.'); return; } @@ -26,9 +27,10 @@ export class UserActorCalleeAgent extends UserActorAgent { } public async onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'UserActorCalleeAgent.onRemoteDescriptionChanged', callId }); + const call = await MediaCalls.findOneById(callId); if (call?.state !== 'accepted' || !call.webrtcOffer || !call.callee.contractId) { - logger.error({ msg: 'Invalid call state', call }); return; } diff --git a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts index 2f2593d5708f3..077f5e8cf0d0f 100644 --- a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts @@ -33,10 +33,10 @@ export class UserActorCallerAgent extends UserActorAgent { } public async onRemoteDescriptionChanged(callId: string, _description: RTCSessionDescriptionInit): Promise { + logger.debug({ msg: 'UserActorCallerAgent.onRemoteDescriptionChanged', callId }); const call = await MediaCalls.findOneById(callId); if (call?.state !== 'accepted' || !call.webrtcAnswer) { - logger.error({ msg: 'Invalid call state', call }); return; } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index d05acc891fe6a..ae26df525219d 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -136,14 +136,6 @@ export class MediaCallDirector { return; } - // If the call was already accepted in the old data, we need to trigger the event - // If the call was not yet accepted then but is now flagged as accepted on mongo, we may also need to trigger the event, - // if the state and the offer were saved at the same time, the event may end up being called twice, but that's better than risk never calling it - const isAccepted = call.state === 'accepted' || (await MediaCalls.isInStateById(call._id, 'accepted')); - if (!isAccepted) { - return; - } - await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); } diff --git a/ee/packages/media-calls/src/server/CastDirector.ts b/ee/packages/media-calls/src/server/CastDirector.ts index 339841a7581c5..82e4d7c3ce567 100644 --- a/ee/packages/media-calls/src/server/CastDirector.ts +++ b/ee/packages/media-calls/src/server/CastDirector.ts @@ -36,8 +36,6 @@ export class MediaCallCastDirector implements IMediaCallCastDirector { } public async getAgentFromCall(call: MediaCallHeader, role: CallRole): Promise { - logger.debug({ msg: 'MediaCallCastDirector.getAgentFromCall', callId: call?._id, role }); - const { [role]: actor } = call; return this.getAgentForActorAndRole(actor, role); diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 7a05cb29164f6..770fe9570bfe1 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -22,5 +22,4 @@ export interface IMediaCallsModel extends IBaseModel { hasUnfinishedCalls(): Promise; setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise; - isInStateById(callId: string, state: IMediaCall['state']): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index f88af4df868a6..3cea1227ba8d2 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -177,9 +177,4 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, ); } - - public async isInStateById(callId: string, state: IMediaCall['state']): Promise { - const count = await this.countDocuments({ _id: callId, state }, { limit: 1 }); - return count > 0; - } } From 08e298ffc771fbf31cd6faad99f1fcd9e7826c7f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 28 Aug 2025 21:33:21 -0300 Subject: [PATCH 47/72] better error handling --- .../internal/agents/CallSignalProcessor.ts | 49 ++++++++++++++++++- .../src/definition/signals/client/error.ts | 11 +++-- packages/media-signaling/src/lib/Call.ts | 22 ++++----- .../src/lib/TransportWrapper.ts | 5 +- 4 files changed, 70 insertions(+), 17 deletions(-) diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index e262ba0e6f16d..b78dd852570f0 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -4,6 +4,7 @@ import type { CallHangupReason, CallRole, ClientMediaSignal, + ClientMediaSignalError, ClientMediaSignalLocalState, ServerMediaSignal, } from '@rocket.chat/media-signaling'; @@ -95,6 +96,8 @@ export class UserActorSignalProcessor { return this.hangup(signal.reason); case 'local-state': return this.reviewLocalState(signal); + case 'error': + return this.processError(signal.errorType, signal.errorCode); } } @@ -123,12 +126,27 @@ export class UserActorSignalProcessor { } } + private async processError(errorType: ClientMediaSignalError['errorType'], errorCode?: string): Promise { + if (!this.signed) { + return; + } + + switch (errorType) { + case 'signaling': + return this.onSignalingError(errorCode); + case 'service': + return this.onServiceError(errorCode); + default: + return this.onUnexpectedError(errorCode); + } + } + protected async clientIsReachable(): Promise { logger.debug({ msg: 'UserActorSignalProcessor.clientIsReachable', role: this.role, uid: this.actorId }); if (this.role === 'callee' && this.call.state === 'none') { // Change the call state from 'none' to 'ringing' when any callee session is found - await MediaCalls.startRingingById(this.call._id, MediaCallDirector.getNewExpirationTime()); + await MediaCalls.startRingingById(this.callId, MediaCallDirector.getNewExpirationTime()); } // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state @@ -179,6 +197,10 @@ export class UserActorSignalProcessor { return ['none', 'ringing'].includes(this.call.state); } + protected isPastNegotiation(): boolean { + return ['active', 'hangup'].includes(this.call.state); + } + private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise { if (!this.signed) { return; @@ -192,4 +214,29 @@ export class UserActorSignalProcessor { await this.clientIsActive(); } } + + private async onSignalingError(errorMessage?: string): Promise { + logger.error({ msg: 'Client reported a signaling error', errorMessage, callId: this.callId, role: this.role, state: this.call.state }); + await MediaCallDirector.hangup(this.call, this.agent, 'service-error'); + } + + private async onServiceError(errorMessage?: string): Promise { + logger.error({ msg: 'Client reported a service error', errorMessage, callId: this.callId, role: this.role, state: this.call.state }); + if (this.isPastNegotiation()) { + return; + } + + await MediaCallDirector.hangup(this.call, this.agent, 'service-error'); + } + + private async onUnexpectedError(errorMessage?: string): Promise { + logger.error({ + msg: 'Client reported an unexpected error', + errorMessage, + callId: this.callId, + role: this.role, + state: this.call.state, + }); + await MediaCallDirector.hangup(this.call, this.agent, 'error'); + } } diff --git a/packages/media-signaling/src/definition/signals/client/error.ts b/packages/media-signaling/src/definition/signals/client/error.ts index 404a148e9b023..e3e5084e63728 100644 --- a/packages/media-signaling/src/definition/signals/client/error.ts +++ b/packages/media-signaling/src/definition/signals/client/error.ts @@ -6,7 +6,8 @@ export type ClientMediaSignalError = { contractId: string; type: 'error'; - errorCode: string; + errorType?: 'signaling' | 'service' | 'other'; + errorCode?: string; }; export const clientMediaSignalErrorSchema: JSONSchemaType = { @@ -24,11 +25,15 @@ export const clientMediaSignalErrorSchema: JSONSchemaType): void { + this.config.logger?.debug('ClientMediaCall.sendError', error); if (this.hidden) { return; } - this.config.transporter.sendError(this.callId, errorCode); + this.config.transporter.sendError(this.callId, error); } protected async processRemoteSDP(signal: ServerMediaSignalRemoteSDP): Promise { @@ -709,7 +709,7 @@ export class ClientMediaCall implements IClientMediaCall { } if (!this.acceptedLocally) { - this.config.transporter.sendError(this.callId, 'not-accepted'); + this.config.transporter.sendError(this.callId, { errorType: 'signaling', errorCode: 'not-accepted' }); this.config.logger?.error('Trying to activate a call that was not yet accepted locally.'); return; } @@ -887,7 +887,7 @@ export class ClientMediaCall implements IClientMediaCall { try { this.prepareWebRtcProcessor(); } catch (e) { - this.sendError('webrtc-not-implemented'); + this.sendError({ errorType: 'service', errorCode: 'webrtc-not-implemented' }); throw e; } } diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index 8c82c0462cd70..a3eb14fca01f2 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -6,6 +6,7 @@ import type { ClientMediaSignalBody, GenericClientMediaSignal, ClientMediaSignal, + ClientMediaSignalError, } from '../definition/signals'; export class MediaSignalTransportWrapper { @@ -25,8 +26,8 @@ export class MediaSignalTransportWrapper { } as GenericClientMediaSignal); } - public sendError(callId: string, errorCode: string) { - this.sendToServer(callId, 'error', { errorCode }); + public sendError(callId: string, { errorType, errorCode }: Partial) { + this.sendToServer(callId, 'error', { errorType: errorType || 'other', ...(errorCode && { errorCode }) }); } public answer(callId: string, answer: CallAnswer) { From d370c03bd6ee3d7e2c4ea4a73abeee729a904289 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 2 Sep 2025 17:30:17 -0300 Subject: [PATCH 48/72] webrtc improvements --- .../src/definition/call/CallEvents.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 5 + .../services/webrtc/IWebRTCProcessor.ts | 13 +- packages/media-signaling/src/lib/Call.ts | 84 ++++++- packages/media-signaling/src/lib/Session.ts | 2 +- .../src/lib/services/webrtc/LocalStream.ts | 56 +++-- .../src/lib/services/webrtc/Processor.ts | 224 +++++++++++++----- .../src/lib/services/webrtc/RemoteStream.ts | 23 +- .../src/lib/services/webrtc/Stream.ts | 51 ++++ .../src/lib/utils/getExternalWaiter.ts | 65 +++++ 10 files changed, 426 insertions(+), 98 deletions(-) create mode 100644 packages/media-signaling/src/lib/utils/getExternalWaiter.ts diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index fb39075e3ff8d..5a61a247a56aa 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -4,6 +4,7 @@ import type { CallState } from './IClientMediaCall'; export type CallEvents = { stateChange: CallState; clientStateChange: ClientState; + trackStateChange: void; contactUpdate: void; initialized: void; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 962ec4312bbaa..0c35371d66067 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -63,14 +63,19 @@ export interface IClientMediaCall { ignored: boolean; signed: boolean; hidden: boolean; + muted: boolean; + onHold: boolean; contact: CallContact; emitter: Emitter; + setInputTrack(newInputTrack: MediaStreamTrack | null): Promise; getRemoteMediaStream(): MediaStream; accept(): void; reject(): void; hangup(): void; + setMuted(muted: boolean): void; + setOnHold(onHold: boolean): void; } diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index fb8216b87d0c4..00337d3c26a05 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -1,5 +1,6 @@ import type { Emitter } from '@rocket.chat/emitter'; +import type { IClientMediaCall } from '../../call'; import type { IMediaSignalLogger } from '../../logger'; import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor'; import type { MediaStreamFactory } from '../MediaStreamFactory'; @@ -17,6 +18,14 @@ export type WebRTCProcessorEvents = ServiceProcessorEvents { emitter: Emitter; + muted: boolean; + onHold: boolean; + + setMuted(muted: boolean): void; + setOnHold(onHold: boolean): void; + stop(): void; + + setInputTrack(newInputTrack: MediaStreamTrack | null): Promise; createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; setRemoteDescription(params: { sdp: RTCSessionDescriptionInit }): Promise; @@ -25,7 +34,9 @@ export interface IWebRTCProcessor extends IServiceProcessor { + this.inputTrack = newInputTrack; + if (this.webrtcProcessor) { + await this.webrtcProcessor.setInputTrack(newInputTrack); + } + } + public getRemoteMediaStream(): MediaStream { this.config.logger?.debug('ClientMediaCall.getRemoteMediaStream'); if (this.shouldIgnoreWebRTC()) { @@ -416,6 +442,38 @@ export class ClientMediaCall implements IClientMediaCall { this.mayReportStates = false; } + public setMuted(muted: boolean): void { + if (this.isOver()) { + return; + } + if (!this.webrtcProcessor && !muted) { + return; + } + + this.requireWebRTC(); + const wasMuted = this.webrtcProcessor.muted; + this.webrtcProcessor.setMuted(muted); + if (wasMuted !== this.webrtcProcessor.muted) { + this.emitter.emit('trackStateChange'); + } + } + + public setOnHold(onHold: boolean): void { + if (this.isOver()) { + return; + } + if (!this.webrtcProcessor && !onHold) { + return; + } + + this.requireWebRTC(); + const wasOnHold = this.webrtcProcessor.onHold; + this.webrtcProcessor.setOnHold(onHold); + if (wasOnHold !== this.webrtcProcessor.onHold) { + this.emitter.emit('trackStateChange'); + } + } + public setContractState(state: 'signed' | 'ignored') { if (this.contractState === state) { return; @@ -447,6 +505,7 @@ export class ClientMediaCall implements IClientMediaCall { if (this.hidden && !wasHidden) { this.emitter.emit('hidden'); } + this.maybeStopWebRTC(); } public reportStates(): void { @@ -478,6 +537,7 @@ export class ClientMediaCall implements IClientMediaCall { const oldState = this._state; this._state = newState; + this.maybeStopWebRTC(); this.updateClientState(); this.emitter.emit('stateChange', oldState); @@ -514,6 +574,16 @@ export class ClientMediaCall implements IClientMediaCall { this.emitter.emit('clientStateChange', oldClientState); } + private maybeStopWebRTC(): void { + if (!this.webrtcProcessor) { + return; + } + + if (this.isOver() || this.hidden) { + this.webrtcProcessor.stop(); + } + } + private changeContact(contact: CallContact | null, { prioritizeExisting }: { prioritizeExisting?: boolean } = {}): void { this.config.logger?.debug('ClientMediaCall.changeContact'); const oldContct = prioritizeExisting ? contact : this._contact; @@ -804,6 +874,15 @@ export class ClientMediaCall implements IClientMediaCall { } } + private onWebRTCInternalError({ critical, error }: { critical: boolean; error: string | Error }): void { + const errorCode = typeof error === 'object' ? error.message : error; + this.sendError({ errorType: 'service', errorCode }); + + if (critical) { + this.hangup('service-error'); + } + } + private onWebRTCConnectionStateChange(stateValue: RTCPeerConnectionState): void { if (this.hidden) { return; @@ -879,7 +958,8 @@ export class ClientMediaCall implements IClientMediaCall { this.throwError('webrtc-not-implemented'); } - this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger, iceGatheringTimeout }); + this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger, iceGatheringTimeout, call: this, inputTrack: this.inputTrack }); + this.webrtcProcessor.emitter.on('internalError', (event) => this.onWebRTCInternalError(event)); this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index ccf0a087c796b..47a5cb99a9f04 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -29,7 +29,7 @@ export type MediaSignalingSessionConfig = { oldSessionId?: string; logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; - mediaStreamFactory: MediaStreamFactory; + mediaStreamFactory?: MediaStreamFactory; transport: MediaSignalTransport; iceGatheringTimeout?: number; }; diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index abea0c4733685..f1f93ed2daab0 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,37 +1,49 @@ import { Stream } from './Stream'; export class LocalStream extends Stream { - public async setTrack(newTrack: MediaStreamTrack, peer: RTCPeerConnection): Promise { - if (newTrack.kind === 'video') { - return; - } + public get muted(): boolean { + return !this.enabled; + } - if (newTrack.kind !== 'audio') { - throw new Error('Unsupported track kind'); + public setMuted(muted: boolean): void { + if (muted) { + this.disable(); + } else { + this.enable(); } + } - const sender = peer.getSenders().find((sender) => sender.track?.kind === newTrack.kind); - if (!sender) { - peer.addTrack(newTrack, this.mediaStream); - this.mediaStream.addTrack(newTrack); - return; - } + public async setTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { + if (newTrack) { + const matchingTrack = this.mediaStream.getTrackById(newTrack.id); + if (matchingTrack) { + matchingTrack.enabled = this.enabled; + return; + } - await sender.replaceTrack(newTrack); + newTrack.enabled = this.enabled; + } - const oldTrack = this.mediaStream.getTracks().find((localTrack) => localTrack.kind === newTrack.kind); - if (oldTrack) { - oldTrack.stop(); - this.mediaStream.removeTrack(oldTrack); + this.removeAudioTracks(); + if (newTrack) { + this.mediaStream.addTrack(newTrack); } - this.mediaStream.addTrack(newTrack); + this.setRemoteTrack(newTrack, peer); } - public async setStreamTracks(stream: MediaStream, peer: RTCPeerConnection): Promise { - const audioTracks = stream.getAudioTracks(); - if (audioTracks.length) { - await this.setTrack(audioTracks[0], peer); + private async setRemoteTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { + // If the peer doesn't yet have any audio track, send it to them + const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio'); + if (!sender) { + if (newTrack) { + // This will require a re-negotiation + peer.addTrack(newTrack, this.mediaStream); + } + return; } + + // If the peer already has a track of the same kind, we can just replace it with the new track with no issues + await sender.replaceTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 81f9b404ed25b..d4c5029dbee02 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -4,13 +4,7 @@ import { LocalStream } from './LocalStream'; import { RemoteStream } from './RemoteStream'; import type { IWebRTCProcessor, WebRTCInternalStateMap, WebRTCProcessorConfig, WebRTCProcessorEvents } from '../../../definition'; import type { ServiceStateValue } from '../../../definition/services/IServiceProcessor'; - -type IceGatheringData = { - promise: Promise; - promiseReject: (error: Error) => void; - promiseResolve: () => void; - timeout: ReturnType; -}; +import { getExternalWaiter, type PromiseWaiterData } from '../../utils/getExternalWaiter'; export class MediaCallWebRTCProcessor implements IWebRTCProcessor { public emitter: Emitter; @@ -31,12 +25,32 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private remoteMediaStream: MediaStream; - private iceGatheringWaiters: Set; + private iceGatheringWaiters: Set; + + private inputTrackWaiter: PromiseWaiterData | null; + + private inputTrack: MediaStreamTrack | null; + + private _muted = false; + + public get muted(): boolean { + return this._muted; + } + + private _onHold = false; + + public get onHold(): boolean { + return this._onHold; + } + + private stopped = false; constructor(private readonly config: WebRTCProcessorConfig) { this.localMediaStream = new MediaStream(); this.remoteMediaStream = new MediaStream(); this.iceGatheringWaiters = new Set(); + this.inputTrack = config.inputTrack; + this.inputTrackWaiter = null; this.localStream = new LocalStream(this.localMediaStream); this.remoteStream = new RemoteStream(this.remoteMediaStream); @@ -50,8 +64,25 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.remoteMediaStream; } + public async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.setInputTrack'); + if (newInputTrack && newInputTrack.kind !== 'audio') { + throw new Error('Unsupported track kind'); + } + + this.inputTrack = newInputTrack; + if (this.inputTrackWaiter && !this.inputTrackWaiter.done) { + this.inputTrackWaiter.promiseResolve(); + } else if (this.localMediaStreamInitialized) { + await this.loadInputTrack(); + } + } + public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }> { this.config.logger?.debug('MediaCallWebRTCProcessor.createOffer'); + if (this.stopped) { + throw new Error('WebRTC Processor has already been stopped.'); + } await this.initializeLocalMediaStream(); if (iceRestart) { @@ -64,8 +95,38 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this.getLocalDescription(); } + public setMuted(muted: boolean): void { + if (this.stopped) { + return; + } + + this._muted = muted; + this.localStream.setMuted(muted || this._onHold); + } + + public setOnHold(onHold: boolean): void { + if (this.stopped) { + return; + } + + this._onHold = onHold; + this.localStream.setMuted(onHold || this._muted); + this.remoteStream.setOnHold(onHold); + } + + public stop(): void { + this.config.logger?.debug('MediaCallWebRTCProcessor.stop'); + + this.stopped = true; + this.localStream.stopAudio(); + this.remoteStream.stopAudio(); + } + public async createAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }> { this.config.logger?.debug('MediaCallWebRTCProcessor.createAnswer'); + if (this.stopped) { + throw new Error('WebRTC Processor has already been stopped.'); + } if (sdp.type !== 'offer') { throw new Error('invalid-webrtc-offer'); } @@ -84,7 +145,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { public async setRemoteDescription({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription'); - await this.initializeLocalMediaStream(); + if (this.stopped) { + return; + } this.peer.setRemoteDescription(sdp); } @@ -107,11 +170,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } } - protected async getuserMedia(constraints: MediaStreamConstraints) { - this.config.logger?.debug('MediaCallWebRTCProcessor.getuserMedia'); - return this.config.mediaStreamFactory(constraints); - } - private changeInternalState(stateName: keyof WebRTCInternalStateMap): void { this.config.logger?.debug('MediaCallWebRTCProcessor.changeInternalState', stateName); this.emitter.emit('internalStateChange', stateName); @@ -119,6 +177,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription'); + if (this.stopped) { + throw new Error('WebRTC Processor has already been stopped.'); + } await this.waitForIceGathering(); // always wait a little extra to ensure all relevant events have been fired @@ -138,7 +199,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private async waitForIceGathering(): Promise { this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering'); - if (this.iceGatheringFinished) { + if (this.iceGatheringFinished || this.stopped) { return; } @@ -147,39 +208,33 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } this.iceGatheringTimedOut = false; - - const data: Partial = {}; - - data.promise = new Promise((resolve, reject) => { - data.promiseResolve = resolve; - data.promiseReject = reject; + const iceGatheringData = getExternalWaiter({ + timeout: this.config.iceGatheringTimeout, + timeoutFn: () => { + if (this.iceGatheringWaiters.has(iceGatheringData)) { + this.clearIceGatheringData(iceGatheringData); + this.iceGatheringTimedOut = true; + this.changeInternalState('iceUntrickler'); + } + }, }); - const iceGatheringData = data as IceGatheringData; - data.timeout = setTimeout(() => { - if (this.iceGatheringWaiters.has(iceGatheringData)) { - this.clearIceGatheringData(iceGatheringData); - this.iceGatheringTimedOut = true; - this.changeInternalState('iceUntrickler'); - } - }, this.config.iceGatheringTimeout); - this.iceGatheringWaiters.add(iceGatheringData); this.changeInternalState('iceUntrickler'); - return data.promise; + return iceGatheringData.promise; } private registerPeerEvents() { const { peer } = this; - peer.ontrack = (event) => this.onTrack(peer, event); - peer.onicecandidate = (event) => this.onIceCandidate(peer, event); - peer.onicecandidateerror = (event) => this.onIceCandidateError(peer, event); - peer.onconnectionstatechange = () => this.onConnectionStateChange(peer); - peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange(peer); - peer.onnegotiationneeded = () => this.onNegotiationNeeded(peer); - peer.onicegatheringstatechange = () => this.onIceGatheringStateChange(peer); - peer.onsignalingstatechange = () => this.onSignalingStateChange(peer); + peer.ontrack = (event) => this.onTrack(event); + peer.onicecandidate = (event) => this.onIceCandidate(event); + peer.onicecandidateerror = (event) => this.onIceCandidateError(event); + peer.onconnectionstatechange = () => this.onConnectionStateChange(); + peer.oniceconnectionstatechange = () => this.onIceConnectionStateChange(); + peer.onnegotiationneeded = () => this.onNegotiationNeeded(); + peer.onicegatheringstatechange = () => this.onIceGatheringStateChange(); + peer.onsignalingstatechange = () => this.onSignalingStateChange(); } private restartIce() { @@ -191,16 +246,16 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.peer.restartIce(); } - private onIceCandidate(peer: RTCPeerConnection, event: RTCPeerConnectionIceEvent) { - if (peer !== this.peer) { + private onIceCandidate(event: RTCPeerConnectionIceEvent) { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', event.candidate); } - private onIceCandidateError(peer: RTCPeerConnection, event: RTCPeerConnectionIceErrorEvent) { - if (peer !== this.peer) { + private onIceCandidateError(event: RTCPeerConnectionIceErrorEvent) { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidateError'); @@ -208,40 +263,40 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.emitter.emit('internalError', { critical: false, error: 'ice-candidate-error' }); } - private onNegotiationNeeded(peer: RTCPeerConnection) { - if (peer !== this.peer) { + private onNegotiationNeeded() { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onNegotiationNeeded'); } - private onTrack(peer: RTCPeerConnection, event: RTCTrackEvent): void { - if (peer !== this.peer) { + private onTrack(event: RTCTrackEvent): void { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onTrack', event.track.kind); // Received a remote stream - this.remoteStream.setTrack(event.track, peer); + this.remoteStream.setTrack(event.track, this.peer); } - private onConnectionStateChange(peer: RTCPeerConnection) { - if (peer !== this.peer) { + private onConnectionStateChange() { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onConnectionStateChange'); this.changeInternalState('connection'); } - private onIceConnectionStateChange(peer: RTCPeerConnection) { - if (peer !== this.peer) { + private onIceConnectionStateChange() { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onIceConnectionStateChange'); this.changeInternalState('iceConnection'); } - private onSignalingStateChange(peer: RTCPeerConnection) { - if (peer !== this.peer) { + private onSignalingStateChange() { + if (this.stopped) { return; } @@ -249,31 +304,80 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.changeInternalState('signaling'); } - private onIceGatheringStateChange(peer: RTCPeerConnection) { - if (peer !== this.peer) { + private onIceGatheringStateChange() { + if (this.stopped) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onIceGatheringStateChange'); - if (peer.iceGatheringState === 'complete') { + if (this.peer.iceGatheringState === 'complete') { this.onIceGatheringComplete(); } this.changeInternalState('iceGathering'); } + private async waitForInputTrack(): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForInputTrack'); + if (this.inputTrack || this.stopped) { + return; + } + + if (this.inputTrackWaiter && !this.inputTrackWaiter.done) { + return this.inputTrackWaiter.promise; + } + + const tracker = getExternalWaiter({ + timeout: 30000, + timeoutFn: () => { + if (this.inputTrack) { + tracker.promiseResolve(); + return; + } + + this.config.logger?.error('MediaCallWebRTCProcessor.waitForInputTrack - Timeout reached with no input track in place.'); + this.emitter.emit('internalError', { critical: true, error: 'no-input-track' }); + }, + cleanupFn: () => { + if (this.inputTrackWaiter === tracker) { + this.inputTrackWaiter = null; + } + }, + }); + this.inputTrackWaiter = tracker; + return this.inputTrackWaiter.promise; + } + private async initializeLocalMediaStream(): Promise { if (this.localMediaStreamInitialized) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.initializeLocalMediaStream'); + const { mediaStreamFactory } = this.config; + + if (mediaStreamFactory) { + const userMedia = await mediaStreamFactory({ audio: true }); + const tracks = userMedia.getAudioTracks(); + + if (!tracks.length) { + this.config.logger?.error('MediaCallWebRTCProcessor.initializeLocalMediaStream - Media stream has no audio tracks.'); + this.emitter.emit('internalError', { critical: true, error: 'no-input-track' }); + throw new Error('Media Stream has no audio tracks.'); + } - const mediaStream = await this.getuserMedia({ audio: true }); + this.inputTrack = tracks[0]; + } + + await this.loadInputTrack(); + } - this.localStream.setStreamTracks(mediaStream, this.peer); + private async loadInputTrack(): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.loadInputTrack'); this.localMediaStreamInitialized = true; + await this.waitForInputTrack(); + await this.localStream.setTrack(this.inputTrack, this.peer); } private onIceGatheringComplete() { @@ -283,7 +387,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.clearIceGatheringWaiters(); } - private clearIceGatheringData(iceGatheringData: IceGatheringData, error?: Error) { + private clearIceGatheringData(iceGatheringData: PromiseWaiterData, error?: Error) { this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringData'); if (this.iceGatheringWaiters.has(iceGatheringData)) { this.iceGatheringWaiters.delete(iceGatheringData); diff --git a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts index e8638b608241b..39c50d182544d 100644 --- a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts @@ -1,20 +1,19 @@ import { Stream } from './Stream'; export class RemoteStream extends Stream { - public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { - if (this.mediaStream.getTrackById(newTrack.id)) { - return; - } + public get onHold(): boolean { + return !this.enabled; + } - if (newTrack.kind !== 'audio') { - return; + public setOnHold(onHold: boolean): void { + if (onHold) { + this.disable(); + } else { + this.enable(); } + } - this.mediaStream.getAudioTracks().forEach((track) => { - track.stop(); - this.mediaStream.removeTrack(track); - }); - - this.mediaStream.addTrack(newTrack); + public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { + this.setAudioTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts index 525f3906593d6..bf69f208ec1e7 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Stream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -1,7 +1,58 @@ export class Stream { protected mediaStream: MediaStream; + protected enabled: boolean; + constructor(mediaStream: MediaStream) { this.mediaStream = mediaStream; + this.enabled = true; + } + + public enable(): void { + this.enabled = true; + this.toggleAudioTracks(); + } + + public disable(): void { + this.enabled = false; + this.toggleAudioTracks(); + } + + public stopAudio(): void { + this.removeAudioTracks(); + } + + protected toggleAudioTracks(): void { + this.mediaStream.getAudioTracks().forEach((track) => { + if (!track) { + return; + } + + track.enabled = this.enabled; + }); + } + + protected removeAudioTracks(): void { + this.mediaStream.getAudioTracks().forEach((track) => { + if (!track) { + return; + } + + track.stop(); + this.mediaStream.removeTrack(track); + }); + } + + protected setAudioTrack(newTrack: MediaStreamTrack): void { + if (newTrack?.kind !== 'audio') { + return; + } + + if (this.mediaStream.getTrackById(newTrack.id)) { + return; + } + + this.removeAudioTracks(); + this.mediaStream.addTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts new file mode 100644 index 0000000000000..6cfc3e34ce128 --- /dev/null +++ b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts @@ -0,0 +1,65 @@ +export type PromiseWaiterData = { + done: boolean; + promise: Promise; + promiseReject: (error: Error) => void; + promiseResolve: () => void; + timeout: ReturnType | null; +}; + +export type PromiseWaiterParams = { + timeout?: number; + timeoutFn?: () => void; + cleanupFn?: () => void; +}; + +export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWaiterParams): PromiseWaiterData { + const data: Partial = { + timeout: null, + done: false, + }; + + const flagAsDone = () => { + if (data.timeout) { + clearTimeout(data.timeout); + data.timeout = null; + } + data.done = true; + cleanupFn?.(); + }; + + data.promise = new Promise((resolve, reject) => { + data.promiseResolve = (...args: unknown[]) => { + if (data.done) { + return; + } + + flagAsDone(); + resolve(...(args as any)); + }; + data.promiseReject = (...args: unknown[]) => { + if (data.done) { + return; + } + flagAsDone(); + reject(...args); + }; + }); + + if (timeout) { + data.timeout = setTimeout(() => { + data.timeout = null; + if (data.done) { + return; + } + flagAsDone(); + + if (timeoutFn) { + timeoutFn(); + } else if (data.promiseReject) { + data.promiseReject(new Error('timeout')); + } + }, timeout); + } + + return data as PromiseWaiterData; +} From 9ed9ca53962d71c54b15bfc2a858fd261715694b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 4 Sep 2025 13:07:19 -0300 Subject: [PATCH 49/72] renamed `onHold` to `held` and improved some comments --- .../src/definition/call/IClientMediaCall.ts | 5 ++-- .../services/webrtc/IWebRTCProcessor.ts | 4 +-- packages/media-signaling/src/lib/Call.ts | 27 ++++++++++--------- .../src/lib/services/webrtc/LocalStream.ts | 12 --------- .../src/lib/services/webrtc/Processor.ts | 16 +++++------ .../src/lib/services/webrtc/RemoteStream.ts | 12 --------- .../src/lib/services/webrtc/Stream.ts | 8 ++++++ 7 files changed, 36 insertions(+), 48 deletions(-) diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 0c35371d66067..b249879166714 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -64,7 +64,8 @@ export interface IClientMediaCall { signed: boolean; hidden: boolean; muted: boolean; - onHold: boolean; + /* if the call was put on hold */ + held: boolean; contact: CallContact; @@ -77,5 +78,5 @@ export interface IClientMediaCall { reject(): void; hangup(): void; setMuted(muted: boolean): void; - setOnHold(onHold: boolean): void; + setHeld(onHold: boolean): void; } diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index 00337d3c26a05..c0ea57068fec3 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -19,10 +19,10 @@ export interface IWebRTCProcessor extends IServiceProcessor; muted: boolean; - onHold: boolean; + held: boolean; setMuted(muted: boolean): void; - setOnHold(onHold: boolean): void; + setHeld(held: boolean): void; stop(): void; setInputTrack(newInputTrack: MediaStreamTrack | null): Promise; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 319f23709d7ab..8c259d1613e01 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -99,12 +99,13 @@ export class ClientMediaCall implements IClientMediaCall { return this.webrtcProcessor.muted; } - public get onHold(): boolean { + /** indicates if the call is on hold */ + public get held(): boolean { if (!this.webrtcProcessor) { return false; } - return this.webrtcProcessor.onHold; + return this.webrtcProcessor.held; } protected webrtcProcessor: IWebRTCProcessor | null = null; @@ -141,7 +142,7 @@ export class ClientMediaCall implements IClientMediaCall { private inputTrack: MediaStreamTrack | null; - // localCallId will only be different on calls initiated by this session + /** localCallId will only be different on calls initiated by this session */ private localCallId: string; constructor( @@ -178,8 +179,10 @@ export class ClientMediaCall implements IClientMediaCall { this._service = null; } - // Initialize an outbound call with basic contact information until we receive the full call details from the server; - // this gets executed once for outbound calls initiated in this session. + /** + * Initialize an outbound call with basic contact information until we receive the full call details from the server; + * this gets executed once for outbound calls initiated in this session. + */ public async initializeOutboundCall(contact: CallContact): Promise { if (this.acceptedLocally) { return; @@ -204,7 +207,7 @@ export class ClientMediaCall implements IClientMediaCall { } } - // Initialize an outbound call with the callee information and send a call request to the server + /** Initialize an outbound call with the callee information and send a call request to the server */ public async requestCall(callee: { type: CallActorType; id: string }, contactInfo?: CallContact): Promise { if (this.initialized) { return; @@ -220,7 +223,7 @@ export class ClientMediaCall implements IClientMediaCall { return this.initializeOutboundCall({ ...contactInfo, ...callee }); } - // initialize a call with the data received from the server on a 'new' signal; this gets executed once for every call + /** initialize a call with the data received from the server on a 'new' signal; this gets executed once for every call */ public async initializeRemoteCall(signal: ServerMediaSignalNewCall): Promise { if (this.hasRemoteData) { return; @@ -458,18 +461,18 @@ export class ClientMediaCall implements IClientMediaCall { } } - public setOnHold(onHold: boolean): void { + public setHeld(held: boolean): void { if (this.isOver()) { return; } - if (!this.webrtcProcessor && !onHold) { + if (!this.webrtcProcessor && !held) { return; } this.requireWebRTC(); - const wasOnHold = this.webrtcProcessor.onHold; - this.webrtcProcessor.setOnHold(onHold); - if (wasOnHold !== this.webrtcProcessor.onHold) { + const wasOnHold = this.webrtcProcessor.held; + this.webrtcProcessor.setHeld(held); + if (wasOnHold !== this.webrtcProcessor.held) { this.emitter.emit('trackStateChange'); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index f1f93ed2daab0..fc09fde288f96 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,18 +1,6 @@ import { Stream } from './Stream'; export class LocalStream extends Stream { - public get muted(): boolean { - return !this.enabled; - } - - public setMuted(muted: boolean): void { - if (muted) { - this.disable(); - } else { - this.enable(); - } - } - public async setTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { if (newTrack) { const matchingTrack = this.mediaStream.getTrackById(newTrack.id); diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index d4c5029dbee02..23a01c10d1c6c 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -37,10 +37,10 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return this._muted; } - private _onHold = false; + private _held = false; - public get onHold(): boolean { - return this._onHold; + public get held(): boolean { + return this._held; } private stopped = false; @@ -101,17 +101,17 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } this._muted = muted; - this.localStream.setMuted(muted || this._onHold); + this.localStream.setEnabled(!muted && !this._held); } - public setOnHold(onHold: boolean): void { + public setHeld(held: boolean): void { if (this.stopped) { return; } - this._onHold = onHold; - this.localStream.setMuted(onHold || this._muted); - this.remoteStream.setOnHold(onHold); + this._held = held; + this.localStream.setEnabled(!held && !this._muted); + this.remoteStream.setEnabled(!held); } public stop(): void { diff --git a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts index 39c50d182544d..dd0e44731d0b0 100644 --- a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts @@ -1,18 +1,6 @@ import { Stream } from './Stream'; export class RemoteStream extends Stream { - public get onHold(): boolean { - return !this.enabled; - } - - public setOnHold(onHold: boolean): void { - if (onHold) { - this.disable(); - } else { - this.enable(); - } - } - public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { this.setAudioTrack(newTrack); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts index bf69f208ec1e7..8f059d2d3fe6b 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Stream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -18,6 +18,14 @@ export class Stream { this.toggleAudioTracks(); } + public setEnabled(enabled: boolean): void { + if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + public stopAudio(): void { this.removeAudioTracks(); } From 1cf44bb63abea5ce77cfce94387478fda05a0e66 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 4 Sep 2025 14:35:25 -0300 Subject: [PATCH 50/72] allow passing the inputTrack as param when creating a new call; improved events and comments --- .../src/definition/call/CallEvents.ts | 10 ++++ packages/media-signaling/src/lib/Call.ts | 19 ++++---- packages/media-signaling/src/lib/Session.ts | 48 ++++++++++++++++--- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 5a61a247a56aa..09959c8acae89 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -2,17 +2,27 @@ import type { ClientState } from '../client'; import type { CallState } from './IClientMediaCall'; export type CallEvents = { + /* Triggered when the call's server state is changed on this client */ stateChange: CallState; + /* Triggered when the call's client state is changed (based on user interaction or internal service state) */ clientStateChange: ClientState; + /* Triggered when an audio track is changed, muted or put on hold */ trackStateChange: void; + /* Triggered when the contact information for the remote user is updated */ contactUpdate: void; + /* Triggered as soon as the call is registered on this client */ initialized: void; + /* Triggered when this client is telling the server that we want to accept the call */ accepting: void; + /* Triggered when the call's state on the server changes to 'accepted' */ accepted: void; + /* Triggered when we detect a stable webrtc connection on our side or the other side reports one to the server */ active: void; + /* Triggered when this client puts the call on the ignore list, either because the call was accepted on a different client or because you called `call.ignore()` */ hidden: void; + /* Triggered when the call's state on the server changes to 'hangup' */ ended: void; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 8c259d1613e01..67051fe78ea61 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -148,6 +148,7 @@ export class ClientMediaCall implements IClientMediaCall { constructor( private readonly config: IClientMediaCallConfig, callId: string, + { inputTrack }: { inputTrack: MediaStreamTrack | null }, ) { this.emitter = new Emitter(); @@ -167,7 +168,7 @@ export class ClientMediaCall implements IClientMediaCall { this.serviceStates = new Map(); this.stateReporterTimeoutHandler = null; this.mayReportStates = true; - this.inputTrack = null; + this.inputTrack = inputTrack; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); @@ -310,10 +311,17 @@ export class ClientMediaCall implements IClientMediaCall { } public async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { + const oldId = this.inputTrack?.id; + const newId = newInputTrack?.id; + this.inputTrack = newInputTrack; if (this.webrtcProcessor) { await this.webrtcProcessor.setInputTrack(newInputTrack); } + + if (oldId !== newId) { + this.emitter.emit('trackStateChange'); + } } public getRemoteMediaStream(): MediaStream { @@ -976,11 +984,6 @@ export class ClientMediaCall implements IClientMediaCall { } } -export class ClientMediaCallWebRTC extends ClientMediaCall { - public webrtcProcessor: IWebRTCProcessor; - - constructor(config: IClientMediaCallConfig, callId: string) { - super(config, callId); - throw new Error('ClientMediaCallWebRTC is not meant to be constructed.'); - } +export abstract class ClientMediaCallWebRTC extends ClientMediaCall { + public abstract webrtcProcessor: IWebRTCProcessor; } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 47a5cb99a9f04..45d0ab182bd63 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -14,9 +14,11 @@ import type { IClientMediaCall, CallState, CallActorType, CallContact } from '.. import type { IMediaSignalLogger } from '../definition/logger'; export type MediaSignalingEvents = { + sessionStateChange: void; callContactUpdate: { call: IClientMediaCall }; callStateChange: { call: IClientMediaCall; oldState: CallState }; callClientStateChange: { call: IClientMediaCall; oldState: ClientState }; + callTrackStateChange: { call: IClientMediaCall }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; activeCall: { call: IClientMediaCall }; @@ -147,10 +149,16 @@ export class MediaSignalingSession extends Emitter { await call.processSignal(signal); } - public async startCall(calleeType: CallActorType, calleeId: string, contactInfo?: CallContact): Promise { + public async startCall( + calleeType: CallActorType, + calleeId: string, + params: { contactInfo?: CallContact; inputTrack: MediaStreamTrack | null }, + ): Promise { this.config.logger?.debug('MediaSignalingSession.startCall', calleeId); + const { contactInfo, ...callParams } = params; + const callId = this.createTemporaryCallId(); - const call = this.createCall(callId); + const call = this.createCall(callId, callParams); await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } @@ -216,7 +224,7 @@ export class MediaSignalingSession extends Emitter { return existingCall; } - return this.createCall(signal.callId); + return this.createCall(signal.callId, { inputTrack: null }); } private reportState(): void { @@ -238,7 +246,7 @@ export class MediaSignalingSession extends Emitter { }); } - private createCall(callId: string): ClientMediaCall { + private createCall(callId: string, { inputTrack }: { inputTrack: MediaStreamTrack | null }): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { logger: this.config.logger, @@ -248,14 +256,16 @@ export class MediaSignalingSession extends Emitter { iceGatheringTimeout: this.config.iceGatheringTimeout || 500, }; - const call = new ClientMediaCall(config, callId); + const call = new ClientMediaCall(config, callId, { inputTrack }); this.knownCalls.set(callId, call); call.emitter.on('contactUpdate', () => this.onCallContactUpdate(call)); call.emitter.on('stateChange', (oldState) => this.onCallStateChange(call, oldState)); call.emitter.on('clientStateChange', (oldState) => this.onCallClientStateChange(call, oldState)); + call.emitter.on('trackStateChange', () => this.onTrackStateChange(call)); call.emitter.on('initialized', () => this.onNewCall(call)); call.emitter.on('accepted', () => this.onAcceptedCall(call)); + call.emitter.on('accepting', () => this.onAcceptingCall(call)); call.emitter.on('hidden', () => this.onHiddenCall(call)); call.emitter.on('active', () => this.onActiveCall(call)); call.emitter.on('ended', () => this.onEndedCall(call)); @@ -270,10 +280,12 @@ export class MediaSignalingSession extends Emitter { } this.emit('callContactUpdate', { call }); + this.onSessionStateChange(); } private onCallStateChange(call: ClientMediaCall, oldState: CallState): void { this.config.logger?.debug('MediaSignalingSession.onCallStateChange'); + this.onSessionStateChange(); if (call.hidden && call.state !== 'hangup') { return; } @@ -283,6 +295,7 @@ export class MediaSignalingSession extends Emitter { private onCallClientStateChange(call: ClientMediaCall, oldState: ClientState): void { this.config.logger?.debug('MediaSignalingSession.onCallClientStateChange'); + this.onSessionStateChange(); if (call.hidden && call.state !== 'hangup') { return; } @@ -292,6 +305,7 @@ export class MediaSignalingSession extends Emitter { private onNewCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onNewCall'); + this.onSessionStateChange(); if (call.hidden) { return; } @@ -301,6 +315,7 @@ export class MediaSignalingSession extends Emitter { private onAcceptedCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onAcceptedCall'); + this.onSessionStateChange(); if (call.hidden) { return; } @@ -308,19 +323,40 @@ export class MediaSignalingSession extends Emitter { this.emit('acceptedCall', { call }); } + private onAcceptingCall(_call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onAcceptingCall'); + this.onSessionStateChange(); + } + + private onTrackStateChange(call: ClientMediaCall): void { + this.config.logger?.debug('MediaSignalingSession.onTrackStateChange'); + this.onSessionStateChange(); + this.emit('callTrackStateChange', { call }); + } + private onEndedCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onEndedCall'); this.ignoreCall(call.callId); + this.onSessionStateChange(); this.emit('endedCall', { call }); } private onHiddenCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onHiddenCall'); - this.emit('hiddenCall', { call }); + this.onSessionStateChange(); + + if (!call.isOver()) { + this.emit('hiddenCall', { call }); + } } private onActiveCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onActiveCall'); + this.onSessionStateChange(); this.emit('activeCall', { call }); } + + private onSessionStateChange(): void { + this.emit('sessionStateChange'); + } } From 246f70d126f4beca76d39b4e13f82174d00c7140 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:02:30 -0300 Subject: [PATCH 51/72] feat: webrtc renegotiation (#36874) --- apps/meteor/server/models.ts | 2 + ee/packages/media-calls/src/base/BaseAgent.ts | 2 +- .../src/definition/IMediaCallAgent.ts | 4 +- .../src/internal/SignalProcessor.ts | 15 +-- .../src/internal/agents/BaseUserAgent.ts | 62 ++++++++++- .../internal/agents/CallSignalProcessor.ts | 68 +++++++----- .../src/internal/agents/CalleeAgent.ts | 25 +---- .../src/internal/agents/CallerAgent.ts | 17 --- .../media-calls/src/server/CallDirector.ts | 100 +++++++++++------ .../core-typings/src/mediaCalls/IMediaCall.ts | 3 - .../src/mediaCalls/IMediaCallNegotiation.ts | 15 +++ packages/core-typings/src/mediaCalls/index.ts | 1 + .../src/definition/call/IClientMediaCall.ts | 3 + .../media-signaling/src/definition/client.ts | 3 + .../definition/services/IServiceProcessor.ts | 1 + .../services/webrtc/IWebRTCProcessor.ts | 3 +- .../definition/signals/client/MediaSignal.ts | 5 +- .../src/definition/signals/client/error.ts | 5 + .../src/definition/signals/client/index.ts | 1 + .../definition/signals/client/local-sdp.ts | 7 +- .../definition/signals/client/local-state.ts | 5 + .../signals/client/negotiation-needed.ts | 34 ++++++ .../definition/signals/server/remote-sdp.ts | 1 + .../signals/server/request-offer.ts | 2 +- packages/media-signaling/src/lib/Call.ts | 75 +++++++++++-- packages/media-signaling/src/lib/Session.ts | 9 +- .../src/lib/TransportWrapper.ts | 12 +- packages/media-signaling/src/lib/index.ts | 1 + .../src/lib/services/states.ts | 11 ++ .../src/lib/services/webrtc/Processor.ts | 40 ++++--- .../src/lib/utils/getExternalWaiter.ts | 10 +- packages/model-typings/src/index.ts | 1 + .../src/models/IMediaCallNegotiationsModel.ts | 22 ++++ .../src/models/IMediaCallsModel.ts | 2 - packages/models/src/index.ts | 2 + packages/models/src/modelClasses.ts | 1 + .../src/models/MediaCallNegotiations.ts | 103 ++++++++++++++++++ packages/models/src/models/MediaCalls.ts | 24 ---- 38 files changed, 519 insertions(+), 178 deletions(-) create mode 100644 packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts create mode 100644 packages/media-signaling/src/definition/signals/client/negotiation-needed.ts create mode 100644 packages/media-signaling/src/lib/services/states.ts create mode 100644 packages/model-typings/src/models/IMediaCallNegotiationsModel.ts create mode 100644 packages/models/src/models/MediaCallNegotiations.ts diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 81c453385dbf9..5c383a2d9f880 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -45,6 +45,7 @@ import { MatrixBridgedUserRaw, MediaCallsRaw, MediaCallChannelsRaw, + MediaCallNegotiationsRaw, MessageReadsRaw, MessagesRaw, MigrationsRaw, @@ -138,6 +139,7 @@ registerModel('IMatrixBridgedRoomModel', new MatrixBridgedRoomRaw(db)); registerModel('IMatrixBridgedUserModel', new MatrixBridgedUserRaw(db)); registerModel('IMediaCallsModel', new MediaCallsRaw(db)); registerModel('IMediaCallChannelsModel', new MediaCallChannelsRaw(db)); +registerModel('IMediaCallNegotiationsModel', new MediaCallNegotiationsRaw(db)); registerModel('IMessageReadsModel', new MessageReadsRaw(db)); registerModel('IMessagesModel', new MessagesRaw(db, trashCollection)); registerModel('IMigrationsModel', new MigrationsRaw(db)); diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index b9336ddb8c18e..e5b798bbc3ca5 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -73,7 +73,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onCallCreated(call: IMediaCall): Promise; - public abstract onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; + public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; protected async createOrUpdateChannel(call: IMediaCall, contractId: string): Promise { if (!contractId) { diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index e0ac7a8dab324..830fd823d8f89 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -15,8 +15,8 @@ export interface IMediaCallAgent { onCallAccepted(callId: string, signedContractId: string): Promise; onCallActive(callId: string): Promise; onCallCreated(call: IMediaCall): Promise; - /* Called when the sdp of the other actor is available, regardless of call state */ - onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise; + /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ + onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; getMyCallActor(call: IMediaCall): MediaCallActor; } diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 2479c57782d81..c2c90bab0e34e 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -1,11 +1,12 @@ import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; -import type { - ClientMediaSignal, - ClientMediaSignalRegister, - ClientMediaSignalRequestCall, - ServerMediaSignal, - ServerMediaSignalRejectedCallRequest, +import { + isPendingState, + type ClientMediaSignal, + type ClientMediaSignalRegister, + type ClientMediaSignalRequestCall, + type ServerMediaSignal, + type ServerMediaSignalRejectedCallRequest, } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; @@ -221,7 +222,7 @@ export class GlobalSignalProcessor { } // if the call is already accepted, we won't send its signals again - if (['active'].includes(call.state)) { + if (!isPendingState(call.state)) { this.invalidCallId(uid, { ...rejection, reason: 'already-requested' }); } diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts index 96c9b5033ed88..9f6d5325fbc39 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts @@ -1,5 +1,6 @@ import type { IMediaCall } from '@rocket.chat/core-typings'; -import type { ClientMediaSignal, ServerMediaSignal, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; +import { isBusyState, type ClientMediaSignal, type ServerMediaSignal, type ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; +import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { UserActorSignalProcessor } from './CallSignalProcessor'; import { BaseMediaCallAgent } from '../../base/BaseAgent'; @@ -55,6 +56,65 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { await this.sendSignal(this.buildNewCallSignal(call)); } + public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onRemoteDescriptionChanged', callId, negotiationId }); + + const call = await MediaCalls.findOneById(callId); + if (!call || !isBusyState(call.state)) { + return; + } + + const actor = this.getMyCallActor(call); + + const toContractId = actor.contractId; + // Do not send any sdp to an actor until they have a signed contract + if (!toContractId) { + return; + } + + const negotiation = await MediaCallNegotiations.findOneById(negotiationId); + if (!negotiation) { + return; + } + + if (negotiation.offerer === this.role) { + if (!negotiation.offer) { + await this.sendSignal({ + callId, + toContractId, + type: 'request-offer', + negotiationId, + }); + return; + } + + if (!negotiation.answer) { + return; + } + + await this.sendSignal({ + callId, + toContractId, + type: 'remote-sdp', + sdp: negotiation.answer, + negotiationId, + }); + return; + } + + if (!negotiation.offer) { + return; + } + + await this.sendSignal({ + callId, + toContractId, + type: 'remote-sdp', + sdp: negotiation.offer, + negotiationId, + }); + } + protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { return { callId: call._id, diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index b78dd852570f0..e5e3898526eb7 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -1,14 +1,15 @@ import type { IMediaCall, IMediaCallChannel, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; -import type { - CallAnswer, - CallHangupReason, - CallRole, - ClientMediaSignal, - ClientMediaSignalError, - ClientMediaSignalLocalState, - ServerMediaSignal, +import { + isPendingState, + type CallAnswer, + type CallHangupReason, + type CallRole, + type ClientMediaSignal, + type ClientMediaSignalError, + type ClientMediaSignalLocalState, + type ServerMediaSignal, } from '@rocket.chat/media-signaling'; -import { MediaCallChannels, MediaCalls } from '@rocket.chat/models'; +import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; @@ -59,18 +60,8 @@ export class UserActorSignalProcessor { this.ignored = Boolean(actor.contractId && actor.contractId !== channel.contractId); } - public async setRemoteDescription(sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.setRemoteDescription', sdp }); - await this.sendSignal({ - callId: this.callId, - toContractId: this.contractId, - type: 'remote-sdp', - sdp, - }); - } - - public async requestWebRTCOffer(params: { iceRestart?: boolean }): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.requestOffer', actor: this.actor }); + public async requestWebRTCOffer(params: { negotiationId: string }): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.requestWebRTCOffer', actor: this.actor }); await this.sendSignal({ callId: this.callId, @@ -89,7 +80,7 @@ export class UserActorSignalProcessor { // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee switch (signal.type) { case 'local-sdp': - return this.saveLocalDescription(signal.sdp); + return this.saveLocalDescription(signal.sdp, signal.negotiationId); case 'answer': return this.processAnswer(signal.answer); case 'hangup': @@ -98,6 +89,8 @@ export class UserActorSignalProcessor { return this.reviewLocalState(signal); case 'error': return this.processError(signal.errorType, signal.errorCode); + case 'negotiation-needed': + return this.processNegotiationNeeded(signal.oldNegotiationId); } } @@ -105,10 +98,10 @@ export class UserActorSignalProcessor { return MediaCallDirector.hangup(this.call, this.agent, reason); } - protected async saveLocalDescription(sdp: RTCSessionDescriptionInit): Promise { + protected async saveLocalDescription(sdp: RTCSessionDescriptionInit, negotiationId: string): Promise { logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp }); - await MediaCallDirector.saveWebrtcSession(this.call, this.agent, sdp, this.contractId); + await MediaCallDirector.saveWebrtcSession(this.call, this.agent, { sdp, negotiationId }, this.contractId); } private async processAnswer(answer: CallAnswer): Promise { @@ -141,6 +134,20 @@ export class UserActorSignalProcessor { } } + private async processNegotiationNeeded(oldNegotiationId: string): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.processNegotiationNeeded', oldNegotiationId }); + const negotiation = await MediaCallNegotiations.findLatestByCallId(this.callId); + // If the negotiation that triggered a request for renegotiation is not the latest negotiation, then a new one must already be happening and we can ignore this request. + if (negotiation?._id !== oldNegotiationId) { + return; + } + + const negotiationId = await MediaCallDirector.startNewNegotiation(this.call, this.role); + if (negotiationId) { + await this.requestWebRTCOffer({ negotiationId }); + } + } + protected async clientIsReachable(): Promise { logger.debug({ msg: 'UserActorSignalProcessor.clientIsReachable', role: this.role, uid: this.actorId }); @@ -151,8 +158,11 @@ export class UserActorSignalProcessor { // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state if (this.role === 'caller' && this.signed) { - // When the signed caller's client is reached, we immediatelly send the first offer request - await this.requestWebRTCOffer({ iceRestart: false }); + // When the signed caller's client is reached, we immediatelly start the first negotiation + const negotiationId = await MediaCallDirector.startFirstNegotiation(this.call); + if (negotiationId) { + await this.requestWebRTCOffer({ negotiationId }); + } } } @@ -194,7 +204,7 @@ export class UserActorSignalProcessor { } protected isCallPending(): boolean { - return ['none', 'ringing'].includes(this.call.state); + return isPendingState(this.call.state); } protected isPastNegotiation(): boolean { @@ -207,6 +217,10 @@ export class UserActorSignalProcessor { } if (signal.clientState === 'active') { + if (signal.negotiationId) { + void MediaCallNegotiations.setStableById(signal.negotiationId).catch(() => null); + } + if (this.channel.state === 'active' || this.channel.activeAt) { return; } diff --git a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts index f2eb19f53c963..f35ea3a98a6fa 100644 --- a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts @@ -1,5 +1,5 @@ import type { MediaCallContact } from '@rocket.chat/core-typings'; -import { MediaCalls } from '@rocket.chat/models'; +import { MediaCallNegotiations } from '@rocket.chat/models'; import { UserActorAgent } from './BaseUserAgent'; import { logger } from '../../logger'; @@ -12,8 +12,8 @@ export class UserActorCalleeAgent extends UserActorAgent { public async onCallAccepted(callId: string, signedContractId: string): Promise { await super.onCallAccepted(callId, signedContractId); - const call = await MediaCalls.findOneById(callId); - if (!call?.webrtcOffer) { + const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); + if (!negotiation?.offer) { logger.debug('The call was accepted but the webrtc offer is not yet available.'); return; } @@ -22,23 +22,8 @@ export class UserActorCalleeAgent extends UserActorAgent { callId, toContractId: signedContractId, type: 'remote-sdp', - sdp: call.webrtcOffer, - }); - } - - public async onRemoteDescriptionChanged(callId: string, description: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserActorCalleeAgent.onRemoteDescriptionChanged', callId }); - - const call = await MediaCalls.findOneById(callId); - if (call?.state !== 'accepted' || !call.webrtcOffer || !call.callee.contractId) { - return; - } - - await this.sendSignal({ - callId, - toContractId: call.callee.contractId, - type: 'remote-sdp', - sdp: description, + sdp: negotiation.offer, + negotiationId: negotiation._id, }); } } diff --git a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts index 077f5e8cf0d0f..ba6d125af0e48 100644 --- a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts @@ -1,6 +1,5 @@ import type { IMediaCall, MediaCallContact } from '@rocket.chat/core-typings'; import type { ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; import { UserActorAgent } from './BaseUserAgent'; import { logger } from '../../logger'; @@ -31,20 +30,4 @@ export class UserActorCallerAgent extends UserActorAgent { ...(call.callerRequestedId && { requestedCallId: call.callerRequestedId }), }; } - - public async onRemoteDescriptionChanged(callId: string, _description: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'UserActorCallerAgent.onRemoteDescriptionChanged', callId }); - const call = await MediaCalls.findOneById(callId); - - if (call?.state !== 'accepted' || !call.webrtcAnswer) { - return; - } - - await this.sendSignal({ - callId, - toContractId: call.caller.contractId, - type: 'remote-sdp', - sdp: call.webrtcAnswer, - }); - } } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index ae26df525219d..f808014137126 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -1,6 +1,14 @@ -import type { AtLeast, IMediaCall, MediaCallActor, MediaCallSignedActor, ServerActor } from '@rocket.chat/core-typings'; -import type { CallHangupReason, CallService } from '@rocket.chat/media-signaling'; -import { MediaCalls } from '@rocket.chat/models'; +import type { + AtLeast, + IMediaCall, + IMediaCallNegotiation, + MediaCallActor, + MediaCallSignedActor, + ServerActor, +} from '@rocket.chat/core-typings'; +import type { CallHangupReason, CallRole, CallService } from '@rocket.chat/media-signaling'; +import type { InsertionModel } from '@rocket.chat/model-typings'; +import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector } from './injection'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; @@ -77,6 +85,10 @@ export class MediaCallDirector { ): Promise { logger.debug({ msg: 'MediaCallDirector.acceptCall' }); + // To avoid race conditions, load the negotiation before changing the call state + // Once the state changes, negotiations need to be referred by id. + const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id); + const stateResult = await MediaCalls.acceptCallById(call._id, data, this.getNewExpirationTime()); // If nothing changed, the call was no longer ringing if (!stateResult.modifiedCount) { @@ -86,13 +98,46 @@ export class MediaCallDirector { await calleeAgent.onCallAccepted(call._id, data.calleeContractId); await calleeAgent.oppositeAgent?.onCallAccepted(call._id, call.caller.contractId); - if (data.webrtcAnswer) { - await calleeAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, data.webrtcAnswer); + if (data.webrtcAnswer && negotiation) { + await MediaCallNegotiations.setAnswerById(negotiation._id, data.webrtcAnswer); + await calleeAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, negotiation._id); } return true; } + public static async startFirstNegotiation( + call: MediaCallHeader, + offer?: RTCSessionDescriptionInit, + ): Promise { + const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id); + // If the call already has a negotiation, do nothing + if (negotiation) { + return null; + } + + return this.startNewNegotiation(call, 'caller', offer); + } + + public static async startNewNegotiation( + call: MediaCallHeader, + offerer: CallRole, + offer?: RTCSessionDescriptionInit, + ): Promise { + const newNegotiation: InsertionModel = { + callId: call._id, + offerer, + requestTimestamp: new Date(), + ...(offer && { + offer, + offerTimestamp: new Date(), + }), + }; + + const result = await MediaCallNegotiations.insertOne(newNegotiation); + return result.insertedId; + } + public static get cast(): IMediaCallCastDirector { try { return getCastDirector(); @@ -105,48 +150,37 @@ export class MediaCallDirector { public static async saveWebrtcSession( call: IMediaCall, fromAgent: IMediaCallAgent, - sdp: RTCSessionDescriptionInit, + session: { sdp: RTCSessionDescriptionInit; negotiationId: string }, contractId: string, ): Promise { - if (sdp.type === 'offer') { - if (fromAgent.role !== 'caller') { - throw new Error('invalid-sdp'); - } - if (contractId !== call.caller.contractId) { - throw new Error('invalid-contract'); - } - - return this.saveWebrtcOffer(call, fromAgent, sdp); + logger.debug({ msg: 'MediaCallDirector.saveWebrtcSession', callId: call?._id }); + const negotiation = await MediaCallNegotiations.findOneById(session.negotiationId); + if (!negotiation) { + throw new Error('invalid-negotiation'); } - if (fromAgent.role !== 'callee') { - throw new Error('invalid-sdp'); - } - if (contractId !== call.callee.contractId || !call.callee.contractId) { + const actor = fromAgent.getMyCallActor(call); + if (!actor.contractId || actor.contractId !== contractId) { throw new Error('invalid-contract'); } - return this.saveWebrtcAnswer(call, fromAgent, sdp); - } + const isOfferer = fromAgent.role === negotiation.offerer; + const isOffer = session.sdp.type === 'offer'; - private static async saveWebrtcOffer(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'MediaCallDirector.saveWebrtcOffer', callId: call?._id }); - const result = await MediaCalls.setWebrtcOfferById(call._id, sdp, this.getNewExpirationTime()); - if (!result.modifiedCount) { - return; + if (isOffer !== isOfferer) { + throw new Error('invalid-sdp'); } - await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); - } + const updater = isOffer + ? MediaCallNegotiations.setOfferById(negotiation._id, session.sdp) + : MediaCallNegotiations.setAnswerById(negotiation._id, session.sdp); + const updateResult = await updater; - private static async saveWebrtcAnswer(call: IMediaCall, fromAgent: IMediaCallAgent, sdp: RTCSessionDescriptionInit): Promise { - logger.debug({ msg: 'MediaCallDirector.saveWebrtcAnswer', callId: call?._id }); - const result = await MediaCalls.setWebrtcAnswerById(call._id, sdp, this.getNewExpirationTime()); - if (!result.modifiedCount) { + if (!updateResult.modifiedCount) { return; } - await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, sdp); + await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, negotiation._id); } public static async createCall(params: CreateCallParams): Promise { diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 665ac859e7ab2..13aa2a1b69ac0 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -49,7 +49,4 @@ export interface IMediaCall extends IRocketChatRecord { expiresAt: Date; callerRequestedId?: string; - - webrtcOffer?: RTCSessionDescriptionInit; - webrtcAnswer?: RTCSessionDescriptionInit; } diff --git a/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts b/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts new file mode 100644 index 0000000000000..0010482e32b61 --- /dev/null +++ b/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts @@ -0,0 +1,15 @@ +import type { IRocketChatRecord } from '../IRocketChatRecord'; + +export interface IMediaCallNegotiation extends IRocketChatRecord { + callId: string; + + offerer: 'caller' | 'callee'; + + requestTimestamp: Date; + offerTimestamp?: Date; + answerTimestamp?: Date; + stableTimestamp?: Date; + + offer?: RTCSessionDescriptionInit; + answer?: RTCSessionDescriptionInit; +} diff --git a/packages/core-typings/src/mediaCalls/index.ts b/packages/core-typings/src/mediaCalls/index.ts index 5a855f34938b2..8deaea84c2d23 100644 --- a/packages/core-typings/src/mediaCalls/index.ts +++ b/packages/core-typings/src/mediaCalls/index.ts @@ -1,2 +1,3 @@ export * from './IMediaCall'; export * from './IMediaCallChannel'; +export * from './IMediaCallNegotiation'; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index b249879166714..3a59ef51d2559 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -23,6 +23,7 @@ export type CallState = | 'ringing' // call has been acknoledged by the callee's agent, but no response about them accepting it or not | 'accepted' // call has been accepted and the webrtc offer is being exchanged | 'active' // webrtc connection has been established + | 'renegotiating' // a webrtc connection had been established before, but a new one is being negotiated | 'hangup'; // call is over export type CallHangupReason = @@ -66,6 +67,8 @@ export interface IClientMediaCall { muted: boolean; /* if the call was put on hold */ held: boolean; + /* busy = state >= 'accepted' && state < 'hangup' */ + busy: boolean; contact: CallContact; diff --git a/packages/media-signaling/src/definition/client.ts b/packages/media-signaling/src/definition/client.ts index 8f0632baf649e..73355bb0fd740 100644 --- a/packages/media-signaling/src/definition/client.ts +++ b/packages/media-signaling/src/definition/client.ts @@ -7,6 +7,9 @@ export type ClientState = | 'has-offer' // The call was accepted and the client has a webrtc offer | 'has-answer' // The call was accepted and the client has a webrtc offer and answer | 'active' // The webrtc call was established + | 'renegotiating' // the webrtc call was established but the client is starting a new negotiation + | 'has-new-offer' // The webrtc call was established but the client has an offer for a new negotiation + | 'has-new-answer' // The webrtc call was established but the client is finishing a renegotiation | 'hangup'; // The call is over, or happening in some other client export type ClientContractState = diff --git a/packages/media-signaling/src/definition/services/IServiceProcessor.ts b/packages/media-signaling/src/definition/services/IServiceProcessor.ts index ed222452264b3..dc9271e6ff25a 100644 --- a/packages/media-signaling/src/definition/services/IServiceProcessor.ts +++ b/packages/media-signaling/src/definition/services/IServiceProcessor.ts @@ -15,6 +15,7 @@ export type ServiceProcessorEvents { diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index c0ea57068fec3..ff96fb3cc286d 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -26,9 +26,10 @@ export interface IWebRTCProcessor extends IServiceProcessor; + startNewNegotiation(): Promise; createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; - setRemoteDescription(params: { sdp: RTCSessionDescriptionInit }): Promise; + setRemoteAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise; getRemoteMediaStream(): MediaStream; } diff --git a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts index 306e66d63c38f..9514f1ab98d9d 100644 --- a/packages/media-signaling/src/definition/signals/client/MediaSignal.ts +++ b/packages/media-signaling/src/definition/signals/client/MediaSignal.ts @@ -5,6 +5,7 @@ import { clientMediaSignalErrorSchema, type ClientMediaSignalError } from './err import { clientMediaSignalHangupSchema, type ClientMediaSignalHangup } from './hangup'; import { clientMediaSignalLocalSDPSchema, type ClientMediaSignalLocalSDP } from './local-sdp'; import { clientMediaSignalLocalStateSchema, type ClientMediaSignalLocalState } from './local-state'; +import { clientMediaSignalNegotiationNeededSchema, type ClientMediaSignalNegotiationNeeded } from './negotiation-needed'; import { clientMediaSignalRegisterSchema, type ClientMediaSignalRegister } from './register'; import { clientMediaSignalRequestCallSchema, type ClientMediaSignalRequestCall } from './request-call'; @@ -17,7 +18,8 @@ export type ClientMediaSignal = | ClientMediaSignalHangup | ClientMediaSignalRequestCall | ClientMediaSignalLocalState - | ClientMediaSignalRegister; + | ClientMediaSignalRegister + | ClientMediaSignalNegotiationNeeded; export const clientMediaSignalSchema: JSONSchemaType = { type: 'object', @@ -32,6 +34,7 @@ export const clientMediaSignalSchema: JSONSchemaType = { clientMediaSignalRequestCallSchema, clientMediaSignalLocalStateSchema, clientMediaSignalRegisterSchema, + clientMediaSignalNegotiationNeededSchema, ], }; diff --git a/packages/media-signaling/src/definition/signals/client/error.ts b/packages/media-signaling/src/definition/signals/client/error.ts index e3e5084e63728..b2bf57843d955 100644 --- a/packages/media-signaling/src/definition/signals/client/error.ts +++ b/packages/media-signaling/src/definition/signals/client/error.ts @@ -8,6 +8,7 @@ export type ClientMediaSignalError = { errorType?: 'signaling' | 'service' | 'other'; errorCode?: string; + negotiationId?: string; }; export const clientMediaSignalErrorSchema: JSONSchemaType = { @@ -33,6 +34,10 @@ export const clientMediaSignalErrorSchema: JSONSchemaType = { @@ -40,7 +41,11 @@ export const clientMediaSignalLocalSDPSchema: JSONSchemaType; ignored?: boolean; contractState: ClientContractState; + negotiationId?: string; }; export const clientMediaSignalLocalStateSchema: JSONSchemaType = { @@ -57,6 +58,10 @@ export const clientMediaSignalLocalStateSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + type: 'string', + const: 'negotiation-needed', + }, + oldNegotiationId: { + type: 'string', + nullable: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'oldNegotiationId'], +}; diff --git a/packages/media-signaling/src/definition/signals/server/remote-sdp.ts b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts index e62b069aaaea9..fd0743f7f171e 100644 --- a/packages/media-signaling/src/definition/signals/server/remote-sdp.ts +++ b/packages/media-signaling/src/definition/signals/server/remote-sdp.ts @@ -5,4 +5,5 @@ export type ServerMediaSignalRemoteSDP = { type: 'remote-sdp'; sdp: RTCSessionDescriptionInit; + negotiationId: string; }; diff --git a/packages/media-signaling/src/definition/signals/server/request-offer.ts b/packages/media-signaling/src/definition/signals/server/request-offer.ts index 578d92208d609..2cb98f1ef9275 100644 --- a/packages/media-signaling/src/definition/signals/server/request-offer.ts +++ b/packages/media-signaling/src/definition/signals/server/request-offer.ts @@ -4,5 +4,5 @@ export type ServerMediaSignalRequestOffer = { toContractId: string; type: 'request-offer'; - iceRestart?: boolean; + negotiationId: string; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 67051fe78ea61..8bbaafcfb93e2 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -16,6 +16,7 @@ import type { ClientContractState, ClientState } from '../definition/client'; import type { IWebRTCProcessor, MediaStreamFactory, WebRTCInternalStateMap } from '../definition/services'; import { mergeContacts } from './utils/mergeContacts'; import type { IMediaSignalLogger } from '../definition/logger'; +import { isPendingState } from './services/states'; import type { ServerMediaSignal, ServerMediaSignalNewCall, @@ -108,6 +109,11 @@ export class ClientMediaCall implements IClientMediaCall { return this.webrtcProcessor.held; } + /** indicates the call is past the "dialing" stage and not yet over */ + public get busy(): boolean { + return !this.isPendingAcceptance() && !this.isOver(); + } + protected webrtcProcessor: IWebRTCProcessor | null = null; private acceptedLocally: boolean; @@ -145,6 +151,8 @@ export class ClientMediaCall implements IClientMediaCall { /** localCallId will only be different on calls initiated by this session */ private localCallId: string; + private currentNegotiationId: string | null; + constructor( private readonly config: IClientMediaCallConfig, callId: string, @@ -170,6 +178,7 @@ export class ClientMediaCall implements IClientMediaCall { this.mayReportStates = true; this.inputTrack = inputTrack; + this.currentNegotiationId = null; this.earlySignals = new Set(); this.stateTimeoutHandlers = new Set(); this._role = 'callee'; @@ -305,6 +314,15 @@ export class ClientMediaCall implements IClientMediaCall { } return 'accepted'; + case 'renegotiating': + if (this.hasLocalDescription && this.hasRemoteDescription) { + return 'has-new-answer'; + } + if (this.hasLocalDescription !== this.hasRemoteDescription) { + return 'has-new-offer'; + } + + return 'renegotiating'; default: return this._state; } @@ -415,7 +433,7 @@ export class ClientMediaCall implements IClientMediaCall { } public isPendingAcceptance(): boolean { - return ['none', 'ringing'].includes(this._state); + return isPendingState(this._state); } public isPendingOurAcceptance(): boolean { @@ -532,6 +550,7 @@ export class ClientMediaCall implements IClientMediaCall { serviceStates: Object.fromEntries(this.serviceStates.entries()), ignored: this.ignored, contractState: this.contractState, + ...(this.currentNegotiationId && { negotiationId: this.currentNegotiationId }), }); if (this.state === 'hangup') { @@ -614,26 +633,35 @@ export class ClientMediaCall implements IClientMediaCall { return; } + const { negotiationId } = signal; + if (this.shouldIgnoreWebRTC()) { - this.sendError({ errorType: 'service', errorCode: 'invalid-service' }); + this.sendError({ errorType: 'service', errorCode: 'invalid-service', negotiationId }); return; } this.requireWebRTC(); + const iceRestart = this.currentNegotiationId !== negotiationId; + this.currentNegotiationId = negotiationId; + if (iceRestart) { + this.hasLocalDescription = false; + } + this.hasRemoteDescription = false; + let offer: { sdp: RTCSessionDescriptionInit } | null = null; try { - offer = await this.webrtcProcessor.createOffer(signal); + offer = await this.webrtcProcessor.createOffer({ iceRestart }); } catch (e) { - this.sendError({ errorType: 'service', errorCode: 'failed-to-create-offer' }); + this.sendError({ errorType: 'service', errorCode: 'failed-to-create-offer', negotiationId }); throw e; } if (!offer) { - this.sendError({ errorType: 'service', errorCode: 'implementation-error' }); + this.sendError({ errorType: 'service', errorCode: 'implementation-error', negotiationId }); } - await this.deliverSdp(offer); + await this.deliverSdp({ ...offer, negotiationId }); } protected shouldIgnoreWebRTC(): boolean { @@ -658,22 +686,31 @@ export class ClientMediaCall implements IClientMediaCall { this.config.logger?.debug('ClientMediaCall.processAnswerRequest', signal); this.requireWebRTC(); + const { negotiationId } = signal; + + const iceRestart = this.currentNegotiationId !== negotiationId; + if (iceRestart) { + this.hasLocalDescription = false; + this.hasRemoteDescription = false; + await this.webrtcProcessor.startNewNegotiation(); + } + this.currentNegotiationId = negotiationId; let answer: { sdp: RTCSessionDescriptionInit } | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal); } catch (e) { - this.sendError({ errorType: 'service', errorCode: 'failed-to-create-answer' }); + this.sendError({ errorType: 'service', errorCode: 'failed-to-create-answer', negotiationId }); throw e; } if (!answer) { - this.sendError({ errorType: 'service', errorCode: 'implementation-error' }); + this.sendError({ errorType: 'service', errorCode: 'implementation-error', negotiationId }); return; } this.hasRemoteDescription = true; - await this.deliverSdp(answer); + await this.deliverSdp({ ...answer, negotiationId }); } protected sendError(error: Partial): void { @@ -706,11 +743,16 @@ export class ClientMediaCall implements IClientMediaCall { return this.processAnswerRequest(signal); } - await this.webrtcProcessor.setRemoteDescription(signal); + if (signal.negotiationId !== this.currentNegotiationId) { + this.config.logger?.error('Received an answer for an unexpected negotiation.'); + return; + } + + await this.webrtcProcessor.setRemoteAnswer(signal); this.hasRemoteDescription = true; } - protected async deliverSdp(data: { sdp: RTCSessionDescriptionInit }) { + protected async deliverSdp(data: { sdp: RTCSessionDescriptionInit; negotiationId: string }) { this.config.logger?.debug('ClientMediaCall.deliverSdp'); this.hasLocalDescription = true; @@ -887,13 +929,21 @@ export class ClientMediaCall implements IClientMediaCall { private onWebRTCInternalError({ critical, error }: { critical: boolean; error: string | Error }): void { const errorCode = typeof error === 'object' ? error.message : error; - this.sendError({ errorType: 'service', errorCode }); + this.sendError({ errorType: 'service', errorCode, ...(this.currentNegotiationId && { negotiationId: this.currentNegotiationId }) }); if (critical) { this.hangup('service-error'); } } + private onWebRTCNegotiationNeeded(): void { + if (this._state !== 'active' || !this.currentNegotiationId) { + return; + } + + this.config.transporter.requestRenegotiation(this.callId, this.currentNegotiationId); + } + private onWebRTCConnectionStateChange(stateValue: RTCPeerConnectionState): void { if (this.hidden) { return; @@ -972,6 +1022,7 @@ export class ClientMediaCall implements IClientMediaCall { this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger, iceGatheringTimeout, call: this, inputTrack: this.inputTrack }); this.webrtcProcessor.emitter.on('internalError', (event) => this.onWebRTCInternalError(event)); this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); + this.webrtcProcessor.emitter.on('negotiationNeeded', () => this.onWebRTCNegotiationNeeded()); } private requireWebRTC(): asserts this is ClientMediaCallWebRTC { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 45d0ab182bd63..df3c8920d060f 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -78,12 +78,7 @@ export class MediaSignalingSession extends Emitter { } public isBusy(): boolean { - const call = this.getMainCall(); - if (!call) { - return false; - } - - return ['accepted', 'active'].includes(call.state); + return this.getMainCall()?.busy ?? false; } public enableStateReport(interval: number): void { @@ -114,7 +109,7 @@ export class MediaSignalingSession extends Emitter { continue; } - if (['accepted', 'active'].includes(call.state)) { + if (call.busy) { return call; } if (call.state === 'ringing' && !ringingCall) { diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index a3eb14fca01f2..ff4ce600b552a 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -26,8 +26,12 @@ export class MediaSignalTransportWrapper { } as GenericClientMediaSignal); } - public sendError(callId: string, { errorType, errorCode }: Partial) { - this.sendToServer(callId, 'error', { errorType: errorType || 'other', ...(errorCode && { errorCode }) }); + public sendError(callId: string, { errorType, errorCode, negotiationId }: Partial) { + this.sendToServer(callId, 'error', { + errorType: errorType || 'other', + ...(errorCode && { errorCode }), + ...(negotiationId && { negotiationId }), + }); } public answer(callId: string, answer: CallAnswer) { @@ -38,6 +42,10 @@ export class MediaSignalTransportWrapper { return this.sendToServer(callId, 'hangup', { reason }); } + public requestRenegotiation(callId: string, oldNegotiationId: string) { + return this.sendToServer(callId, 'negotiation-needed', { oldNegotiationId }); + } + public sendSignal(signal: ClientMediaSignal): void { this.logger?.debug('MediaSignalTransportWrapper.sendSignal', signal); this.sendSignalFn(signal); diff --git a/packages/media-signaling/src/lib/index.ts b/packages/media-signaling/src/lib/index.ts index 46e331fe03a2e..c9b45151636ec 100644 --- a/packages/media-signaling/src/lib/index.ts +++ b/packages/media-signaling/src/lib/index.ts @@ -1,4 +1,5 @@ export * from './Session'; export * from './Call'; export * from './TransportWrapper'; +export * from './services/states'; export * from './services/webrtc'; diff --git a/packages/media-signaling/src/lib/services/states.ts b/packages/media-signaling/src/lib/services/states.ts new file mode 100644 index 0000000000000..2ec5fd95e3fd0 --- /dev/null +++ b/packages/media-signaling/src/lib/services/states.ts @@ -0,0 +1,11 @@ +import type { CallState } from '../../definition'; + +/* returns true if the value represents a state in which the underlying service has not been involved yet */ +export function isPendingState(state: CallState): boolean { + return ['none', 'ringing'].includes(state); +} + +/* returns true if the value represents a state in which the underlying service is already involved in the call */ +export function isBusyState(state: CallState): boolean { + return ['accepted', 'active', 'renegotiating'].includes(state); +} diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 23a01c10d1c6c..c188ba9253776 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -122,6 +122,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.remoteStream.stopAudio(); } + public async startNewNegotiation(): Promise { + this.iceGatheringFinished = false; + this.clearIceGatheringWaiters(new Error('new-negotiation')); + } + public async createAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }> { this.config.logger?.debug('MediaCallWebRTCProcessor.createAnswer'); if (this.stopped) { @@ -134,21 +139,27 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { await this.initializeLocalMediaStream(); if (this.peer.remoteDescription?.sdp !== sdp.sdp) { + this.startNewNegotiation(); this.peer.setRemoteDescription(sdp); } const answer = await this.peer.createAnswer(); + await this.peer.setLocalDescription(answer); return this.getLocalDescription(); } - public async setRemoteDescription({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { + public async setRemoteAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription'); if (this.stopped) { return; } + if (sdp.type === 'offer') { + throw new Error('invalid-answer'); + } + this.peer.setRemoteDescription(sdp); } @@ -182,10 +193,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } await this.waitForIceGathering(); - // always wait a little extra to ensure all relevant events have been fired - // 30ms is low enough that it won't be noticeable by users, but is also enough time to process a full `host` candidate - await new Promise((resolve) => setTimeout(resolve, 30)); - const sdp = this.peer.localDescription; if (!sdp) { @@ -203,15 +210,12 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - if (this.peer.iceGatheringState === 'complete') { - return; - } - this.iceGatheringTimedOut = false; const iceGatheringData = getExternalWaiter({ timeout: this.config.iceGatheringTimeout, timeoutFn: () => { if (this.iceGatheringWaiters.has(iceGatheringData)) { + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering - timeout'); this.clearIceGatheringData(iceGatheringData); this.iceGatheringTimedOut = true; this.changeInternalState('iceUntrickler'); @@ -221,7 +225,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.iceGatheringWaiters.add(iceGatheringData); this.changeInternalState('iceUntrickler'); - return iceGatheringData.promise; + await iceGatheringData.promise; + + // always wait a little extra to ensure all relevant events have been fired + // 30ms is low enough that it won't be noticeable by users, but is also enough time to process any local stuff + await new Promise((resolve) => setTimeout(resolve, 30)); } private registerPeerEvents() { @@ -239,9 +247,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private restartIce() { this.config.logger?.debug('MediaCallWebRTCProcessor.restartIce'); - this.iceGatheringFinished = false; - - this.clearIceGatheringWaiters(new Error('ice-restart')); + this.startNewNegotiation(); this.peer.restartIce(); } @@ -268,6 +274,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.onNegotiationNeeded'); + this.emitter.emit('negotiationNeeded'); } private onTrack(event: RTCTrackEvent): void { @@ -412,9 +419,14 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private clearIceGatheringWaiters(error?: Error) { this.config.logger?.debug('MediaCallWebRTCProcessor.clearIceGatheringWaiters'); + this.iceGatheringTimedOut = false; + + if (!this.iceGatheringWaiters.size) { + return; + } + const waiters = this.iceGatheringWaiters.values().toArray(); this.iceGatheringWaiters.clear(); - this.iceGatheringTimedOut = false; for (const iceGatheringData of waiters) { this.clearIceGatheringData(iceGatheringData, error); diff --git a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts index 6cfc3e34ce128..cc0f797d56181 100644 --- a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts +++ b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts @@ -19,6 +19,10 @@ export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWait }; const flagAsDone = () => { + if (data.done) { + return; + } + if (data.timeout) { clearTimeout(data.timeout); data.timeout = null; @@ -51,11 +55,13 @@ export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWait if (data.done) { return; } - flagAsDone(); if (timeoutFn) { timeoutFn(); - } else if (data.promiseReject) { + flagAsDone(); + } + + if (data.promiseReject) { data.promiseReject(new Error('timeout')); } }, timeout); diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 641243604e729..69184ad32e247 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -85,5 +85,6 @@ export * from './models/IMigrationsModel'; export * from './models/IModerationReportsModel'; export * from './models/IMediaCallsModel'; export * from './models/IMediaCallChannelsModel'; +export * from './models/IMediaCallNegotiationsModel'; export * from './updater'; export * from './models/IWorkspaceCredentialsModel'; diff --git a/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts new file mode 100644 index 0000000000000..273ba3d2eb1d0 --- /dev/null +++ b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts @@ -0,0 +1,22 @@ +import type { IMediaCallNegotiation } from '@rocket.chat/core-typings'; +import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface IMediaCallNegotiationsModel extends IBaseModel { + findAllByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise>; + findLatestByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise; + findActiveByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise; + setOfferById(id: string, offer: RTCSessionDescriptionInit): Promise; + setAnswerById(id: string, answer: RTCSessionDescriptionInit): Promise; + setStableById(id: string): Promise; +} diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 770fe9570bfe1..ca3285fe53f17 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -20,6 +20,4 @@ export interface IMediaCallsModel extends IBaseModel { hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; hasUnfinishedCalls(): Promise; - setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise; - setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise; } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index ade19e99f73d3..89c2136d3668a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -92,6 +92,7 @@ import type { IFreeSwitchChannelEventDeltaModel, IMediaCallsModel, IMediaCallChannelsModel, + IMediaCallNegotiationsModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -184,6 +185,7 @@ export const LoginServiceConfiguration = proxify('IMessagesModel'); export const MediaCalls = proxify('IMediaCallsModel'); export const MediaCallChannels = proxify('IMediaCallChannelsModel'); +export const MediaCallNegotiations = proxify('IMediaCallNegotiationsModel'); export const NotificationQueue = proxify('INotificationQueueModel'); export const Nps = proxify('INpsModel'); export const NpsVote = proxify('INpsVoteModel'); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index b8db18e0299c6..9972d15f3372c 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -77,5 +77,6 @@ export * from './models/Migrations'; export * from './models/ModerationReports'; export * from './models/MediaCalls'; export * from './models/MediaCallChannels'; +export * from './models/MediaCallNegotiations'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; diff --git a/packages/models/src/models/MediaCallNegotiations.ts b/packages/models/src/models/MediaCallNegotiations.ts new file mode 100644 index 0000000000000..3eb0e399275d2 --- /dev/null +++ b/packages/models/src/models/MediaCallNegotiations.ts @@ -0,0 +1,103 @@ +import type { RocketChatRecordDeleted, IMediaCallNegotiation } from '@rocket.chat/core-typings'; +import type { IMediaCallNegotiationsModel } from '@rocket.chat/model-typings'; +import type { IndexDescription, Collection, Db, FindOptions, Document, FindCursor, UpdateResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class MediaCallNegotiationsRaw extends BaseRaw implements IMediaCallNegotiationsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'media_call_negotiations', trash); + } + + protected modelIndexes(): IndexDescription[] { + return [{ key: { callId: 1, requestTimestamp: -1, stableTimestamp: -1 }, unique: false, sparse: true }]; + } + + public async findAllByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise> { + return this.find({ callId }, options); + } + + public async findLatestByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise { + return this.findOne( + { + callId, + }, + { + ...options, + sort: { + requestTimestamp: -1, + }, + limit: 1, + }, + ); + } + + public async findActiveByCallId( + callId: IMediaCallNegotiation['callId'], + options?: FindOptions, + ): Promise { + return this.findOne( + { + callId, + stableTimestamp: { $exists: true }, + }, + { + ...options, + sort: { + requestTimestamp: -1, + }, + limit: 1, + }, + ); + } + + public async setOfferById(id: string, offer: RTCSessionDescriptionInit): Promise { + return this.updateOne( + { + _id: id, + offer: { $exists: false }, + }, + { + $set: { + offer, + offerTimestamp: new Date(), + }, + }, + ); + } + + public async setAnswerById(id: string, answer: RTCSessionDescriptionInit): Promise { + return this.updateOne( + { + _id: id, + answer: { $exists: false }, + }, + { + $set: { + answer, + answerTimestamp: new Date(), + }, + }, + ); + } + + public async setStableById(id: string): Promise { + return this.updateOne( + { + _id: id, + stableTimestamp: { $exists: false }, + }, + { + $set: { + stableTimestamp: new Date(), + }, + }, + ); + } +} diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 3cea1227ba8d2..c6d4bff8b541f 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -153,28 +153,4 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod const count = await this.countDocuments({ state: { $ne: 'hangup' } }, { limit: 1 }); return count > 0; } - - public async setWebrtcOfferById(callId: string, offer: RTCSessionDescriptionInit, expiresAt: Date): Promise { - return this.updateOne( - { - _id: callId, - webrtcOffer: { $exists: false }, - }, - { - $set: { webrtcOffer: offer, expiresAt }, - }, - ); - } - - public async setWebrtcAnswerById(callId: string, answer: RTCSessionDescriptionInit, expiresAt: Date): Promise { - return this.updateOne( - { - _id: callId, - webrtcAnswer: { $exists: false }, - }, - { - $set: { webrtcAnswer: answer, expiresAt }, - }, - ); - } } From 0274eb630d1c0fa242236fc2011f9a5d8d659c61 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Thu, 11 Sep 2025 19:33:04 -0300 Subject: [PATCH 52/72] feat: media call transfer (#36898) --- ee/packages/media-calls/src/base/BaseAgent.ts | 6 +- .../src/definition/IMediaCallAgent.ts | 2 + .../src/definition/IMediaCallServer.ts | 4 + .../media-calls/src/definition/common.ts | 8 +- .../src/internal/InternalCallProvider.ts | 4 + .../src/internal/SignalProcessor.ts | 96 ++-------- .../src/internal/agents/BaseUserAgent.ts | 26 ++- .../internal/agents/CallSignalProcessor.ts | 34 ++-- .../media-calls/src/server/CallDirector.ts | 113 +++++++---- .../media-calls/src/server/CastDirector.ts | 4 +- .../media-calls/src/server/MediaCallServer.ts | 105 ++++++++-- .../core-typings/src/mediaCalls/IMediaCall.ts | 6 + .../src/definition/call/IClientMediaCall.ts | 3 +- .../services/webrtc/IWebRTCProcessor.ts | 2 - .../definition/signals/client/MediaSignal.ts | 5 +- .../src/definition/signals/client/index.ts | 1 + .../src/definition/signals/client/transfer.ts | 48 +++++ .../src/definition/signals/server/new.ts | 2 + packages/media-signaling/src/lib/Call.ts | 73 +++++-- packages/media-signaling/src/lib/Session.ts | 179 ++++++++++++++++-- .../src/lib/services/webrtc/LocalStream.ts | 6 +- .../src/lib/services/webrtc/Processor.ts | 59 +----- .../src/lib/services/webrtc/Stream.ts | 15 +- .../src/lib/utils/getExternalWaiter.ts | 2 +- .../src/models/IMediaCallsModel.ts | 3 +- packages/models/src/models/MediaCalls.ts | 29 ++- 26 files changed, 571 insertions(+), 264 deletions(-) create mode 100644 packages/media-signaling/src/definition/signals/client/transfer.ts diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index e5b798bbc3ca5..f237b0a2904c5 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -46,11 +46,11 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { return actor.type === this.actorType && actor.id === this.actorId; } - public getMyCallActor(call: IMediaCall): MediaCallActor { + public getMyCallActor(call: IMediaCall): MediaCallContact { return call[this.role]; } - public getOtherCallActor(call: IMediaCall): MediaCallActor { + public getOtherCallActor(call: IMediaCall): MediaCallContact { return call[this.oppositeRole]; } @@ -75,6 +75,8 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; + public abstract onCallTransfered(callId: string): Promise; + protected async createOrUpdateChannel(call: IMediaCall, contractId: string): Promise { if (!contractId) { throw new Error('error-invalid-contract'); diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index 830fd823d8f89..8187723fa1ace 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -18,5 +18,7 @@ export interface IMediaCallAgent { /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; + onCallTransfered(callId: string): Promise; + getMyCallActor(call: IMediaCall): MediaCallActor; } diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 1de988932b762..03c82ad270220 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -2,6 +2,8 @@ import type { IUser } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { InternalCallParams } from './common'; + export type MediaCallServerEvents = { signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; }; @@ -32,4 +34,6 @@ export interface IMediaCallServer { hangupExpiredCalls(): Promise; scheduleExpirationCheck(): void; configure(settings: IMediaCallServerSettings): void; + + requestCall(params: InternalCallParams): Promise; } diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index 9ed167c0e57ad..264c1100e2a23 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IMediaCall, IUser, MediaCallActor, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { AtLeast, IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; import type { CallService } from '@rocket.chat/media-signaling'; export type MinimalUserData = Pick; @@ -9,10 +9,12 @@ export type GetActorContactOptions = { }; export type InternalCallParams = { - caller: MediaCallSignedActor; - callee: MediaCallActor; + caller: MediaCallSignedContact; + callee: MediaCallContact; requestedCallId?: string; requestedService?: CallService; + parentCallId?: string; + requestedBy?: MediaCallSignedContact; }; export type MediaCallHeader = AtLeast; diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts index f891c71e49abc..e132db567d90f 100644 --- a/ee/packages/media-calls/src/internal/InternalCallProvider.ts +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -33,6 +33,10 @@ export class InternalCallProvider extends BaseCallProvider { // If the callee agent fails, this will automatically hangup the call, notify the caller agent and then throw an error await MediaCallDirector.runOnCallCreatedForAgent(call, calleeAgent, callerAgent); + if (params.parentCallId) { + MediaCallDirector.hangupTransferedCallById(params.parentCallId).catch(() => null); + } + return call; } } diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index c2c90bab0e34e..bea829f5d6442 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, IUser, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { isPendingState, @@ -10,26 +10,21 @@ import { } from '@rocket.chat/media-signaling'; import { MediaCalls } from '@rocket.chat/models'; -import type { GetActorContactOptions, InternalCallParams } from '../definition/common'; +import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/BaseUserAgent'; -import type { IMediaCallServerSettings } from '../definition/IMediaCallServer'; -import { getDefaultSettings } from '../server/getDefaultSettings'; export type SignalProcessorEvents = { signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; - callRequest: { fromUid: IUser['_id']; params: InternalCallParams }; + callRequest: { params: InternalCallParams }; }; export class GlobalSignalProcessor { public readonly emitter: Emitter; - private settings: IMediaCallServerSettings; - constructor() { this.emitter = new Emitter(); - this.settings = getDefaultSettings(); } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { @@ -49,16 +44,12 @@ export class GlobalSignalProcessor { logger.error({ msg: 'Unrecognized media signal', signal }); } - public configure(settings: IMediaCallServerSettings): void { - this.settings = settings; - } - protected sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { this.emitter.emit('signalRequest', { toUid, signal }); } - protected createCall(fromUid: IUser['_id'], params: InternalCallParams): void { - this.emitter.emit('callRequest', { fromUid, params }); + protected createCall(params: InternalCallParams): void { + this.emitter.emit('callRequest', { params }); } private async processCallSignal( @@ -111,55 +102,9 @@ export class GlobalSignalProcessor { // 2. Hangup active calls involving the oldContractId if it's different from the new one. } - private getCalleeContactOptions(): GetActorContactOptions { - if (!this.settings.sip.enabled) { - return { - requiredType: 'user', - }; - } - - switch (this.settings.internalCalls.routeExternally) { - case 'always': - // Will only make sip calls - return { - requiredType: 'sip', - }; - case 'never': - // Will not use sip when calling an user or an extension assigned to an user, but will use sip when calling an unassigned extension - return { - preferredType: 'user', - }; - case 'preferrably': - // Will only skip sip for users that don't have an assigned extension (or not call at all if `requireExtensions` is true) - return { - preferredType: 'sip', - }; - } - - return {}; - } - private async processRequestCallSignal(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise { logger.debug({ msg: 'GlobalSignalProcessor.processRequestCallSignal', signal, uid }); - - // The caller contact should always be from type = 'user' when the call was initiated from rocket.chat - const caller = await MediaCallDirector.cast.getContactForUserId(uid, { requiredType: 'user' }); - if (!caller) { - throw new Error('Failed to load caller contact information'); - } - - // The callee contact type will determine if the call is going to go through SIP or directly to another rocket.chat user - const callee = await MediaCallDirector.cast.getContactForActor(signal.callee, this.getCalleeContactOptions()); - if (!callee) { - throw new Error('Failed to load callee contact information'); - } - - if (this.settings.internalCalls.requireExtensions && !callee.sipExtension) { - throw new Error('Invalid target user'); - } - - const existingCall = await this.getExistingRequestedCall(uid, signal, callee); - + const existingCall = await this.getExistingRequestedCall(uid, signal); if (existingCall) { return; } @@ -169,29 +114,31 @@ export class GlobalSignalProcessor { const params: InternalCallParams = { caller: { - ...caller, + type: 'user', + id: uid, + contractId: signal.contractId, + }, + callee: signal.callee, + requestedBy: { + type: 'user', + id: uid, contractId: signal.contractId, }, - callee, requestedCallId: signal.callId, ...(requestedService && { requestedService }), }; - this.emitter.emit('callRequest', { fromUid: uid, params }); + this.createCall(params); } - private async getExistingRequestedCall( - uid: IUser['_id'], - signal: ClientMediaSignalRequestCall, - callee: MediaCallActor, - ): Promise { - const { callId: requestedCallId, callee: originalCallee } = signal; + private async getExistingRequestedCall(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise { + const { callId: requestedCallId } = signal; if (!requestedCallId) { return null; } - logger.debug({ msg: 'GlobalSignalProcessor.getExistingRequestedCall', uid, signal, callee }); + logger.debug({ msg: 'GlobalSignalProcessor.getExistingRequestedCall', uid, signal }); const caller = { type: 'user', id: uid } as const; const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; @@ -208,12 +155,7 @@ export class GlobalSignalProcessor { this.invalidCallId(uid, rejection); } - const isMutatedCallee = call.callee.type === callee.type && call.callee.id === callee.id; - const isOriginalCallee = call.callee.type === originalCallee.type && call.callee.id === originalCallee.id; - const isSameCallee = isMutatedCallee || isOriginalCallee; - const isSameContract = call.caller.contractId === signal.contractId; - - if (!isSameCallee || !isSameContract) { + if (call.caller.contractId !== signal.contractId) { this.invalidCallId(uid, { ...rejection, reason: 'existing-call-id' }); } diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts index 9f6d5325fbc39..d9a8ca1b932e3 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts @@ -1,4 +1,4 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings'; import { isBusyState, type ClientMediaSignal, type ServerMediaSignal, type ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; @@ -115,6 +115,29 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { }); } + public async onCallTransfered(callId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onCallTransfered', callId }); + + const call = await MediaCalls.findOneById(callId); + if (!call?.transferedBy || !call?.transferedTo) { + return; + } + + const actor = this.getMyCallActor(call); + // If we haven't signed yet, we can't be transfered + if (!actor.contractId) { + return; + } + + getMediaCallServer().requestCall({ + caller: actor as MediaCallSignedContact, + callee: call.transferedTo, + requestedService: call.service, + requestedBy: call.transferedBy, + parentCallId: call._id, + }); + } + protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { return { callId: call._id, @@ -123,6 +146,7 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { kind: call.kind, role: this.role, contact: this.getOtherCallActor(call), + ...(call.parentCallId && { replacingCallId: call.parentCallId }), }; } } diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index e5e3898526eb7..5e3ea9c235d23 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -1,13 +1,14 @@ -import type { IMediaCall, IMediaCallChannel, MediaCallActor, MediaCallActorType } from '@rocket.chat/core-typings'; -import { - isPendingState, - type CallAnswer, - type CallHangupReason, - type CallRole, - type ClientMediaSignal, - type ClientMediaSignalError, - type ClientMediaSignalLocalState, - type ServerMediaSignal, +import type { IMediaCall, IMediaCallChannel, MediaCallActorType, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import { isPendingState, isBusyState } from '@rocket.chat/media-signaling'; +import type { + ClientMediaSignalTransfer, + CallAnswer, + CallHangupReason, + CallRole, + ClientMediaSignal, + ClientMediaSignalError, + ClientMediaSignalLocalState, + ServerMediaSignal, } from '@rocket.chat/media-signaling'; import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; @@ -37,7 +38,7 @@ export class UserActorSignalProcessor { return this.channel.role; } - public get actor(): MediaCallActor { + public get actor(): MediaCallSignedActor { return { type: this.actorType, id: this.actorId, @@ -91,6 +92,8 @@ export class UserActorSignalProcessor { return this.processError(signal.errorType, signal.errorCode); case 'negotiation-needed': return this.processNegotiationNeeded(signal.oldNegotiationId); + case 'transfer': + return this.processCallTransfer(signal.to); } } @@ -148,6 +151,15 @@ export class UserActorSignalProcessor { } } + private async processCallTransfer(to: ClientMediaSignalTransfer['to']): Promise { + logger.debug({ msg: 'UserActorSignalProcessor.processCallTransfer', to }); + if (!isBusyState(this.call.state)) { + return; + } + + return MediaCallDirector.transferCall(this.call, to, this.actor, this.agent); + } + protected async clientIsReachable(): Promise { logger.debug({ msg: 'UserActorSignalProcessor.clientIsReachable', role: this.role, uid: this.actorId }); diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index f808014137126..391012747e28e 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -1,30 +1,18 @@ -import type { - AtLeast, - IMediaCall, - IMediaCallNegotiation, - MediaCallActor, - MediaCallSignedActor, - ServerActor, -} from '@rocket.chat/core-typings'; -import type { CallHangupReason, CallRole, CallService } from '@rocket.chat/media-signaling'; +import type { IMediaCall, IMediaCallNegotiation, MediaCallContact, MediaCallSignedActor, ServerActor } from '@rocket.chat/core-typings'; +import type { CallHangupReason, CallRole } from '@rocket.chat/media-signaling'; import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector } from './injection'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; -import type { MediaCallHeader } from '../definition/common'; +import type { InternalCallParams, MediaCallHeader } from '../definition/common'; import { logger } from '../logger'; const EXPIRATION_TIME = 120000; const EXPIRATION_CHECK_DELAY = 1000; -export type CreateCallParams = { - caller: MediaCallSignedActor; - callee: MediaCallActor; - requestedCallId?: string; - requestedService?: CallService; - +export type CreateCallParams = InternalCallParams & { callerAgent: IMediaCallAgent; calleeAgent: IMediaCallAgent; @@ -43,28 +31,8 @@ export class MediaCallDirector { } } - public static async hangupByServer(call: AtLeast, serverErrorCode: string): Promise { - logger.debug({ msg: 'MediaCallDirector.hangupByServer', callId: call._id, serverErrorCode }); - try { - const endedBy = { type: 'server', id: 'server' } as ServerActor; - - const modified = await this.hangupCallById(call._id, { endedBy, reason: serverErrorCode }); - if (!modified) { - return; - } - - // Try to notify the agents but there's no guarantee they are reachable - try { - const agents = await this.cast.getAgentsFromCall(call); - for (const agent of Object.values(agents)) { - agent?.onCallEnded(call._id).catch(() => null); - } - } catch { - // Ignore errors on the ended event - } - } catch (error) { - logger.error({ msg: 'Failed to terminate call.', error, callId: call._id, serverErrorCode }); - } + public static async hangupByServer(call: MediaCallHeader, serverErrorCode: string): Promise { + return this.hangupDetachedCall(call, { reason: serverErrorCode }); } public static async activate(call: IMediaCall, actorAgent: IMediaCallAgent): Promise { @@ -185,7 +153,7 @@ export class MediaCallDirector { public static async createCall(params: CreateCallParams): Promise { logger.debug({ msg: 'MediaCallDirector.createCall', params }); - const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, webrtcOffer } = params; + const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, webrtcOffer, parentCallId, requestedBy } = params; // The caller must always have a contract to create the call if (!caller.contractId) { @@ -210,12 +178,18 @@ export class MediaCallDirector { callerAgent.oppositeAgent = calleeAgent; calleeAgent.oppositeAgent = callerAgent; + const createdByActor = requestedBy || caller; + const call: Omit = { service, kind: 'direct', state: 'none', - createdBy: caller, + createdBy: { + type: createdByActor.type, + id: createdByActor.id, + contractId: createdByActor.contractId, + }, createdAt: new Date(), caller, @@ -225,6 +199,7 @@ export class MediaCallDirector { ...(requestedCallId && { callerRequestedId: requestedCallId }), ...(webrtcOffer && { webrtcOffer }), + ...(parentCallId && { parentCallId }), }; logger.debug({ msg: 'creating call', call }); @@ -242,10 +217,39 @@ export class MediaCallDirector { return newCall; } + public static async transferCall( + call: MediaCallHeader, + to: MediaCallContact, + by: MediaCallSignedActor, + agent: IMediaCallAgent, + ): Promise { + if (!agent.oppositeAgent) { + logger.error('Unable to transfer calls without a reference to the opposite agent.'); + return; + } + + const updateResult = await MediaCalls.transferCallById(call._id, { by, to }); + if (!updateResult.modifiedCount) { + return; + } + + agent.oppositeAgent.onCallTransfered(call._id); + } + + public static async hangupTransferedCallById(callId: string): Promise { + logger.debug({ msg: 'MediaCallDirector.hangupTransferedCallById', callId }); + const call = await MediaCalls.findOneById(callId); + if (!call?.transferedBy) { + return; + } + + return this.hangupDetachedCall(call, { endedBy: call.transferedBy, reason: 'transfer' }); + } + public static async hangupExpiredCalls(): Promise { logger.debug('MediaCallDirector.hangupExpiredCalls'); - const result = MediaCalls.findAllExpiredCalls>({ + const result = MediaCalls.findAllExpiredCalls({ projection: { _id: 1, caller: 1, callee: 1 }, }); @@ -336,4 +340,31 @@ export class MediaCallDirector { ), ); } + + private static async hangupDetachedCall( + call: MediaCallHeader, + params?: { endedBy?: IMediaCall['endedBy']; reason?: string }, + ): Promise { + logger.debug({ msg: 'MediaCallDirector.hangupDetachedCall', callId: call._id, params }); + try { + const endedBy = params?.endedBy || ({ type: 'server', id: 'server' } as ServerActor); + + const modified = await this.hangupCallById(call._id, { ...params, endedBy }); + if (!modified) { + return; + } + + // Try to notify the agents but there's no guarantee they are reachable + try { + const agents = await this.cast.getAgentsFromCall(call); + for (const agent of Object.values(agents)) { + agent?.onCallEnded(call._id).catch(() => null); + } + } catch { + // Ignore errors on the ended event + } + } catch (error) { + logger.error({ msg: 'Failed to terminate call.', error, callId: call._id, params }); + } + } } diff --git a/ee/packages/media-calls/src/server/CastDirector.ts b/ee/packages/media-calls/src/server/CastDirector.ts index 82e4d7c3ce567..baa5b81a023d6 100644 --- a/ee/packages/media-calls/src/server/CastDirector.ts +++ b/ee/packages/media-calls/src/server/CastDirector.ts @@ -49,11 +49,11 @@ export class MediaCallCastDirector implements IMediaCallCastDirector { logger.debug({ msg: 'MediaCallCastDirector.getContactForActor', actor, options, defaultContactInfo }); if (actor.type === 'user') { - return this.getContactForUserId(actor.id, options, defaultContactInfo); + return this.getContactForUserId(actor.id, options, { ...actor, ...defaultContactInfo }); } if (actor.type === 'sip') { - return this.getContactForExtensionNumber(actor.id, options, defaultContactInfo); + return this.getContactForExtensionNumber(actor.id, options, { ...actor, ...defaultContactInfo }); } return null; diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 8ea6398999f40..15b6ea5add810 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -5,10 +5,11 @@ import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-si import { MediaCallDirector } from './CallDirector'; import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; -import type { InternalCallParams } from '../definition/common'; +import type { GetActorContactOptions, InternalCallParams } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; +import { getDefaultSettings } from './getDefaultSettings'; /** * Class used as gateway to send and receive signals to/from clients @@ -17,17 +18,21 @@ import { logger } from '../logger'; export class MediaCallServer implements IMediaCallServer { private signalProcessor: GlobalSignalProcessor; + private settings: IMediaCallServerSettings; + public emitter: Emitter; constructor() { this.emitter = new Emitter(); + this.settings = getDefaultSettings(); this.signalProcessor = new GlobalSignalProcessor(); this.signalProcessor.emitter.on('signalRequest', ({ toUid, signal }) => { + // Forward signal requests from the signal processor to the server this.sendSignal(toUid, signal); }); - this.signalProcessor.emitter.on('callRequest', ({ fromUid, params }) => { - this.onCallRequest(fromUid, params); + this.signalProcessor.emitter.on('callRequest', ({ params }) => { + this.requestCall(params).catch(() => null); }); } @@ -50,6 +55,27 @@ export class MediaCallServer implements IMediaCallServer { this.emitter.emit('signalRequest', { toUid, signal }); } + public async requestCall(params: InternalCallParams): Promise { + const fullParams = await this.fillContactInformationForNewCall(params); + try { + await this.createCall(fullParams); + } catch (error) { + logger.error({ msg: 'Failed to create a requested call', params, error }); + + const originalId = params.requestedCallId || params.parentCallId; + + if (originalId && params.requestedBy?.type === 'user') { + this.sendSignal(params.requestedBy.id, { + type: 'rejected-call-request', + callId: originalId, + toContractId: params.requestedBy.contractId, + reason: 'unsupported', + }); + } + throw error; + } + } + public async createCall(params: InternalCallParams): Promise { logger.debug({ msg: 'MediaCallServer.createCall', params }); @@ -70,22 +96,67 @@ export class MediaCallServer implements IMediaCallServer { public configure(settings: IMediaCallServerSettings): void { logger.debug({ msg: 'Media Server Configuration', settings }); - this.signalProcessor.configure(settings); + this.settings = settings; } - private onCallRequest(fromUid: IUser['_id'], params: InternalCallParams): void { - this.createCall(params).catch((error) => { - logger.error({ msg: 'Failed to create a call requested by a signal', fromUid, params, error }); + private async fillContactInformationForNewCall(params: InternalCallParams): Promise { + logger.debug({ msg: 'MediaCallServer.getContactForNewCallActors', params }); - if (params.requestedCallId) { - this.sendSignal(fromUid, { - type: 'rejected-call-request', - callId: params.requestedCallId, - toContractId: params.caller.contractId, - reason: 'unsupported', - }); - } - throw error; - }); + // On call transfers, do not mutate the caller + // On new calls, force the caller type to be 'user' (since the call is being created in rocket.chat first) + const isTransfer = Boolean(params.parentCallId); + const callerRequiredType = isTransfer ? params.caller.type : 'user'; + + const caller = await MediaCallDirector.cast.getContactForActor(params.caller, { requiredType: callerRequiredType }); + if (!caller) { + throw new Error('Failed to load caller contact information'); + } + + // The callee contact type will determine if the call is going to go through SIP or directly to another rocket.chat user + const callee = await MediaCallDirector.cast.getContactForActor(params.callee, this.getCalleeContactOptions()); + if (!callee) { + throw new Error('Failed to load callee contact information.'); + } + + if (this.settings.internalCalls.requireExtensions && !callee.sipExtension) { + throw new Error('Invalid target user'); + } + + return { + ...params, + caller: { + ...caller, + contractId: params.caller.contractId, + }, + callee, + }; + } + + private getCalleeContactOptions(): GetActorContactOptions { + if (!this.settings.sip.enabled) { + return { + requiredType: 'user', + }; + } + + switch (this.settings.internalCalls.routeExternally) { + case 'always': + // Will only make sip calls + return { + requiredType: 'sip', + }; + case 'never': + // Will not use sip when calling an user or an extension assigned to an user, but will use sip when calling an unassigned extension + return { + preferredType: 'user', + }; + case 'preferrably': + // Will only skip sip for users that don't have an assigned extension (or not call at all if `requireExtensions` is true) + return { + preferredType: 'sip', + }; + } + + return {}; } } diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 13aa2a1b69ac0..b2e21672def33 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -49,4 +49,10 @@ export interface IMediaCall extends IRocketChatRecord { expiresAt: Date; callerRequestedId?: string; + parentCallId?: string; + + /** transfered* fields are filled as soon as the transfer is requested, but the old call will only end when the new one is created */ + transferedBy?: MediaCallSignedActor; + transferedTo?: MediaCallContact; + transferedAt?: Date; } diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 3a59ef51d2559..23334ccedc3eb 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -31,6 +31,7 @@ export type CallHangupReason = | 'remote' // Server told the client to hang up | 'rejected' // The callee rejected the call | 'unavailable' // The actor is not available + | 'transfer' // one of the users requested the other be transfered to someone else | 'timeout' // The call state hasn't progressed for too long | 'signaling-error' // Hanging up because of an error during the signal processing | 'service-error' // Hanging up because of an error setting up the service connection @@ -74,7 +75,6 @@ export interface IClientMediaCall { emitter: Emitter; - setInputTrack(newInputTrack: MediaStreamTrack | null): Promise; getRemoteMediaStream(): MediaStream; accept(): void; @@ -82,4 +82,5 @@ export interface IClientMediaCall { hangup(): void; setMuted(muted: boolean): void; setHeld(onHold: boolean): void; + transfer(callee: { type: CallActorType; id: string }): void; } diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index ff96fb3cc286d..31e982e1ca4fb 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -3,7 +3,6 @@ import type { Emitter } from '@rocket.chat/emitter'; import type { IClientMediaCall } from '../../call'; import type { IMediaSignalLogger } from '../../logger'; import type { IServiceProcessor, ServiceProcessorEvents } from '../IServiceProcessor'; -import type { MediaStreamFactory } from '../MediaStreamFactory'; export type WebRTCInternalStateMap = { signaling: RTCSignalingState; @@ -35,7 +34,6 @@ export interface IWebRTCProcessor extends IServiceProcessor = { type: 'object', @@ -35,6 +37,7 @@ export const clientMediaSignalSchema: JSONSchemaType = { clientMediaSignalLocalStateSchema, clientMediaSignalRegisterSchema, clientMediaSignalNegotiationNeededSchema, + clientMediaSignalTransferSchema, ], }; diff --git a/packages/media-signaling/src/definition/signals/client/index.ts b/packages/media-signaling/src/definition/signals/client/index.ts index 131353c6edc37..dabaf0ac19f53 100644 --- a/packages/media-signaling/src/definition/signals/client/index.ts +++ b/packages/media-signaling/src/definition/signals/client/index.ts @@ -6,5 +6,6 @@ export * from './local-state'; export * from './negotiation-needed'; export * from './register'; export * from './request-call'; +export * from './transfer'; export * from './MediaSignal'; diff --git a/packages/media-signaling/src/definition/signals/client/transfer.ts b/packages/media-signaling/src/definition/signals/client/transfer.ts new file mode 100644 index 0000000000000..fd8a07ab2a360 --- /dev/null +++ b/packages/media-signaling/src/definition/signals/client/transfer.ts @@ -0,0 +1,48 @@ +import type { JSONSchemaType } from 'ajv'; + +/** Client is transfering the other actor to a new actor */ +export type ClientMediaSignalTransfer = { + callId: string; + type: 'transfer'; + contractId: string; + + to: { + type: 'user' | 'sip'; + id: string; + }; +}; + +export const clientMediaSignalTransferSchema: JSONSchemaType = { + type: 'object', + properties: { + callId: { + type: 'string', + nullable: false, + }, + contractId: { + type: 'string', + nullable: false, + }, + type: { + type: 'string', + const: 'transfer', + }, + to: { + type: 'object', + properties: { + type: { + type: 'string', + nullable: false, + }, + id: { + type: 'string', + nullable: false, + }, + }, + required: ['type', 'id'], + additionalProperties: false, + }, + }, + additionalProperties: false, + required: ['callId', 'contractId', 'type', 'to'], +}; diff --git a/packages/media-signaling/src/definition/signals/server/new.ts b/packages/media-signaling/src/definition/signals/server/new.ts index e935925594c07..2a1cc37e3b603 100644 --- a/packages/media-signaling/src/definition/signals/server/new.ts +++ b/packages/media-signaling/src/definition/signals/server/new.ts @@ -12,4 +12,6 @@ export type ServerMediaSignalNewCall = { /** This will only be sent to the caller, with the id it used to request this call */ requestedCallId?: string; + /** If this new call initiated from a transfer, this will hold the id of the call that was transfered */ + replacingCallId?: string; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 8bbaafcfb93e2..761ebcd713f1b 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -13,7 +13,7 @@ import type { CallActorType, } from '../definition/call'; import type { ClientContractState, ClientState } from '../definition/client'; -import type { IWebRTCProcessor, MediaStreamFactory, WebRTCInternalStateMap } from '../definition/services'; +import type { IWebRTCProcessor, WebRTCInternalStateMap } from '../definition/services'; import { mergeContacts } from './utils/mergeContacts'; import type { IMediaSignalLogger } from '../definition/logger'; import { isPendingState } from './services/states'; @@ -29,7 +29,6 @@ export interface IClientMediaCallConfig { logger?: IMediaSignalLogger; transporter: MediaSignalTransportWrapper; processorFactories: IServiceProcessorFactoryList; - mediaStreamFactory?: MediaStreamFactory; iceGatheringTimeout: number; } @@ -156,7 +155,7 @@ export class ClientMediaCall implements IClientMediaCall { constructor( private readonly config: IClientMediaCallConfig, callId: string, - { inputTrack }: { inputTrack: MediaStreamTrack | null }, + { inputTrack }: { inputTrack?: MediaStreamTrack | null } = {}, ) { this.emitter = new Emitter(); @@ -176,7 +175,7 @@ export class ClientMediaCall implements IClientMediaCall { this.serviceStates = new Map(); this.stateReporterTimeoutHandler = null; this.mayReportStates = true; - this.inputTrack = inputTrack; + this.inputTrack = inputTrack || null; this.currentNegotiationId = null; this.earlySignals = new Set(); @@ -234,7 +233,7 @@ export class ClientMediaCall implements IClientMediaCall { } /** initialize a call with the data received from the server on a 'new' signal; this gets executed once for every call */ - public async initializeRemoteCall(signal: ServerMediaSignalNewCall): Promise { + public async initializeRemoteCall(signal: ServerMediaSignalNewCall, oldCall?: ClientMediaCall | null): Promise { if (this.hasRemoteData) { return; } @@ -251,9 +250,13 @@ export class ClientMediaCall implements IClientMediaCall { this.changeContact(signal.contact); - if (this._role === 'caller' && !this.acceptedLocally && AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS) { - this.config.logger?.log('Ignoring Unknown Outbound Call'); - this.ignore(); + if (this._role === 'caller' && !this.acceptedLocally) { + if (oldCall) { + this.acceptedLocally = true; + } else if (AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS) { + this.config.logger?.log('Ignoring Unknown Outbound Call'); + this.ignore(); + } } // If it's flagged as ignored even before the initialization, tell the server we're unavailable @@ -278,7 +281,7 @@ export class ClientMediaCall implements IClientMediaCall { } // If the call was requested by this specific session, assume we're signed already. - if (this._role === 'caller' && this.acceptedLocally && signal.requestedCallId === this.localCallId) { + if (this._role === 'caller' && this.acceptedLocally && (signal.requestedCallId === this.localCallId || Boolean(oldCall))) { this.contractState = 'pre-signed'; } @@ -289,6 +292,30 @@ export class ClientMediaCall implements IClientMediaCall { await this.processEarlySignals(); } + public mayNeedInputTrack(): boolean { + if (this.isOver() || this._ignored || this.hidden) { + return false; + } + + return true; + } + + public needsInputTrack(): boolean { + if (!this.mayNeedInputTrack()) { + return false; + } + + return ['active', 'renegotiating'].includes(this._state); + } + + public hasInputTrack(): boolean { + return Boolean(this.inputTrack); + } + + public isMissingInputTrack(): boolean { + return !this.hasInputTrack() && this.mayNeedInputTrack(); + } + public getClientState(): ClientState { if (this.isOver()) { return 'hangup'; @@ -329,17 +356,12 @@ export class ClientMediaCall implements IClientMediaCall { } public async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { - const oldId = this.inputTrack?.id; - const newId = newInputTrack?.id; + this.config.logger?.debug('ClientMediaCall.setInputTrack'); this.inputTrack = newInputTrack; if (this.webrtcProcessor) { await this.webrtcProcessor.setInputTrack(newInputTrack); } - - if (oldId !== newId) { - this.emitter.emit('trackStateChange'); - } } public getRemoteMediaStream(): MediaStream { @@ -353,7 +375,7 @@ export class ClientMediaCall implements IClientMediaCall { return this.webrtcProcessor.getRemoteMediaStream(); } - public async processSignal(signal: ServerMediaSignal) { + public async processSignal(signal: ServerMediaSignal, oldCall?: ClientMediaCall | null) { if (this.isOver()) { return; } @@ -362,7 +384,7 @@ export class ClientMediaCall implements IClientMediaCall { const { type: signalType } = signal; if (signalType === 'new') { - return this.initializeRemoteCall(signal); + return this.initializeRemoteCall(signal, oldCall); } if (!this.hasRemoteData) { @@ -418,6 +440,18 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('hangup'); } + public transfer(callee: { type: CallActorType; id: string }): void { + if (!this.busy) { + return; + } + + this.config.logger?.debug('ClientMediaCall.transfer', callee); + + this.config.transporter.sendToServer(this.callId, 'transfer', { + to: callee, + }); + } + public hangup(reason: CallHangupReason = 'normal'): void { this.config.logger?.debug('ClientMediaCall.hangup', reason); if (this.endedLocally || this._state === 'hangup') { @@ -978,7 +1012,6 @@ export class ClientMediaCall implements IClientMediaCall { } private clearStateReporter(): void { - this.config.logger?.debug('ClientMediaCall.clearStateReporter'); if (this.stateReporterTimeoutHandler) { clearTimeout(this.stateReporterTimeoutHandler); this.stateReporterTimeoutHandler = null; @@ -986,7 +1019,6 @@ export class ClientMediaCall implements IClientMediaCall { } private requestStateReport(): void { - this.config.logger?.debug('ClientMediaCall.requestStateReport'); this.clearStateReporter(); if (!this.mayReportStates) { return; @@ -1009,7 +1041,6 @@ export class ClientMediaCall implements IClientMediaCall { } const { - mediaStreamFactory, logger, processorFactories: { webrtc: webrtcFactory }, iceGatheringTimeout, @@ -1019,7 +1050,7 @@ export class ClientMediaCall implements IClientMediaCall { this.throwError('webrtc-not-implemented'); } - this.webrtcProcessor = webrtcFactory({ mediaStreamFactory, logger, iceGatheringTimeout, call: this, inputTrack: this.inputTrack }); + this.webrtcProcessor = webrtcFactory({ logger, iceGatheringTimeout, call: this, inputTrack: this.inputTrack }); this.webrtcProcessor.emitter.on('internalError', (event) => this.onWebRTCInternalError(event)); this.webrtcProcessor.emitter.on('internalStateChange', (stateName) => this.onWebRTCInternalStateChange(stateName)); this.webrtcProcessor.emitter.on('negotiationNeeded', () => this.onWebRTCNegotiationNeeded()); diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index df3c8920d060f..dfe2cb48c817a 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -31,7 +31,7 @@ export type MediaSignalingSessionConfig = { oldSessionId?: string; logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; - mediaStreamFactory?: MediaStreamFactory; + mediaStreamFactory: MediaStreamFactory; transport: MediaSignalTransport; iceGatheringTimeout?: number; }; @@ -54,6 +54,16 @@ export class MediaSignalingSession extends Emitter { private callCount = 0; + private inputTrack: MediaStreamTrack | null; + + private updatingInputTrack: boolean; + + private deviceId: ConstrainDOMString | null; + + private currentDeviceId: ConstrainDOMString | null; + + private callsToGetUserMedia: number; + public get sessionId(): string { return this._sessionId; } @@ -70,6 +80,11 @@ export class MediaSignalingSession extends Emitter { this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); + this.inputTrack = null; + this.updatingInputTrack = false; + this.deviceId = null; + this.currentDeviceId = null; + this.callsToGetUserMedia = 0; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); @@ -141,19 +156,30 @@ export class MediaSignalingSession extends Emitter { } } - await call.processSignal(signal); + const oldCall = this.getReplacedCallBySignal(signal); + await call.processSignal(signal, oldCall); + } + + public async setDeviceId(deviceId: ConstrainDOMString | null): Promise { + this.deviceId = deviceId; + // do nothing if: + // 1. doesn't have any input track yet + // 2. it's the same device id + // 3. has no restriction on which device to use + if (!this.inputTrack || deviceId === this.currentDeviceId || !deviceId) { + return; + } + + await this.setInputTrack(null); + await this.startInputTrack(); } - public async startCall( - calleeType: CallActorType, - calleeId: string, - params: { contactInfo?: CallContact; inputTrack: MediaStreamTrack | null }, - ): Promise { + public async startCall(calleeType: CallActorType, calleeId: string, params: { contactInfo?: CallContact } = {}): Promise { this.config.logger?.debug('MediaSignalingSession.startCall', calleeId); - const { contactInfo, ...callParams } = params; + const { contactInfo } = params; const callId = this.createTemporaryCallId(); - const call = this.createCall(callId, callParams); + const call = this.createCall(callId); await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } @@ -212,6 +238,14 @@ export class MediaSignalingSession extends Emitter { return null; } + private getReplacedCallBySignal(signal: ServerMediaSignal): ClientMediaCall | null { + if ('replacingCallId' in signal && signal.replacingCallId) { + return this.knownCalls.get(signal.replacingCallId) || null; + } + + return null; + } + private getOrCreateCallBySignal(signal: ServerMediaSignal): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.getOrCreateCallBySignal', signal); const existingCall = this.getExistingCallBySignal(signal); @@ -219,7 +253,7 @@ export class MediaSignalingSession extends Emitter { return existingCall; } - return this.createCall(signal.callId, { inputTrack: null }); + return this.createCall(signal.callId); } private reportState(): void { @@ -241,17 +275,133 @@ export class MediaSignalingSession extends Emitter { }); } - private createCall(callId: string, { inputTrack }: { inputTrack: MediaStreamTrack | null }): ClientMediaCall { + private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { + this.config.logger?.debug('MediaSignalingSession.setInputTrack'); + const { inputTrack: oldInputTrack } = this; + if (newInputTrack === oldInputTrack) { + return; + } + + this.inputTrack = newInputTrack; + + for await (const call of this.knownCalls.values()) { + await call.setInputTrack(newInputTrack); + } + + if (oldInputTrack) { + oldInputTrack.stop(); + } + } + + private requestInputTrackUpdate(): void { + if (this.updatingInputTrack || this.callsToGetUserMedia > 0) { + return; + } + + this.updateInputTrack().catch(() => null); + } + + private async updateInputTrack(): Promise { + this.updatingInputTrack = true; + + try { + if (this.inputTrack) { + await this.maybeStopInputTrack(); + return; + } + + await this.maybeStartInputTrack(); + } finally { + this.updatingInputTrack = this.callsToGetUserMedia > 0; + } + } + + private async maybeStartInputTrack(): Promise { + this.config.logger?.debug('MediaSignalingSession.maybeStartInputTrack'); + for (const call of this.knownCalls.values()) { + if (!call.mayNeedInputTrack()) { + continue; + } + + return this.startInputTrack(); + } + } + + private getAudioConstraints(): boolean | MediaTrackConstraints { + if (this.deviceId) { + return { deviceId: this.deviceId }; + } + + return true; + } + + private async startInputTrack(): Promise { + this.config.logger?.debug('MediaSignalingSession.startInputTrack'); + + this.currentDeviceId = this.deviceId; + + let userMedia: MediaStream | null = null; + this.callsToGetUserMedia++; + try { + userMedia = await this.config.mediaStreamFactory({ audio: this.getAudioConstraints() }).catch(() => null); + } finally { + this.callsToGetUserMedia--; + } + + // If there's multiple simultaneous attempts to get the track, only process the output of the last one + if (this.callsToGetUserMedia > 0) { + return; + } + + if (!userMedia) { + return this.hangupCallsThatNeedInput(); + } + + const tracks = userMedia.getAudioTracks(); + if (!tracks.length) { + return this.hangupCallsThatNeedInput(); + } + + return this.setInputTrack(tracks[0]); + } + + private hangupCallsThatNeedInput(): void { + this.config.logger?.debug('MediaSignalingSession.hangupCallsThatNeedInput'); + + for (const call of this.knownCalls.values()) { + if (!call.needsInputTrack()) { + continue; + } + + try { + call.hangup('service-error'); + } catch { + // + } + } + } + + private async maybeStopInputTrack(): Promise { + this.config.logger?.debug('MediaSignalingSession.maybeStopInputTrack'); + for (const call of this.knownCalls.values()) { + if (call.mayNeedInputTrack()) { + return; + } + } + + await this.setInputTrack(null); + } + + private createCall(callId: string): ClientMediaCall { this.config.logger?.debug('MediaSignalingSession.createCall'); const config = { logger: this.config.logger, transporter: this.transporter, processorFactories: this.config.processorFactories, - mediaStreamFactory: this.config.mediaStreamFactory, - iceGatheringTimeout: this.config.iceGatheringTimeout || 500, + iceGatheringTimeout: this.config.iceGatheringTimeout || 1000, }; - const call = new ClientMediaCall(config, callId, { inputTrack }); + const call = new ClientMediaCall(config, callId, { inputTrack: this.inputTrack }); this.knownCalls.set(callId, call); call.emitter.on('contactUpdate', () => this.onCallContactUpdate(call)); @@ -353,5 +503,6 @@ export class MediaSignalingSession extends Emitter { private onSessionStateChange(): void { this.emit('sessionStateChange'); + this.requestInputTrackUpdate(); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index fc09fde288f96..727bf15f64304 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,6 +1,8 @@ import { Stream } from './Stream'; export class LocalStream extends Stream { + private transceiver: RTCRtpTransceiver | null = null; + public async setTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { if (newTrack) { const matchingTrack = this.mediaStream.getTrackById(newTrack.id); @@ -22,11 +24,13 @@ export class LocalStream extends Stream { private async setRemoteTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { // If the peer doesn't yet have any audio track, send it to them - const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio'); + const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio' || sender === this.transceiver?.sender); if (!sender) { if (newTrack) { // This will require a re-negotiation peer.addTrack(newTrack, this.mediaStream); + } else { + this.transceiver = peer.addTransceiver('audio', { direction: 'sendrecv' }); } return; } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index c188ba9253776..be323d3742847 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -27,8 +27,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private iceGatheringWaiters: Set; - private inputTrackWaiter: PromiseWaiterData | null; - private inputTrack: MediaStreamTrack | null; private _muted = false; @@ -50,7 +48,6 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.remoteMediaStream = new MediaStream(); this.iceGatheringWaiters = new Set(); this.inputTrack = config.inputTrack; - this.inputTrackWaiter = null; this.localStream = new LocalStream(this.localMediaStream); this.remoteStream = new RemoteStream(this.remoteMediaStream); @@ -71,11 +68,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } this.inputTrack = newInputTrack; - if (this.inputTrackWaiter && !this.inputTrackWaiter.done) { - this.inputTrackWaiter.promiseResolve(); - } else if (this.localMediaStreamInitialized) { - await this.loadInputTrack(); - } + await this.loadInputTrack(); } public async createOffer({ iceRestart }: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }> { @@ -118,7 +111,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.config.logger?.debug('MediaCallWebRTCProcessor.stop'); this.stopped = true; - this.localStream.stopAudio(); + // Stop only the remote stream; the track of the local stream may still be in use by another call so it's up to the session to stop it. this.remoteStream.stopAudio(); } @@ -325,65 +318,17 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.changeInternalState('iceGathering'); } - private async waitForInputTrack(): Promise { - this.config.logger?.debug('MediaCallWebRTCProcessor.waitForInputTrack'); - if (this.inputTrack || this.stopped) { - return; - } - - if (this.inputTrackWaiter && !this.inputTrackWaiter.done) { - return this.inputTrackWaiter.promise; - } - - const tracker = getExternalWaiter({ - timeout: 30000, - timeoutFn: () => { - if (this.inputTrack) { - tracker.promiseResolve(); - return; - } - - this.config.logger?.error('MediaCallWebRTCProcessor.waitForInputTrack - Timeout reached with no input track in place.'); - this.emitter.emit('internalError', { critical: true, error: 'no-input-track' }); - }, - cleanupFn: () => { - if (this.inputTrackWaiter === tracker) { - this.inputTrackWaiter = null; - } - }, - }); - this.inputTrackWaiter = tracker; - return this.inputTrackWaiter.promise; - } - private async initializeLocalMediaStream(): Promise { if (this.localMediaStreamInitialized) { return; } this.config.logger?.debug('MediaCallWebRTCProcessor.initializeLocalMediaStream'); - const { mediaStreamFactory } = this.config; - - if (mediaStreamFactory) { - const userMedia = await mediaStreamFactory({ audio: true }); - const tracks = userMedia.getAudioTracks(); - - if (!tracks.length) { - this.config.logger?.error('MediaCallWebRTCProcessor.initializeLocalMediaStream - Media stream has no audio tracks.'); - this.emitter.emit('internalError', { critical: true, error: 'no-input-track' }); - throw new Error('Media Stream has no audio tracks.'); - } - - this.inputTrack = tracks[0]; - } await this.loadInputTrack(); } private async loadInputTrack(): Promise { - this.config.logger?.debug('MediaCallWebRTCProcessor.loadInputTrack'); - this.localMediaStreamInitialized = true; - await this.waitForInputTrack(); await this.localStream.setTrack(this.inputTrack, this.peer); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts index 8f059d2d3fe6b..1114e7e2e0a0f 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Stream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -9,21 +9,16 @@ export class Stream { } public enable(): void { - this.enabled = true; - this.toggleAudioTracks(); + this.setEnabled(true); } public disable(): void { - this.enabled = false; - this.toggleAudioTracks(); + this.setEnabled(false); } public setEnabled(enabled: boolean): void { - if (enabled) { - this.enable(); - } else { - this.disable(); - } + this.enabled = enabled; + this.toggleAudioTracks(); } public stopAudio(): void { @@ -46,7 +41,6 @@ export class Stream { return; } - track.stop(); this.mediaStream.removeTrack(track); }); } @@ -62,5 +56,6 @@ export class Stream { this.removeAudioTracks(); this.mediaStream.addTrack(newTrack); + newTrack.enabled = this.enabled; } } diff --git a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts index cc0f797d56181..01cefd7e1f513 100644 --- a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts +++ b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts @@ -12,7 +12,7 @@ export type PromiseWaiterParams = { cleanupFn?: () => void; }; -export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWaiterParams): PromiseWaiterData { +export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWaiterParams = {}): PromiseWaiterData { const data: Partial = { timeout: null, done: false, diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index ca3285fe53f17..9f6f2449ca311 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallActorType, MediaCallContact, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -18,6 +18,7 @@ export interface IMediaCallsModel extends IBaseModel { activateCallById(callId: string, expiresAt: Date): Promise; setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; + transferCallById(callId: string, params: { by: MediaCallSignedActor; to: MediaCallContact }): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; hasUnfinishedCalls(): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index c6d4bff8b541f..7851f2ade0334 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -1,4 +1,10 @@ -import type { IMediaCall, RocketChatRecordDeleted, MediaCallActorType } from '@rocket.chat/core-typings'; +import type { + IMediaCall, + RocketChatRecordDeleted, + MediaCallActorType, + MediaCallSignedActor, + MediaCallContact, +} from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { IndexDescription, @@ -135,6 +141,27 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } + public async transferCallById(callId: string, params: { by: MediaCallSignedActor; to: MediaCallContact }): Promise { + return this.updateOne( + { + _id: callId, + state: { + $in: ['accepted', 'active'], + }, + transferedAt: { + $exists: false, + }, + }, + { + $set: { + transferedAt: new Date(), + transferedBy: params.by, + transferedTo: params.to, + }, + }, + ); + } + public findAllExpiredCalls(options?: FindOptions): FindCursor { return this.find( { From fb3abc397d9b2acacf6e0c7566260469a4ed4fc5 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 12 Sep 2025 14:36:46 -0300 Subject: [PATCH 53/72] improving code quality + applying some suggestions from code review --- .../server/services/media-call/service.ts | 6 ++-- ee/packages/media-calls/src/base/BaseAgent.ts | 2 +- .../src/definition/IMediaCallAgent.ts | 2 +- .../src/definition/IMediaCallServer.ts | 2 +- .../src/internal/InternalCallProvider.ts | 2 +- .../src/internal/SignalProcessor.ts | 10 +++++-- .../src/internal/agents/BaseUserAgent.ts | 12 ++++---- .../internal/agents/CallSignalProcessor.ts | 5 ++-- .../media-calls/src/server/CallDirector.ts | 19 +++++++----- .../media-calls/src/server/MediaCallServer.ts | 8 ++--- .../core-typings/src/mediaCalls/IMediaCall.ts | 8 ++--- .../src/definition/call/CallEvents.ts | 2 +- .../src/definition/call/IClientMediaCall.ts | 2 +- .../media-signaling/src/definition/client.ts | 2 ++ .../services/webrtc/IWebRTCProcessor.ts | 2 +- .../src/definition/signals/client/answer.ts | 3 ++ .../src/definition/signals/client/error.ts | 3 ++ .../src/definition/signals/client/hangup.ts | 14 +++++++++ .../definition/signals/client/local-sdp.ts | 3 ++ .../definition/signals/client/local-state.ts | 18 +++++++++++ .../src/definition/signals/client/register.ts | 1 + .../definition/signals/client/request-call.ts | 6 ++++ .../src/definition/signals/client/transfer.ts | 4 +++ .../src/definition/signals/server/new.ts | 2 +- packages/media-signaling/src/lib/Call.ts | 13 ++++---- packages/media-signaling/src/lib/Session.ts | 11 +++---- .../src/lib/TransportWrapper.ts | 2 +- .../src/lib/services/webrtc/LocalStream.ts | 16 +++++++--- .../src/lib/services/webrtc/Processor.ts | 10 +++---- .../src/lib/utils/getExternalWaiter.ts | 8 ++--- .../src/lib/utils/mergeContacts.ts | 27 ----------------- .../src/models/IMediaCallNegotiationsModel.ts | 10 +------ .../src/models/IMediaCallsModel.ts | 10 ++----- .../src/models/MediaCallNegotiations.ts | 30 ++----------------- packages/models/src/models/MediaCalls.ts | 30 +++++++------------ 35 files changed, 151 insertions(+), 154 deletions(-) delete mode 100644 packages/media-signaling/src/lib/utils/mergeContacts.ts diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 22501eae8f824..508e6a1feddfa 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -27,7 +27,7 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { try { - logger.debug({ msg: 'new client signal', signal, uid }); + logger.debug({ msg: 'new client signal', type: signal.type, uid }); callServer.receiveSignal(uid, signal); } catch (error) { logger.error({ msg: 'failed to process client signal', error, signal, uid }); @@ -36,13 +36,13 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise { try { - logger.debug({ msg: 'new client signal', signal, uid }); + logger.debug({ msg: 'new client signal', uid }); const deserialized = await this.deserializeClientSignal(signal); callServer.receiveSignal(uid, deserialized); } catch (error) { - logger.error({ msg: 'failed to process client signal', error, signal, uid }); + logger.error({ msg: 'failed to process client signal', error, uid }); } } diff --git a/ee/packages/media-calls/src/base/BaseAgent.ts b/ee/packages/media-calls/src/base/BaseAgent.ts index f237b0a2904c5..5f79f6caf8e67 100644 --- a/ee/packages/media-calls/src/base/BaseAgent.ts +++ b/ee/packages/media-calls/src/base/BaseAgent.ts @@ -75,7 +75,7 @@ export abstract class BaseMediaCallAgent implements IMediaCallAgent { public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; - public abstract onCallTransfered(callId: string): Promise; + public abstract onCallTransferred(callId: string): Promise; protected async createOrUpdateChannel(call: IMediaCall, contractId: string): Promise { if (!contractId) { diff --git a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts index 8187723fa1ace..9f9035bfbab40 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallAgent.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallAgent.ts @@ -18,7 +18,7 @@ export interface IMediaCallAgent { /* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */ onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise; - onCallTransfered(callId: string): Promise; + onCallTransferred(callId: string): Promise; getMyCallActor(call: IMediaCall): MediaCallActor; } diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 03c82ad270220..ff967638f5639 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -13,7 +13,7 @@ export interface IMediaCallServerSettings { internalCalls: { requireExtensions: boolean; - routeExternally: 'never' | 'preferrably' | 'always'; + routeExternally: 'never' | 'preferably' | 'always'; }; sip: { diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts index e132db567d90f..13c92b9ffddfc 100644 --- a/ee/packages/media-calls/src/internal/InternalCallProvider.ts +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -34,7 +34,7 @@ export class InternalCallProvider extends BaseCallProvider { await MediaCallDirector.runOnCallCreatedForAgent(call, calleeAgent, callerAgent); if (params.parentCallId) { - MediaCallDirector.hangupTransferedCallById(params.parentCallId).catch(() => null); + MediaCallDirector.hangupTransferredCallById(params.parentCallId).catch(() => null); } return call; diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index bea829f5d6442..00e61e2af839c 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -143,15 +143,19 @@ export class GlobalSignalProcessor { const caller = { type: 'user', id: uid } as const; const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const; - const call = await MediaCalls.findOneByIdOrCallerRequestedId(requestedCallId, caller); + // The requestedCallId must never match a real call id + const matchingValidCall = await MediaCalls.findOneById(requestedCallId, { projection: { _id: 1 } }); + if (matchingValidCall) { + this.invalidCallId(uid, rejection); + } + const call = await MediaCalls.findOneByCallerRequestedId(requestedCallId, caller); if (!call) { return null; } - // if the requested id matches a real call id, we block it to avoid any potential issues as the requestedId is not expected to ever be an actual call id. // if the call is already over, we treat it as an invalid id since it can't be reused - if (call._id === requestedCallId || call.state === 'hangup') { + if (call.state === 'hangup') { this.invalidCallId(uid, rejection); } diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts index d9a8ca1b932e3..cb19f308243e8 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts @@ -115,25 +115,25 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { }); } - public async onCallTransfered(callId: string): Promise { - logger.debug({ msg: 'UserActorAgent.onCallTransfered', callId }); + public async onCallTransferred(callId: string): Promise { + logger.debug({ msg: 'UserActorAgent.onCallTransferred', callId }); const call = await MediaCalls.findOneById(callId); - if (!call?.transferedBy || !call?.transferedTo) { + if (!call?.transferredBy || !call?.transferredTo) { return; } const actor = this.getMyCallActor(call); - // If we haven't signed yet, we can't be transfered + // If we haven't signed yet, we can't be transferred if (!actor.contractId) { return; } getMediaCallServer().requestCall({ caller: actor as MediaCallSignedContact, - callee: call.transferedTo, + callee: call.transferredTo, requestedService: call.service, - requestedBy: call.transferedBy, + requestedBy: call.transferredBy, parentCallId: call._id, }); } diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 5e3ea9c235d23..95030cc65d166 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -102,7 +102,7 @@ export class UserActorSignalProcessor { } protected async saveLocalDescription(sdp: RTCSessionDescriptionInit, negotiationId: string): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp }); + logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdpType: sdp?.type, hasSdpBody: Boolean(sdp?.sdp) }); await MediaCallDirector.saveWebrtcSession(this.call, this.agent, { sdp, negotiationId }, this.contractId); } @@ -191,6 +191,7 @@ export class UserActorSignalProcessor { protected async clientIsUnavailable(): Promise { logger.debug({ msg: 'UserActorSignalProcessor.clientIsUnavailable', role: this.role, uid: this.actorId }); + // Ignore 'unavailable' responses as some other client session may have a different answer } protected async clientHasAccepted(): Promise { @@ -243,7 +244,7 @@ export class UserActorSignalProcessor { private async onSignalingError(errorMessage?: string): Promise { logger.error({ msg: 'Client reported a signaling error', errorMessage, callId: this.callId, role: this.role, state: this.call.state }); - await MediaCallDirector.hangup(this.call, this.agent, 'service-error'); + await MediaCallDirector.hangup(this.call, this.agent, 'signaling-error'); } private async onServiceError(errorMessage?: string): Promise { diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 391012747e28e..c9822b7b107c2 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -57,7 +57,9 @@ export class MediaCallDirector { // Once the state changes, negotiations need to be referred by id. const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id); - const stateResult = await MediaCalls.acceptCallById(call._id, data, this.getNewExpirationTime()); + const { webrtcAnswer, ...acceptData } = data; + + const stateResult = await MediaCalls.acceptCallById(call._id, acceptData, this.getNewExpirationTime()); // If nothing changed, the call was no longer ringing if (!stateResult.modifiedCount) { return false; @@ -198,7 +200,6 @@ export class MediaCallDirector { expiresAt: MediaCallDirector.getNewExpirationTime(), ...(requestedCallId && { callerRequestedId: requestedCallId }), - ...(webrtcOffer && { webrtcOffer }), ...(parentCallId && { parentCallId }), }; @@ -214,6 +215,10 @@ export class MediaCallDirector { throw new Error('failed-to-retrieve-call'); } + if (webrtcOffer) { + await this.startNewNegotiation(newCall, 'caller', webrtcOffer); + } + return newCall; } @@ -233,17 +238,17 @@ export class MediaCallDirector { return; } - agent.oppositeAgent.onCallTransfered(call._id); + await agent.oppositeAgent.onCallTransferred(call._id); } - public static async hangupTransferedCallById(callId: string): Promise { - logger.debug({ msg: 'MediaCallDirector.hangupTransferedCallById', callId }); + public static async hangupTransferredCallById(callId: string): Promise { + logger.debug({ msg: 'MediaCallDirector.hangupTransferredCallById', callId }); const call = await MediaCalls.findOneById(callId); - if (!call?.transferedBy) { + if (!call?.transferredBy) { return; } - return this.hangupDetachedCall(call, { endedBy: call.transferedBy, reason: 'transfer' }); + return this.hangupDetachedCall(call, { endedBy: call.transferredBy, reason: 'transfer' }); } public static async hangupExpiredCalls(): Promise { diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 15b6ea5add810..0553433cab904 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -37,7 +37,7 @@ export class MediaCallServer implements IMediaCallServer { } public receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void { - logger.debug({ msg: 'MediaCallServer.receiveSignal', signal, fromUid }); + logger.debug({ msg: 'MediaCallServer.receiveSignal', type: signal.type, fromUid }); if (!isClientMediaSignal(signal)) { logger.error({ msg: 'The Media Signal Server received an invalid client signal object' }); @@ -50,7 +50,7 @@ export class MediaCallServer implements IMediaCallServer { } public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { - logger.debug({ msg: 'MediaCallServer.sendSignal', toUid, signal }); + logger.debug({ msg: 'MediaCallServer.sendSignal', toUid, type: signal.type }); this.emitter.emit('signalRequest', { toUid, signal }); } @@ -91,7 +91,7 @@ export class MediaCallServer implements IMediaCallServer { } public scheduleExpirationCheck(): void { - return MediaCallDirector.scheduleExpirationCheck(); + MediaCallDirector.scheduleExpirationCheck(); } public configure(settings: IMediaCallServerSettings): void { @@ -150,7 +150,7 @@ export class MediaCallServer implements IMediaCallServer { return { preferredType: 'user', }; - case 'preferrably': + case 'preferably': // Will only skip sip for users that don't have an assigned extension (or not call at all if `requireExtensions` is true) return { preferredType: 'sip', diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index b2e21672def33..7f64b0a1f96f3 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -51,8 +51,8 @@ export interface IMediaCall extends IRocketChatRecord { callerRequestedId?: string; parentCallId?: string; - /** transfered* fields are filled as soon as the transfer is requested, but the old call will only end when the new one is created */ - transferedBy?: MediaCallSignedActor; - transferedTo?: MediaCallContact; - transferedAt?: Date; + /** transferred* fields are filled as soon as the transfer is requested, but the old call will only end when the new one is created */ + transferredBy?: MediaCallSignedActor; + transferredTo?: MediaCallContact; + transferredAt?: Date; } diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 09959c8acae89..50a82dfbe3efc 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -2,7 +2,7 @@ import type { ClientState } from '../client'; import type { CallState } from './IClientMediaCall'; export type CallEvents = { - /* Triggered when the call's server state is changed on this client */ + /* Triggered when the call's server state is changed on this client, with the old state as param */ stateChange: CallState; /* Triggered when the call's client state is changed (based on user interaction or internal service state) */ clientStateChange: ClientState; diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 23334ccedc3eb..3d70641196aee 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -31,7 +31,7 @@ export type CallHangupReason = | 'remote' // Server told the client to hang up | 'rejected' // The callee rejected the call | 'unavailable' // The actor is not available - | 'transfer' // one of the users requested the other be transfered to someone else + | 'transfer' // one of the users requested the other be transferred to someone else | 'timeout' // The call state hasn't progressed for too long | 'signaling-error' // Hanging up because of an error during the signal processing | 'service-error' // Hanging up because of an error setting up the service connection diff --git a/packages/media-signaling/src/definition/client.ts b/packages/media-signaling/src/definition/client.ts index 73355bb0fd740..9b2ac8005406e 100644 --- a/packages/media-signaling/src/definition/client.ts +++ b/packages/media-signaling/src/definition/client.ts @@ -18,3 +18,5 @@ export type ClientContractState = | 'pre-signed' // the session that requested a call is assuming it will be signed into it | 'self-signed' // the call has progressed beyond the signing stage without any signature confirmation | 'ignored'; // the server signed a contract from a different session + +export type RandomStringFactory = () => string; diff --git a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts index 31e982e1ca4fb..bdd62b73c8ebc 100644 --- a/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts +++ b/packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts @@ -25,7 +25,7 @@ export interface IWebRTCProcessor extends IServiceProcessor; - startNewNegotiation(): Promise; + startNewNegotiation(): void; createOffer(params: { iceRestart?: boolean }): Promise<{ sdp: RTCSessionDescriptionInit }>; createAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }>; setRemoteAnswer(params: { sdp: RTCSessionDescriptionInit }): Promise; diff --git a/packages/media-signaling/src/definition/signals/client/answer.ts b/packages/media-signaling/src/definition/signals/client/answer.ts index 06e6427892296..61e9ad8e89765 100644 --- a/packages/media-signaling/src/definition/signals/client/answer.ts +++ b/packages/media-signaling/src/definition/signals/client/answer.ts @@ -17,10 +17,12 @@ export const clientMediaSignalAnswerSchema: JSONSchemaType { this.config.logger?.debug('ClientMediaCall.processEarlySignals'); - const earlySignals = this.earlySignals.values().toArray(); + const earlySignals = Array.from(this.earlySignals.values()); this.earlySignals.clear(); for await (const signal of earlySignals) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index dfe2cb48c817a..ad99ca6ce838b 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -8,6 +8,7 @@ import type { IServiceProcessorFactoryList, MediaSignalTransport, MediaStreamFactory, + RandomStringFactory, ServerMediaSignal, } from '../definition'; import type { IClientMediaCall, CallState, CallActorType, CallContact } from '../definition/call'; @@ -32,12 +33,12 @@ export type MediaSignalingSessionConfig = { logger?: IMediaSignalLogger; processorFactories: IServiceProcessorFactoryList; mediaStreamFactory: MediaStreamFactory; + randomStringFactory: RandomStringFactory; transport: MediaSignalTransport; iceGatheringTimeout?: number; }; const STATE_REPORT_INTERVAL = 60000; -let sessionCount = 0; export class MediaSignalingSession extends Emitter { private _userId: string; @@ -52,8 +53,6 @@ export class MediaSignalingSession extends Emitter { private recurringStateReportHandler: ReturnType | null; - private callCount = 0; - private inputTrack: MediaStreamTrack | null; private updatingInputTrack: boolean; @@ -74,9 +73,8 @@ export class MediaSignalingSession extends Emitter { constructor(private config: MediaSignalingSessionConfig) { super(); - sessionCount++; this._userId = config.userId; - this._sessionId = `${sessionCount}-${Date.now().toString()}`; + this._sessionId = config.randomStringFactory(); this.recurringStateReportHandler = null; this.knownCalls = new Map(); this.ignoredCalls = new Set(); @@ -189,8 +187,7 @@ export class MediaSignalingSession extends Emitter { } private createTemporaryCallId(): string { - this.callCount++; - return `${this._sessionId}-${this.callCount}-${Date.now().toString()}`; + return `${this._sessionId}-${this.config.randomStringFactory()}`; } private isSignalTargetingAnotherSession(signal: ServerMediaSignal): boolean { diff --git a/packages/media-signaling/src/lib/TransportWrapper.ts b/packages/media-signaling/src/lib/TransportWrapper.ts index ff4ce600b552a..d1eae5fc6d9c5 100644 --- a/packages/media-signaling/src/lib/TransportWrapper.ts +++ b/packages/media-signaling/src/lib/TransportWrapper.ts @@ -19,7 +19,7 @@ export class MediaSignalTransportWrapper { public sendToServer(callId: string, type: T, signal: ClientMediaSignalBody) { this.logger?.debug('MediaSignalTransportWrapper.sendToServer', type); return this.sendSignal({ - callId, + ...(type !== 'register' && { callId }), contractId: this.contractId, type, ...signal, diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index 727bf15f64304..576a28e9d3554 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,9 +1,13 @@ import { Stream } from './Stream'; export class LocalStream extends Stream { - private transceiver: RTCRtpTransceiver | null = null; + private emptyTransceiver: RTCRtpTransceiver | null = null; public async setTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { + if (newTrack?.kind !== 'audio') { + return; + } + if (newTrack) { const matchingTrack = this.mediaStream.getTrackById(newTrack.id); if (matchingTrack) { @@ -19,23 +23,27 @@ export class LocalStream extends Stream { this.mediaStream.addTrack(newTrack); } - this.setRemoteTrack(newTrack, peer); + await this.setRemoteTrack(newTrack, peer); } private async setRemoteTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { // If the peer doesn't yet have any audio track, send it to them - const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio' || sender === this.transceiver?.sender); + const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio' || sender === this.emptyTransceiver?.sender); if (!sender) { if (newTrack) { // This will require a re-negotiation peer.addTrack(newTrack, this.mediaStream); + this.emptyTransceiver = null; } else { - this.transceiver = peer.addTransceiver('audio', { direction: 'sendrecv' }); + this.emptyTransceiver = peer.addTransceiver('audio', { direction: 'sendrecv' }); } return; } // If the peer already has a track of the same kind, we can just replace it with the new track with no issues await sender.replaceTrack(newTrack); + if (newTrack) { + this.emptyTransceiver = null; + } } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index be323d3742847..fc31bac5b3787 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -115,7 +115,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.remoteStream.stopAudio(); } - public async startNewNegotiation(): Promise { + public startNewNegotiation(): void { this.iceGatheringFinished = false; this.clearIceGatheringWaiters(new Error('new-negotiation')); } @@ -133,7 +133,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (this.peer.remoteDescription?.sdp !== sdp.sdp) { this.startNewNegotiation(); - this.peer.setRemoteDescription(sdp); + await this.peer.setRemoteDescription(sdp); } const answer = await this.peer.createAnswer(); @@ -144,7 +144,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async setRemoteAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise { - this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteDescription'); + this.config.logger?.debug('MediaCallWebRTCProcessor.setRemoteAnswer'); if (this.stopped) { return; } @@ -153,7 +153,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { throw new Error('invalid-answer'); } - this.peer.setRemoteDescription(sdp); + await this.peer.setRemoteDescription(sdp); } public getInternalState(stateName: K): ServiceStateValue { @@ -370,7 +370,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - const waiters = this.iceGatheringWaiters.values().toArray(); + const waiters = Array.from(this.iceGatheringWaiters.values()); this.iceGatheringWaiters.clear(); for (const iceGatheringData of waiters) { diff --git a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts index 01cefd7e1f513..8c5ba59d0e127 100644 --- a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts +++ b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts @@ -32,20 +32,20 @@ export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWait }; data.promise = new Promise((resolve, reject) => { - data.promiseResolve = (...args: unknown[]) => { + data.promiseResolve = () => { if (data.done) { return; } flagAsDone(); - resolve(...(args as any)); + resolve(); }; - data.promiseReject = (...args: unknown[]) => { + data.promiseReject = (error: Error) => { if (data.done) { return; } flagAsDone(); - reject(...args); + reject(error); }; }); diff --git a/packages/media-signaling/src/lib/utils/mergeContacts.ts b/packages/media-signaling/src/lib/utils/mergeContacts.ts deleted file mode 100644 index b8079e46827e2..0000000000000 --- a/packages/media-signaling/src/lib/utils/mergeContacts.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { CallContact } from '../../definition'; - -export function mergeContacts(oldContact: CallContact | null, newContact: CallContact | null): CallContact | null { - if (!oldContact || !newContact) { - return newContact || oldContact; - } - - if (!Object.keys(newContact).length) { - return oldContact; - } - if (!Object.keys(oldContact).length) { - return newContact; - } - - const keys: (keyof CallContact)[] = ['type', 'id', 'username', 'sipExtension']; - - for (const key of keys) { - if (oldContact[key] && newContact[key] && oldContact[key] !== newContact[key]) { - return newContact; - } - } - - return { - ...oldContact, - ...newContact, - }; -} diff --git a/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts index 273ba3d2eb1d0..dbf497a457477 100644 --- a/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts +++ b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts @@ -1,21 +1,13 @@ import type { IMediaCallNegotiation } from '@rocket.chat/core-typings'; -import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; +import type { Document, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallNegotiationsModel extends IBaseModel { - findAllByCallId( - callId: IMediaCallNegotiation['callId'], - options?: FindOptions, - ): Promise>; findLatestByCallId( callId: IMediaCallNegotiation['callId'], options?: FindOptions, ): Promise; - findActiveByCallId( - callId: IMediaCallNegotiation['callId'], - options?: FindOptions, - ): Promise; setOfferById(id: string, offer: RTCSessionDescriptionInit): Promise; setAnswerById(id: string, answer: RTCSessionDescriptionInit): Promise; setStableById(id: string): Promise; diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 9f6f2449ca311..24a156d2d6a41 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -4,17 +4,13 @@ import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; export interface IMediaCallsModel extends IBaseModel { - findOneByIdOrCallerRequestedId( - id: IMediaCall['_id'] | Required['callerRequestedId'], + findOneByCallerRequestedId( + id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string }, options?: FindOptions, ): Promise; startRingingById(callId: string, expiresAt: Date): Promise; - acceptCallById( - callId: string, - data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit }, - expiresAt: Date, - ): Promise; + acceptCallById(callId: string, data: { calleeContractId: string }, expiresAt: Date): Promise; activateCallById(callId: string, expiresAt: Date): Promise; setExpiresAtById(callId: string, expiresAt: Date): Promise; hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; diff --git a/packages/models/src/models/MediaCallNegotiations.ts b/packages/models/src/models/MediaCallNegotiations.ts index 3eb0e399275d2..c3a1a111f1169 100644 --- a/packages/models/src/models/MediaCallNegotiations.ts +++ b/packages/models/src/models/MediaCallNegotiations.ts @@ -1,6 +1,6 @@ import type { RocketChatRecordDeleted, IMediaCallNegotiation } from '@rocket.chat/core-typings'; import type { IMediaCallNegotiationsModel } from '@rocket.chat/model-typings'; -import type { IndexDescription, Collection, Db, FindOptions, Document, FindCursor, UpdateResult } from 'mongodb'; +import type { IndexDescription, Collection, Db, FindOptions, Document, UpdateResult } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -10,14 +10,7 @@ export class MediaCallNegotiationsRaw extends BaseRaw imp } protected modelIndexes(): IndexDescription[] { - return [{ key: { callId: 1, requestTimestamp: -1, stableTimestamp: -1 }, unique: false, sparse: true }]; - } - - public async findAllByCallId( - callId: IMediaCallNegotiation['callId'], - options?: FindOptions, - ): Promise> { - return this.find({ callId }, options); + return [{ key: { callId: 1, requestTimestamp: -1 }, unique: false }]; } public async findLatestByCallId( @@ -38,25 +31,6 @@ export class MediaCallNegotiationsRaw extends BaseRaw imp ); } - public async findActiveByCallId( - callId: IMediaCallNegotiation['callId'], - options?: FindOptions, - ): Promise { - return this.findOne( - { - callId, - stableTimestamp: { $exists: true }, - }, - { - ...options, - sort: { - requestTimestamp: -1, - }, - limit: 1, - }, - ); - } - public async setOfferById(id: string, offer: RTCSessionDescriptionInit): Promise { return this.updateOne( { diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 7851f2ade0334..a4cb12aa00f62 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -36,19 +36,16 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ]; } - public async findOneByIdOrCallerRequestedId( - id: IMediaCall['_id'] | Required['callerRequestedId'], + public async findOneByCallerRequestedId( + id: Required['callerRequestedId'], caller: { type: MediaCallActorType; id: string }, options?: FindOptions, ): Promise { return this.findOne( { - $or: [ - // If we get an id, find any call with that id - { _id: id }, - // if we get a callerRequestedId, then only find it if it was created by the same actor - { 'caller.type': caller.type, 'caller.id': caller.id, 'callerRequestedId': id }, - ], + 'caller.type': caller.type, + 'caller.id': caller.id, + 'callerRequestedId': id, }, options, ); @@ -72,12 +69,8 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ); } - public async acceptCallById( - callId: string, - data: { calleeContractId: string; webrtcAnswer?: RTCSessionDescriptionInit }, - expiresAt: Date, - ): Promise { - const { calleeContractId, webrtcAnswer } = data; + public async acceptCallById(callId: string, data: { calleeContractId: string }, expiresAt: Date): Promise { + const { calleeContractId } = data; return this.updateOne( { @@ -89,7 +82,6 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod 'state': 'accepted', 'callee.contractId': calleeContractId, expiresAt, - ...(webrtcAnswer && { webrtcAnswer }), }, }, ); @@ -148,15 +140,15 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod state: { $in: ['accepted', 'active'], }, - transferedAt: { + transferredAt: { $exists: false, }, }, { $set: { - transferedAt: new Date(), - transferedBy: params.by, - transferedTo: params.to, + transferredAt: new Date(), + transferredBy: params.by, + transferredTo: params.to, }, }, ); From 69e4896b4baca32e599efd8a69990ab74215d5c3 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Fri, 12 Sep 2025 20:19:37 -0300 Subject: [PATCH 54/72] better state and presence management --- .../media-calls/src/definition/common.ts | 11 +- .../src/internal/InternalCallProvider.ts | 21 +++- .../src/internal/SignalProcessor.ts | 101 ++++++++++++++++-- .../media-calls/src/server/CallDirector.ts | 10 +- .../media-calls/src/server/MediaCallServer.ts | 22 ++-- .../core-typings/src/mediaCalls/IMediaCall.ts | 4 + .../src/definition/call/IClientMediaCall.ts | 7 +- .../src/definition/signals/client/register.ts | 2 +- .../signals/server/rejected-call-request.ts | 2 +- packages/media-signaling/src/lib/Call.ts | 50 ++++++--- packages/media-signaling/src/lib/Session.ts | 73 +++++++++---- .../src/models/IMediaCallsModel.ts | 4 +- packages/models/src/models/MediaCalls.ts | 38 +++++-- 13 files changed, 272 insertions(+), 73 deletions(-) diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index 264c1100e2a23..e02473dcf1b8f 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -1,5 +1,5 @@ import type { AtLeast, IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; -import type { CallService } from '@rocket.chat/media-signaling'; +import type { CallRejectedReason, CallService } from '@rocket.chat/media-signaling'; export type MinimalUserData = Pick; @@ -18,3 +18,12 @@ export type InternalCallParams = { }; export type MediaCallHeader = AtLeast; + +export class CallRejectedError extends Error { + constructor( + public readonly callRejectedReason: CallRejectedReason, + message?: string, + ) { + super(message || 'call-rejected'); + } +} diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts index 13c92b9ffddfc..196be62271c9b 100644 --- a/ee/packages/media-calls/src/internal/InternalCallProvider.ts +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -1,13 +1,30 @@ -import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import { MediaCalls } from '@rocket.chat/models'; import { BaseCallProvider } from '../base/BaseCallProvider'; -import type { InternalCallParams } from '../definition/common'; +import { CallRejectedError, type InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; export class InternalCallProvider extends BaseCallProvider { + public static async isActorAvailable(actor: MediaCallActor): Promise { + if (actor.type !== 'user') { + return false; + } + + const userHasCalls = await MediaCalls.hasUnfinishedCallsByUid(actor.id); + return !userHasCalls; + } + public static async createCall(params: InternalCallParams): Promise { logger.debug({ msg: 'InternalCallProvider.createCall', params }); + if (!(await this.isActorAvailable(params.caller))) { + throw new CallRejectedError('busy'); + } + if (!(await this.isActorAvailable(params.callee))) { + throw new CallRejectedError('unavailable'); + } + const callerAgent = await MediaCallDirector.cast.getAgentForActorAndRole(params.caller, 'caller'); const calleeAgent = await MediaCallDirector.cast.getAgentForActorAndRole(params.callee, 'callee'); diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 00e61e2af839c..62e6cd568bdaa 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -96,10 +96,84 @@ export class GlobalSignalProcessor { } } - private async processRegisterSignal(_uid: IUser['_id'], _signal: ClientMediaSignalRegister): Promise { - // #TODO: client registration - // 1. Re-send signals for any pending calls involving this actor; - // 2. Hangup active calls involving the oldContractId if it's different from the new one. + private async processRegisterSignal(uid: IUser['_id'], signal: ClientMediaSignalRegister): Promise { + const calls = await MediaCalls.findAllNotOverByUid(uid).toArray(); + if (!calls.length) { + return; + } + + await Promise.all(calls.map((call) => this.reactToUnknownCall(uid, call, signal).catch(() => null))); + } + + private async reactToUnknownCall(uid: IUser['_id'], call: IMediaCall, signal: ClientMediaSignalRegister): Promise { + if (call.state === 'hangup') { + return; + } + + const isCaller = call.caller.type === 'user' && call.caller.id === uid; + const isCallee = call.callee.type === 'user' && call.callee.id === uid; + + if (!isCaller && !isCallee) { + return; + } + + const role = isCaller ? 'caller' : 'callee'; + const actor = call[role]; + + // If this user's side of the call has already been signed + if (actor.contractId) { + // If it's signed to the same session that is now registering + // Or it was signed by a session that the current session is replacing (as in a browser refresh) + if (actor.contractId === signal.contractId || actor.contractId === signal.oldContractId) { + await MediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' }); + return; + } + } else { + await MediaCallDirector.renewCallId(call._id); + } + + if (isCaller) { + this.sendSignal(uid, { + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: 'caller', + contact: { + ...call.callee, + }, + ...(call.callerRequestedId && { requestedCallId: call.callerRequestedId }), + }); + } + + if (isCallee) { + this.sendSignal(uid, { + callId: call._id, + type: 'new', + service: call.service, + kind: call.kind, + role: 'callee', + contact: { + ...call.caller, + }, + }); + } + + if (call.state === 'active') { + this.sendSignal(uid, { + callId: call._id, + type: 'notification', + notification: 'active', + ...(actor.contractId && { signedContractId: actor.contractId }), + }); + } else if (actor.contractId && !isPendingState(call.state)) { + this.sendSignal(uid, { + callId: call._id, + type: 'notification', + notification: 'accepted', + signedContractId: actor.contractId, + }); + } } private async processRequestCallSignal(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise { @@ -109,6 +183,11 @@ export class GlobalSignalProcessor { return; } + const hasCalls = await MediaCalls.hasUnfinishedCallsByUid(uid); + if (hasCalls) { + this.rejectCallRequest(uid, { callId: signal.callId, toContractId: signal.contractId, reason: 'busy' }); + } + const services = signal.supportedServices ?? []; const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); @@ -146,7 +225,7 @@ export class GlobalSignalProcessor { // The requestedCallId must never match a real call id const matchingValidCall = await MediaCalls.findOneById(requestedCallId, { projection: { _id: 1 } }); if (matchingValidCall) { - this.invalidCallId(uid, rejection); + this.rejectCallRequest(uid, rejection); } const call = await MediaCalls.findOneByCallerRequestedId(requestedCallId, caller); @@ -156,20 +235,20 @@ export class GlobalSignalProcessor { // if the call is already over, we treat it as an invalid id since it can't be reused if (call.state === 'hangup') { - this.invalidCallId(uid, rejection); + this.rejectCallRequest(uid, rejection); } if (call.caller.contractId !== signal.contractId) { - this.invalidCallId(uid, { ...rejection, reason: 'existing-call-id' }); + this.rejectCallRequest(uid, { ...rejection, reason: 'existing-call-id' }); } if (signal.supportedServices?.length && !signal.supportedServices.includes(call.service)) { - this.invalidCallId(uid, { ...rejection, reason: 'unsupported' }); + this.rejectCallRequest(uid, { ...rejection, reason: 'unsupported' }); } // if the call is already accepted, we won't send its signals again if (!isPendingState(call.state)) { - this.invalidCallId(uid, { ...rejection, reason: 'already-requested' }); + this.rejectCallRequest(uid, { ...rejection, reason: 'already-requested' }); } this.sendSignal(uid, { @@ -187,9 +266,9 @@ export class GlobalSignalProcessor { return call; } - private invalidCallId(uid: IUser['_id'], rejection: Omit): never { + private rejectCallRequest(uid: IUser['_id'], rejection: Omit): never { this.sendSignal(uid, { type: 'rejected-call-request', ...rejection }); - throw new Error(rejection.reason ?? 'invalid-call-id'); + throw new Error(rejection.reason); } } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index c9822b7b107c2..3e3722250dbe4 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -198,6 +198,12 @@ export class MediaCallDirector { callee, expiresAt: MediaCallDirector.getNewExpirationTime(), + uids: [ + // add actor ids to uids field if their type is 'user', to make it easy to identify any call an user was part of + ...(caller.type === 'user' ? [caller.id] : []), + ...(callee.type === 'user' ? [callee.id] : []), + ], + ended: false, ...(requestedCallId && { callerRequestedId: requestedCallId }), ...(parentCallId && { parentCallId }), @@ -329,7 +335,7 @@ export class MediaCallDirector { return Boolean(result.modifiedCount); } - private static async hangupCallByIdAndNotifyAgents( + public static async hangupCallByIdAndNotifyAgents( callId: string, agents: IMediaCallAgent[], params?: { endedBy?: IMediaCall['endedBy']; reason?: string }, @@ -346,7 +352,7 @@ export class MediaCallDirector { ); } - private static async hangupDetachedCall( + public static async hangupDetachedCall( call: MediaCallHeader, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }, ): Promise { diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 0553433cab904..0ccf335639a9f 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,11 +1,11 @@ -import type { IMediaCall, IUser } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { isClientMediaSignal } from '@rocket.chat/media-signaling'; -import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; +import type { CallRejectedReason, ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; import { MediaCallDirector } from './CallDirector'; import type { IMediaCallServer, IMediaCallServerSettings, MediaCallServerEvents } from '../definition/IMediaCallServer'; -import type { GetActorContactOptions, InternalCallParams } from '../definition/common'; +import { CallRejectedError, type GetActorContactOptions, type InternalCallParams } from '../definition/common'; import { InternalCallProvider } from '../internal/InternalCallProvider'; import { GlobalSignalProcessor } from '../internal/SignalProcessor'; import { logger } from '../logger'; @@ -60,7 +60,12 @@ export class MediaCallServer implements IMediaCallServer { try { await this.createCall(fullParams); } catch (error) { - logger.error({ msg: 'Failed to create a requested call', params, error }); + let rejectionReason: CallRejectedReason = 'unsupported'; + if (error && typeof error === 'object' && error instanceof CallRejectedError) { + rejectionReason = error.callRejectedReason; + } else { + logger.error({ msg: 'Failed to create a requested call', params, error }); + } const originalId = params.requestedCallId || params.parentCallId; @@ -69,21 +74,22 @@ export class MediaCallServer implements IMediaCallServer { type: 'rejected-call-request', callId: originalId, toContractId: params.requestedBy.contractId, - reason: 'unsupported', + reason: rejectionReason, }); + } else { + throw error; } - throw error; } } - public async createCall(params: InternalCallParams): Promise { + public async createCall(params: InternalCallParams): Promise { logger.debug({ msg: 'MediaCallServer.createCall', params }); if (params.callee.type === 'sip') { throw new Error('Outgoing SIP calls are not yet implemented.'); } - return InternalCallProvider.createCall(params); + await InternalCallProvider.createCall(params); } public async hangupExpiredCalls(): Promise { diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 7f64b0a1f96f3..9d315a668b3a7 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -1,4 +1,5 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { IUser } from '../IUser'; import type { RequiredField } from '../utils'; export type MediaCallActorType = 'user' | 'sip'; @@ -42,6 +43,7 @@ export interface IMediaCall extends IRocketChatRecord { caller: MediaCallSignedContact; callee: MediaCallContact; + ended: boolean; endedBy?: MediaCallActor | ServerActor; endedAt?: Date; hangupReason?: string; @@ -55,4 +57,6 @@ export interface IMediaCall extends IRocketChatRecord { transferredBy?: MediaCallSignedActor; transferredTo?: MediaCallContact; transferredAt?: Date; + + uids: IUser['_id'][]; } diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 3d70641196aee..75cfcbb602b06 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -36,7 +36,8 @@ export type CallHangupReason = | 'signaling-error' // Hanging up because of an error during the signal processing | 'service-error' // Hanging up because of an error setting up the service connection | 'media-error' // Hanging up because of an error setting up the media connection - | 'error'; // Hanging up because of an unidentified error + | 'error' // Hanging up because of an unidentified error + | 'unknown'; // One of the call's signed users reported they don't know this call export type CallAnswer = | 'accept' // actor accepts the call @@ -54,7 +55,9 @@ export type CallRejectedReason = | 'invalid-contract-id' // this specific contract can't request this call | 'existing-call-id' // the call already exists with a different callee or contract | 'already-requested' // the request is valid, but a call matching its params is already underway - | 'unsupported'; // no matching supported services between actors + | 'unsupported' // no matching supported services between actors + | 'unavailable' // the callee is unavailable + | 'busy'; // the actor who requested the call is supposedly busy export interface IClientMediaCall { callId: string; diff --git a/packages/media-signaling/src/definition/signals/client/register.ts b/packages/media-signaling/src/definition/signals/client/register.ts index 773955302d113..003c151815786 100644 --- a/packages/media-signaling/src/definition/signals/client/register.ts +++ b/packages/media-signaling/src/definition/signals/client/register.ts @@ -1,6 +1,6 @@ import type { JSONSchemaType } from 'ajv'; -/** Client is reporting a brand new session */ +/** Client is reporting a clean session, possibly brand new */ export type ClientMediaSignalRegister = { type: 'register'; contractId: string; diff --git a/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts b/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts index d0c66a5fccb52..5bbd338da3723 100644 --- a/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts +++ b/packages/media-signaling/src/definition/signals/server/rejected-call-request.ts @@ -4,5 +4,5 @@ export type ServerMediaSignalRejectedCallRequest = { callId: string; type: 'rejected-call-request'; toContractId: string; - reason?: CallRejectedReason; + reason: CallRejectedReason; }; diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 50ab9a15b97ce..d51bd83653d5c 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -13,8 +13,8 @@ import type { CallActorType, } from '../definition/call'; import type { ClientContractState, ClientState } from '../definition/client'; -import type { IWebRTCProcessor, WebRTCInternalStateMap } from '../definition/services'; import type { IMediaSignalLogger } from '../definition/logger'; +import type { IWebRTCProcessor, WebRTCInternalStateMap } from '../definition/services'; import { isPendingState } from './services/states'; import type { ServerMediaSignal, @@ -36,6 +36,7 @@ const TIMEOUT_TO_ACCEPT = 30000; const TIMEOUT_TO_CONFIRM_ACCEPTANCE = 2000; const TIMEOUT_TO_PROGRESS_SIGNALING = 10000; const STATE_REPORT_DELAY = 300; +const CALLS_WITH_NO_REMOTE_DATA_REPORT_DELAY = 5000; // if the server tells us we're the caller in a call we don't recognize, ignore it completely const AUTO_IGNORE_UNKNOWN_OUTBOUND_CALLS = true; @@ -151,6 +152,8 @@ export class ClientMediaCall implements IClientMediaCall { private currentNegotiationId: string | null; + private creationTimestamp: Date; + constructor( private readonly config: IClientMediaCallConfig, callId: string, @@ -175,6 +178,7 @@ export class ClientMediaCall implements IClientMediaCall { this.stateReporterTimeoutHandler = null; this.mayReportStates = true; this.inputTrack = inputTrack || null; + this.creationTimestamp = new Date(); this.currentNegotiationId = null; this.earlySignals = new Set(); @@ -365,6 +369,10 @@ export class ClientMediaCall implements IClientMediaCall { public getRemoteMediaStream(): MediaStream { this.config.logger?.debug('ClientMediaCall.getRemoteMediaStream'); + if (this.hidden) { + this.throwError('getRemoteMediaStream is not available for this call'); + } + if (this.shouldIgnoreWebRTC()) { this.throwError('getRemoteMediaStream is not available for this service'); } @@ -386,6 +394,10 @@ export class ClientMediaCall implements IClientMediaCall { return this.initializeRemoteCall(signal, oldCall); } + if (signalType === 'rejected-call-request') { + return this.flagAsEnded('remote'); + } + if (!this.hasRemoteData) { this.config.logger?.debug('Remote data missing, adding signal to queue'); this.earlySignals.add(signal); @@ -485,6 +497,10 @@ export class ClientMediaCall implements IClientMediaCall { return this._state === 'hangup'; } + public isAbleToReportStates(): boolean { + return this.mayReportStates; + } + public ignore(): void { if (this.ignored) { return; @@ -505,7 +521,7 @@ export class ClientMediaCall implements IClientMediaCall { } public setMuted(muted: boolean): void { - if (this.isOver()) { + if (this.isOver() || this.hidden) { return; } if (!this.webrtcProcessor && !muted) { @@ -521,7 +537,7 @@ export class ClientMediaCall implements IClientMediaCall { } public setHeld(held: boolean): void { - if (this.isOver()) { + if (this.isOver() || this.hidden) { return; } if (!this.webrtcProcessor && !held) { @@ -577,14 +593,16 @@ export class ClientMediaCall implements IClientMediaCall { return; } - this.config.transporter.sendToServer(this.callId, 'local-state', { - callState: this.state, - clientState: this.getClientState(), - serviceStates: Object.fromEntries(this.serviceStates.entries()), - ignored: this.ignored, - contractState: this.contractState, - ...(this.currentNegotiationId && { negotiationId: this.currentNegotiationId }), - }); + if (this.hasRemoteData || Date.now() > this.creationTimestamp.valueOf() + CALLS_WITH_NO_REMOTE_DATA_REPORT_DELAY) { + this.config.transporter.sendToServer(this.callId, 'local-state', { + callState: this.state, + clientState: this.getClientState(), + serviceStates: Object.fromEntries(this.serviceStates.entries()), + ignored: this.ignored, + contractState: this.contractState, + ...(this.currentNegotiationId && { negotiationId: this.currentNegotiationId }), + }); + } if (this.state === 'hangup') { this.mayReportStates = false; @@ -661,6 +679,10 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processOfferRequest(signal: ServerMediaSignalRequestOffer) { + if (this.hidden) { + return; + } + this.config.logger?.debug('ClientMediaCall.processOfferRequest', signal); if (!signal.toContractId && !this.signed) { @@ -714,7 +736,7 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processAnswerRequest(signal: ServerMediaSignalRemoteSDP): Promise { - if (this.shouldIgnoreWebRTC()) { + if (this.hidden || this.shouldIgnoreWebRTC()) { return; } @@ -847,7 +869,7 @@ export class ClientMediaCall implements IClientMediaCall { case 'accepted': return this.flagAsAccepted(); case 'active': - if (this.state === 'accepted') { + if (this.state === 'accepted' || this.hidden) { this.changeState('active'); } return; @@ -889,7 +911,7 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (!this.hidden) { + if (!this.hidden && this.hasRemoteData) { this.config.transporter.hangup(this.callId, reason); } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index ad99ca6ce838b..d85728c6132e2 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -63,6 +63,8 @@ export class MediaSignalingSession extends Emitter { private callsToGetUserMedia: number; + private lastRegisterTimestamp: Date | null = null; + public get sessionId(): string { return this._sessionId; } @@ -139,7 +141,8 @@ export class MediaSignalingSession extends Emitter { public async processSignal(signal: ServerMediaSignal): Promise { this.config.logger?.debug('MediaSignalingSession.processSignal', signal); - if (this.isSignalTargetingAnotherSession(signal) || this.isCallIgnored(signal.callId)) { + + if (this.isCallIgnored(signal.callId)) { return; } @@ -152,6 +155,8 @@ export class MediaSignalingSession extends Emitter { // The server accepted a contract, but it wasn't ours - ignore the call in this session call.setContractState('ignored'); } + } else if ('toContractId' in signal) { + call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); } const oldCall = this.getReplacedCallBySignal(signal); @@ -182,6 +187,16 @@ export class MediaSignalingSession extends Emitter { await call.requestCall({ type: calleeType, id: calleeId }, contactInfo); } + public register(): void { + this.lastRegisterTimestamp = new Date(); + + this.transporter.sendSignal({ + type: 'register', + contractId: this._sessionId, + ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), + }); + } + public setIceGatheringTimeout(newTimeout: number): void { this.config.iceGatheringTimeout = newTimeout; } @@ -190,18 +205,6 @@ export class MediaSignalingSession extends Emitter { return `${this._sessionId}-${this.config.randomStringFactory()}`; } - private isSignalTargetingAnotherSession(signal: ServerMediaSignal): boolean { - if (signal.type === 'new' || signal.type === 'notification') { - return false; - } - - if (signal.toContractId && signal.toContractId !== this._sessionId) { - return true; - } - - return false; - } - private isCallIgnored(callId: string): boolean { return this.ignoredCalls.has(callId); } @@ -254,22 +257,46 @@ export class MediaSignalingSession extends Emitter { } private reportState(): void { - const call = this.getMainCall() as ClientMediaCall | null; - if (call && !call.isOver()) { + let reportedAny = false; + let anyNotOver = false; + + for (const call of this.knownCalls.values()) { + if (call.state !== 'hangup') { + anyNotOver = true; + } + + if (!call.isAbleToReportStates()) { + continue; + } + + reportedAny = true; call.reportStates(); + } + + if (reportedAny) { + // If we're reporting a call's state, then ensure we'll register again once all calls over + this.lastRegisterTimestamp = null; return; } - // If we don't have any call to report the state on, send a register signal instead; the server will ignore it if there's nothing on that side either - this.register(); + // Even if we're not reporting any calls, if we know about one that isn't over, don't register + if (anyNotOver) { + return; + } + + // By registering we're telling the server we have a clean session; if it's not supposed to be clean, it'll tell us + this.autoRegister(); } - private register(): void { - this.transporter.sendSignal({ - type: 'register', - contractId: this._sessionId, - ...(this.config.oldSessionId && { oldContractId: this.config.oldSessionId }), - }); + private autoRegister(): void { + if (this.lastRegisterTimestamp) { + const diff = Date.now() - this.lastRegisterTimestamp.valueOf(); + if (diff < STATE_REPORT_INTERVAL * 10) { + return; + } + } + + this.register(); } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 24a156d2d6a41..9ed400fff59df 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallActorType, MediaCallContact, MediaCallSignedActor } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedActor } from '@rocket.chat/core-typings'; import type { Document, FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -16,5 +16,7 @@ export interface IMediaCallsModel extends IBaseModel { hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; transferCallById(callId: string, params: { by: MediaCallSignedActor; to: MediaCallContact }): Promise; findAllExpiredCalls(options: FindOptions | undefined): FindCursor; + findAllNotOverByUid(uid: IUser['_id'], options?: FindOptions): FindCursor; hasUnfinishedCalls(): Promise; + hasUnfinishedCallsByUid(uid: IUser['_id']): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index a4cb12aa00f62..fa8dfe2d2cd0b 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -4,6 +4,7 @@ import type { MediaCallActorType, MediaCallSignedActor, MediaCallContact, + IUser, } from '@rocket.chat/core-typings'; import type { IMediaCallsModel } from '@rocket.chat/model-typings'; import type { @@ -28,7 +29,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod protected modelIndexes(): IndexDescription[] { return [ { key: { createdAt: 1 }, unique: false }, - { key: { state: 1, expiresAt: 1 }, unique: false }, + { key: { ended: 1, uids: 1, expiresAt: 1 }, unique: false }, { key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 }, sparse: true, @@ -108,11 +109,12 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne( { _id: callId, - state: { $ne: 'hangup' }, + ended: false, }, { $set: { state: 'hangup', + ended: true, endedAt: new Date(), ...(endedBy && { endedBy }), ...(reason && { hangupReason: reason }), @@ -125,7 +127,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return this.updateOne( { _id: callId, - state: { $ne: 'hangup' }, + ended: false, }, { $set: { expiresAt }, @@ -157,19 +159,41 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod public findAllExpiredCalls(options?: FindOptions): FindCursor { return this.find( { - state: { - $ne: 'hangup', + ended: false, + expiresAt: { + $lte: new Date(), }, + }, + options, + ); + } + + public findAllNotOverByUid(uid: IUser['_id'], options?: FindOptions): FindCursor { + return this.find( + { + ended: false, expiresAt: { - $lt: new Date(), + $gt: new Date(), }, + uids: uid, }, options, ); } public async hasUnfinishedCalls(): Promise { - const count = await this.countDocuments({ state: { $ne: 'hangup' } }, { limit: 1 }); + const count = await this.countDocuments({ ended: false }, { limit: 1 }); + return count > 0; + } + + public async hasUnfinishedCallsByUid(uid: IUser['_id']): Promise { + const count = await this.countDocuments( + { + ended: false, + uids: uid, + }, + { limit: 1 }, + ); return count > 0; } } From e5b269d495e2ba7bbbb617101348251f8c06c14f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 11:19:54 -0300 Subject: [PATCH 55/72] changes from code review --- ee/apps/ddp-streamer/package.json | 1 - .../media-calls/src/definition/common.ts | 12 +++++-- .../src/internal/InternalCallProvider.ts | 19 ++++------- .../src/internal/SignalProcessor.ts | 4 +-- .../internal/agents/CallSignalProcessor.ts | 4 +-- .../src/internal/agents/CalleeAgent.ts | 29 ---------------- .../src/internal/agents/CallerAgent.ts | 33 ------------------- .../{BaseUserAgent.ts => UserActorAgent.ts} | 28 ++++++++++++++-- .../media-calls/src/server/CallDirector.ts | 5 ++- .../media-calls/src/server/CastDirector.ts | 14 ++------ packages/core-services/package.json | 2 +- .../core-typings/src/mediaCalls/IMediaCall.ts | 15 +++++---- packages/ddp-client/package.json | 2 +- packages/media-signaling/src/lib/Call.ts | 16 +++++++-- packages/media-signaling/src/lib/Session.ts | 1 + .../src/lib/services/webrtc/Processor.ts | 12 ++++--- .../src/lib/utils/getExternalWaiter.ts | 3 +- yarn.lock | 1 - 18 files changed, 85 insertions(+), 116 deletions(-) delete mode 100644 ee/packages/media-calls/src/internal/agents/CalleeAgent.ts delete mode 100644 ee/packages/media-calls/src/internal/agents/CallerAgent.ts rename ee/packages/media-calls/src/internal/agents/{BaseUserAgent.ts => UserActorAgent.ts} (83%) diff --git a/ee/apps/ddp-streamer/package.json b/ee/apps/ddp-streamer/package.json index 78ced7fb143e3..902ab9fb776f2 100644 --- a/ee/apps/ddp-streamer/package.json +++ b/ee/apps/ddp-streamer/package.json @@ -47,7 +47,6 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/ddp-client": "workspace:~", "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/media-signaling": "workspace:^", "@types/ejson": "^2.2.2", "@types/node": "~22.16.1", "@types/polka": "^0.5.7", diff --git a/ee/packages/media-calls/src/definition/common.ts b/ee/packages/media-calls/src/definition/common.ts index e02473dcf1b8f..6a9c9936f098d 100644 --- a/ee/packages/media-calls/src/definition/common.ts +++ b/ee/packages/media-calls/src/definition/common.ts @@ -1,4 +1,12 @@ -import type { AtLeast, IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; +import type { + AtLeast, + IMediaCall, + IUser, + MediaCallActorType, + MediaCallContact, + MediaCallSignedActor, + MediaCallSignedContact, +} from '@rocket.chat/core-typings'; import type { CallRejectedReason, CallService } from '@rocket.chat/media-signaling'; export type MinimalUserData = Pick; @@ -14,7 +22,7 @@ export type InternalCallParams = { requestedCallId?: string; requestedService?: CallService; parentCallId?: string; - requestedBy?: MediaCallSignedContact; + requestedBy?: MediaCallSignedActor; }; export type MediaCallHeader = AtLeast; diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts index 196be62271c9b..af3e489765c7a 100644 --- a/ee/packages/media-calls/src/internal/InternalCallProvider.ts +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -1,4 +1,4 @@ -import type { IMediaCall, MediaCallActor } from '@rocket.chat/core-typings'; +import type { IMediaCall } from '@rocket.chat/core-typings'; import { MediaCalls } from '@rocket.chat/models'; import { BaseCallProvider } from '../base/BaseCallProvider'; @@ -7,21 +7,16 @@ import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; export class InternalCallProvider extends BaseCallProvider { - public static async isActorAvailable(actor: MediaCallActor): Promise { - if (actor.type !== 'user') { - return false; - } - - const userHasCalls = await MediaCalls.hasUnfinishedCallsByUid(actor.id); - return !userHasCalls; - } - public static async createCall(params: InternalCallParams): Promise { logger.debug({ msg: 'InternalCallProvider.createCall', params }); - if (!(await this.isActorAvailable(params.caller))) { + if (params.caller.type !== 'user' || params.callee.type !== 'user') { + throw new CallRejectedError('unsupported'); + } + + if (await MediaCalls.hasUnfinishedCallsByUid(params.caller.id)) { throw new CallRejectedError('busy'); } - if (!(await this.isActorAvailable(params.callee))) { + if (await MediaCalls.hasUnfinishedCallsByUid(params.callee.id)) { throw new CallRejectedError('unavailable'); } diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 62e6cd568bdaa..c0ac12fb1faa2 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -13,7 +13,7 @@ import { MediaCalls } from '@rocket.chat/models'; import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; -import { UserActorAgent } from './agents/BaseUserAgent'; +import { UserActorAgent } from './agents/UserActorAgent'; export type SignalProcessorEvents = { signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; @@ -189,7 +189,7 @@ export class GlobalSignalProcessor { } const services = signal.supportedServices ?? []; - const requestedService = services.includes('webrtc') ? 'webrtc' : services.shift(); + const requestedService = services.includes('webrtc') ? 'webrtc' : services[0]; const params: InternalCallParams = { caller: { diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 95030cc65d166..2146699b414cd 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -75,10 +75,10 @@ export class UserActorSignalProcessor { public async processSignal(signal: ClientMediaSignal): Promise { logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal }); - // The code will only reach this point if one of the three following conditions are true: + // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call // 2. the signal came from the exact user session where the callee accepted the call - // 2. the call has not been accepted yet and the signal came from a valid sesison from the callee + // 3. the call has not been accepted yet and the signal came from a valid sesison from the callee switch (signal.type) { case 'local-sdp': return this.saveLocalDescription(signal.sdp, signal.negotiationId); diff --git a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts b/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts deleted file mode 100644 index f35ea3a98a6fa..0000000000000 --- a/ee/packages/media-calls/src/internal/agents/CalleeAgent.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { MediaCallContact } from '@rocket.chat/core-typings'; -import { MediaCallNegotiations } from '@rocket.chat/models'; - -import { UserActorAgent } from './BaseUserAgent'; -import { logger } from '../../logger'; - -export class UserActorCalleeAgent extends UserActorAgent { - constructor(contact: MediaCallContact) { - super(contact, 'callee'); - } - - public async onCallAccepted(callId: string, signedContractId: string): Promise { - await super.onCallAccepted(callId, signedContractId); - - const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); - if (!negotiation?.offer) { - logger.debug('The call was accepted but the webrtc offer is not yet available.'); - return; - } - - await this.sendSignal({ - callId, - toContractId: signedContractId, - type: 'remote-sdp', - sdp: negotiation.offer, - negotiationId: negotiation._id, - }); - } -} diff --git a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts b/ee/packages/media-calls/src/internal/agents/CallerAgent.ts deleted file mode 100644 index ba6d125af0e48..0000000000000 --- a/ee/packages/media-calls/src/internal/agents/CallerAgent.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { IMediaCall, MediaCallContact } from '@rocket.chat/core-typings'; -import type { ServerMediaSignalNewCall } from '@rocket.chat/media-signaling'; - -import { UserActorAgent } from './BaseUserAgent'; -import { logger } from '../../logger'; - -export class UserActorCallerAgent extends UserActorAgent { - constructor(contact: MediaCallContact) { - super(contact, 'caller'); - } - - public async onCallCreated(call: IMediaCall): Promise { - logger.debug({ msg: 'UserActorCallerAgent.onCallCreated', call }); - const { caller } = call; - - if (!caller.contractId) { - throw new Error('error-invalid-contract'); - } - - // Pre-create the channel for the contractId that requested the call - await this.getOrCreateChannel(call, caller.contractId); - - return super.onCallCreated(call); - } - - protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall { - return { - ...super.buildNewCallSignal(call), - // Send back the caller requested Id so the client can match this call to its request - ...(call.callerRequestedId && { requestedCallId: call.callerRequestedId }), - }; - } -} diff --git a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts similarity index 83% rename from ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts rename to ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index cb19f308243e8..43d254928d926 100644 --- a/ee/packages/media-calls/src/internal/agents/BaseUserAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -7,7 +7,7 @@ import { BaseMediaCallAgent } from '../../base/BaseAgent'; import { logger } from '../../logger'; import { getMediaCallServer } from '../../server/injection'; -export abstract class UserActorAgent extends BaseMediaCallAgent { +export class UserActorAgent extends BaseMediaCallAgent { public async processSignal(call: IMediaCall, signal: ClientMediaSignal): Promise { const channel = await this.getOrCreateChannel(call, signal.contractId); @@ -22,12 +22,30 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { public async onCallAccepted(callId: string, signedContractId: string): Promise { logger.debug({ msg: 'UserActorAgent.onCallAccepted', callId }); - return this.sendSignal({ + await this.sendSignal({ callId, type: 'notification', notification: 'accepted', signedContractId, }); + + if (this.role !== 'callee') { + return; + } + + const negotiation = await MediaCallNegotiations.findLatestByCallId(callId); + if (!negotiation?.offer) { + logger.debug('The call was accepted but the webrtc offer is not yet available.'); + return; + } + + await this.sendSignal({ + callId, + toContractId: signedContractId, + type: 'remote-sdp', + sdp: negotiation.offer, + negotiationId: negotiation._id, + }); } public async onCallEnded(callId: string): Promise { @@ -53,6 +71,11 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { public async onCallCreated(call: IMediaCall): Promise { logger.debug({ msg: 'UserActorAgent.onCallCreated', call }); + if (this.role === 'caller' && call.caller.contractId) { + // Pre-create the channel for the contractId that requested the call + await this.getOrCreateChannel(call, call.caller.contractId); + } + await this.sendSignal(this.buildNewCallSignal(call)); } @@ -147,6 +170,7 @@ export abstract class UserActorAgent extends BaseMediaCallAgent { role: this.role, contact: this.getOtherCallActor(call), ...(call.parentCallId && { replacingCallId: call.parentCallId }), + ...(call.callerRequestedId && this.role === 'caller' && { requestedCallId: call.callerRequestedId }), }; } } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 3e3722250dbe4..e97351644fcd2 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -154,8 +154,11 @@ export class MediaCallDirector { } public static async createCall(params: CreateCallParams): Promise { - logger.debug({ msg: 'MediaCallDirector.createCall', params }); const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, webrtcOffer, parentCallId, requestedBy } = params; + logger.debug({ + msg: 'MediaCallDirector.createCall', + params: { caller, callee, requestedCallId, requestedService, hasOffer: Boolean(webrtcOffer), parentCallId, requestedBy }, + }); // The caller must always have a contract to create the call if (!caller.contractId) { diff --git a/ee/packages/media-calls/src/server/CastDirector.ts b/ee/packages/media-calls/src/server/CastDirector.ts index baa5b81a023d6..e930fb72a7928 100644 --- a/ee/packages/media-calls/src/server/CastDirector.ts +++ b/ee/packages/media-calls/src/server/CastDirector.ts @@ -5,9 +5,7 @@ import { Users } from '@rocket.chat/models'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; import type { GetActorContactOptions, MinimalUserData, MediaCallHeader } from '../definition/common'; -import type { UserActorAgent } from '../internal/agents/BaseUserAgent'; -import { UserActorCalleeAgent } from '../internal/agents/CalleeAgent'; -import { UserActorCallerAgent } from '../internal/agents/CallerAgent'; +import { UserActorAgent } from '../internal/agents/UserActorAgent'; import { logger } from '../logger'; type ContactList = Record; @@ -120,15 +118,7 @@ export class MediaCallCastDirector implements IMediaCallCastDirector { } protected async getAgentForUserActorAndRole(actor: MediaCallContact, role: CallRole): Promise { - if (role === 'caller') { - return new UserActorCallerAgent(actor); - } - - if (role === 'callee') { - return new UserActorCalleeAgent(actor); - } - - return null; + return new UserActorAgent(actor, role); } protected async getAgentForSipActorAndRole(_actor: MediaCallContact, _role: CallRole): Promise { diff --git a/packages/core-services/package.json b/packages/core-services/package.json index a4fa247245052..273e939de8541 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -9,7 +9,6 @@ "@rocket.chat/apps-engine": "workspace:^", "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", - "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "babel-jest": "~30.0.5", @@ -37,6 +36,7 @@ "dependencies": { "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/icons": "^0.43.0", + "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/message-parser": "workspace:^", "@rocket.chat/models": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", diff --git a/packages/core-typings/src/mediaCalls/IMediaCall.ts b/packages/core-typings/src/mediaCalls/IMediaCall.ts index 9d315a668b3a7..6c2df47627f48 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCall.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCall.ts @@ -10,7 +10,9 @@ export type MediaCallActor = contractId?: string; }; -export type MediaCallSignedActor = RequiredField; +export type MediaCallSignedEntity = RequiredField; + +export type MediaCallSignedActor = MediaCallSignedEntity>; export type ServerActor = { type: 'server'; @@ -23,19 +25,18 @@ export type MediaCallContactInformation = { sipExtension?: string; }; -export type MediaCallContact = T & MediaCallContactInformation; +export type MediaCallContact = MediaCallActor & MediaCallContactInformation; -export type TypedMediaCallContact = MediaCallContact>; +export type MediaCallSignedContact = MediaCallSignedEntity>; -export type MediaCallSignedContact = MediaCallContact< - MediaCallSignedActor> ->; +/* The list of call states that may actually be stored on the collection is smaller than the list of call states that may be computed by the client class */ +export type MediaCallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; export interface IMediaCall extends IRocketChatRecord { service: 'webrtc'; kind: 'direct'; - state: 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; + state: MediaCallState; createdBy: MediaCallActor; createdAt: Date; diff --git a/packages/ddp-client/package.json b/packages/ddp-client/package.json index c7cd2d50019fc..9f3368ff62674 100644 --- a/packages/ddp-client/package.json +++ b/packages/ddp-client/package.json @@ -3,7 +3,6 @@ "version": "0.3.37", "devDependencies": { "@rocket.chat/jest-presets": "workspace:~", - "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "@types/ws": "^8.5.13", @@ -35,6 +34,7 @@ "dependencies": { "@rocket.chat/api-client": "workspace:^", "@rocket.chat/core-typings": "workspace:~", + "@rocket.chat/media-signaling": "workspace:^", "@rocket.chat/rest-typings": "workspace:^" } } diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index d51bd83653d5c..2310822dcc1b3 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -28,6 +28,7 @@ export interface IClientMediaCallConfig { logger?: IMediaSignalLogger; transporter: MediaSignalTransportWrapper; processorFactories: IServiceProcessorFactoryList; + sessionId: string; iceGatheringTimeout: number; } @@ -685,7 +686,7 @@ export class ClientMediaCall implements IClientMediaCall { this.config.logger?.debug('ClientMediaCall.processOfferRequest', signal); - if (!signal.toContractId && !this.signed) { + if (!this.isSignalTargetingThisSession(signal)) { this.config.logger?.error('Received an unsigned offer request.'); return; } @@ -786,14 +787,15 @@ export class ClientMediaCall implements IClientMediaCall { return; } - if (!signal.toContractId && !this.signed) { - this.config.logger?.error('Received an unsigned SDP signal'); + if (!this.isSignalTargetingThisSession(signal)) { + this.config.logger?.error('Received an offer request that is unsigned, or signed to a different session.'); return; } if (this.shouldIgnoreWebRTC()) { return; } + this.requireWebRTC(); if (signal.sdp.type === 'offer') { @@ -1057,6 +1059,14 @@ export class ClientMediaCall implements IClientMediaCall { throw new Error(error); } + private isSignalTargetingThisSession(signal: ServerMediaSignalRemoteSDP | ServerMediaSignalRequestOffer): boolean { + if (signal.toContractId) { + return signal.toContractId === this.config.sessionId; + } + + return this.signed; + } + private prepareWebRtcProcessor(): asserts this is ClientMediaCallWebRTC { this.config.logger?.debug('ClientMediaCall.prepareWebRtcProcessor'); if (this.webrtcProcessor) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index d85728c6132e2..fdf62edc54e98 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -423,6 +423,7 @@ export class MediaSignalingSession extends Emitter { transporter: this.transporter, processorFactories: this.config.processorFactories, iceGatheringTimeout: this.config.iceGatheringTimeout || 1000, + sessionId: this.sessionId, }; const call = new ClientMediaCall(config, callId, { inputTrack: this.inputTrack }); diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index fc31bac5b3787..5f6169b810d61 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -207,12 +207,14 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { const iceGatheringData = getExternalWaiter({ timeout: this.config.iceGatheringTimeout, timeoutFn: () => { - if (this.iceGatheringWaiters.has(iceGatheringData)) { - this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering - timeout'); - this.clearIceGatheringData(iceGatheringData); - this.iceGatheringTimedOut = true; - this.changeInternalState('iceUntrickler'); + if (!this.iceGatheringWaiters.has(iceGatheringData)) { + return; } + + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering - timeout'); + this.clearIceGatheringData(iceGatheringData); + this.iceGatheringTimedOut = true; + this.changeInternalState('iceUntrickler'); }, }); diff --git a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts index 8c5ba59d0e127..e2768329996a4 100644 --- a/packages/media-signaling/src/lib/utils/getExternalWaiter.ts +++ b/packages/media-signaling/src/lib/utils/getExternalWaiter.ts @@ -58,10 +58,9 @@ export function getExternalWaiter({ timeout, timeoutFn, cleanupFn }: PromiseWait if (timeoutFn) { timeoutFn(); - flagAsDone(); } - if (data.promiseReject) { + if (data.promiseReject && !data.done) { data.promiseReject(new Error('timeout')); } }, timeout); diff --git a/yarn.lock b/yarn.lock index daebfb62c218b..2d2edf4b6dcc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7216,7 +7216,6 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/instance-status": "workspace:^" "@rocket.chat/logger": "workspace:^" - "@rocket.chat/media-signaling": "workspace:^" "@rocket.chat/model-typings": "workspace:^" "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" From 05c4be8143d099b9218daa96d49ec1e37a2102ec Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 12:12:24 -0300 Subject: [PATCH 56/72] strip sensitive data from logs --- .../src/internal/SignalProcessor.ts | 5 ++-- .../internal/agents/CallSignalProcessor.ts | 5 ++-- .../src/server/stripSensitiveData.ts | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 ee/packages/media-calls/src/server/stripSensitiveData.ts diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index c0ac12fb1faa2..aa7d020561d89 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -14,6 +14,7 @@ import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/UserActorAgent'; +import { stipSensitiveDataFromSignal } from '../server/stripSensitiveData'; export type SignalProcessorEvents = { signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; @@ -28,7 +29,7 @@ export class GlobalSignalProcessor { } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { - logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal, uid }); + logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal: stipSensitiveDataFromSignal(signal), uid }); switch (signal.type) { case 'register': @@ -59,7 +60,7 @@ export class GlobalSignalProcessor { try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { - logger.error({ msg: 'call not found', method: 'GlobalSignalProcessor.processCallSignal', signal }); + logger.error({ msg: 'call not found', method: 'GlobalSignalProcessor.processCallSignal', signal: stipSensitiveDataFromSignal(signal) }); throw new Error('invalid-call'); } diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 2146699b414cd..76f962259e5bd 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -16,6 +16,7 @@ import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; import { MediaCallDirector } from '../../server/CallDirector'; import { getMediaCallServer } from '../../server/injection'; +import { stipSensitiveDataFromSignal, stripSensitiveDataFromSdp } from '../../server/stripSensitiveData'; export class UserActorSignalProcessor { public get contractId(): string { @@ -73,7 +74,7 @@ export class UserActorSignalProcessor { } public async processSignal(signal: ClientMediaSignal): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal }); + logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal: stipSensitiveDataFromSignal(signal) }); // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call @@ -102,7 +103,7 @@ export class UserActorSignalProcessor { } protected async saveLocalDescription(sdp: RTCSessionDescriptionInit, negotiationId: string): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdpType: sdp?.type, hasSdpBody: Boolean(sdp?.sdp) }); + logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp: stripSensitiveDataFromSdp(sdp) }); await MediaCallDirector.saveWebrtcSession(this.call, this.agent, { sdp, negotiationId }, this.contractId); } diff --git a/ee/packages/media-calls/src/server/stripSensitiveData.ts b/ee/packages/media-calls/src/server/stripSensitiveData.ts new file mode 100644 index 0000000000000..3d01c207b9a2d --- /dev/null +++ b/ee/packages/media-calls/src/server/stripSensitiveData.ts @@ -0,0 +1,24 @@ +import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling'; + +export function stripSensitiveDataFromSdp(sdp: T): T { + if (!sdp?.sdp) { + return sdp; + } + + const { ...data } = sdp; + + return { + ...data, + sdp: `length=${sdp.sdp.length}`, + }; +} + +export function stipSensitiveDataFromSignal(signal: T): Partial { + const { ...data } = signal; + + if ('sdp' in data) { + data.sdp = stripSensitiveDataFromSdp(data.sdp); + } + + return data; +} From 6a3831e81736a9d3a71f41ac2b4b5e897974d2f4 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 12:28:30 -0300 Subject: [PATCH 57/72] code review --- .../src/internal/SignalProcessor.ts | 20 ++++++++++++++----- .../internal/agents/CallSignalProcessor.ts | 4 ++-- .../src/server/stripSensitiveData.ts | 2 +- .../definition/services/IServiceProcessor.ts | 3 +-- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index aa7d020561d89..10332eebee3e8 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -14,7 +14,7 @@ import type { InternalCallParams } from '../definition/common'; import { logger } from '../logger'; import { MediaCallDirector } from '../server/CallDirector'; import { UserActorAgent } from './agents/UserActorAgent'; -import { stipSensitiveDataFromSignal } from '../server/stripSensitiveData'; +import { stripSensitiveDataFromSignal } from '../server/stripSensitiveData'; export type SignalProcessorEvents = { signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; @@ -29,7 +29,7 @@ export class GlobalSignalProcessor { } public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise { - logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal: stipSensitiveDataFromSignal(signal), uid }); + logger.debug({ msg: 'GlobalSignalProcessor.processSignal', signal: stripSensitiveDataFromSignal(signal), uid }); switch (signal.type) { case 'register': @@ -42,7 +42,7 @@ export class GlobalSignalProcessor { return this.processCallSignal(uid, signal); } - logger.error({ msg: 'Unrecognized media signal', signal }); + logger.error({ msg: 'Unrecognized media signal', signal: stripSensitiveDataFromSignal(signal) }); } protected sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void { @@ -60,7 +60,11 @@ export class GlobalSignalProcessor { try { const call = await MediaCalls.findOneById(signal.callId); if (!call) { - logger.error({ msg: 'call not found', method: 'GlobalSignalProcessor.processCallSignal', signal: stipSensitiveDataFromSignal(signal) }); + logger.error({ + msg: 'call not found', + method: 'GlobalSignalProcessor.processCallSignal', + signal: stripSensitiveDataFromSignal(signal), + }); throw new Error('invalid-call'); } @@ -69,7 +73,13 @@ export class GlobalSignalProcessor { // The user must be either the caller or the callee, if its none or both, we can't process it if (isCaller === isCallee) { - logger.error({ msg: 'failed to identify actor role in the call', method: 'processSignal', signal, isCaller, isCallee }); + logger.error({ + msg: 'failed to identify actor role in the call', + method: 'processSignal', + signal: stripSensitiveDataFromSignal(signal), + isCaller, + isCallee, + }); throw new Error('invalid-call'); } diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 76f962259e5bd..d1a0b2ae16e54 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -16,7 +16,7 @@ import type { IMediaCallAgent } from '../../definition/IMediaCallAgent'; import { logger } from '../../logger'; import { MediaCallDirector } from '../../server/CallDirector'; import { getMediaCallServer } from '../../server/injection'; -import { stipSensitiveDataFromSignal, stripSensitiveDataFromSdp } from '../../server/stripSensitiveData'; +import { stripSensitiveDataFromSignal, stripSensitiveDataFromSdp } from '../../server/stripSensitiveData'; export class UserActorSignalProcessor { public get contractId(): string { @@ -74,7 +74,7 @@ export class UserActorSignalProcessor { } public async processSignal(signal: ClientMediaSignal): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal: stipSensitiveDataFromSignal(signal) }); + logger.debug({ msg: 'UserActorSignalProcessor.processSignal', signal: stripSensitiveDataFromSignal(signal) }); // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call diff --git a/ee/packages/media-calls/src/server/stripSensitiveData.ts b/ee/packages/media-calls/src/server/stripSensitiveData.ts index 3d01c207b9a2d..e4cb3123c90ce 100644 --- a/ee/packages/media-calls/src/server/stripSensitiveData.ts +++ b/ee/packages/media-calls/src/server/stripSensitiveData.ts @@ -13,7 +13,7 @@ export function stripSensitiveDataFromSdp(signal: T): Partial { +export function stripSensitiveDataFromSignal(signal: T): Partial { const { ...data } = signal; if ('sdp' in data) { diff --git a/packages/media-signaling/src/definition/services/IServiceProcessor.ts b/packages/media-signaling/src/definition/services/IServiceProcessor.ts index dc9271e6ff25a..7932f5952ea01 100644 --- a/packages/media-signaling/src/definition/services/IServiceProcessor.ts +++ b/packages/media-signaling/src/definition/services/IServiceProcessor.ts @@ -1,6 +1,6 @@ import type { Emitter } from '@rocket.chat/emitter'; -export type DefaultServiceStateMap = Record; +export type DefaultServiceStateMap = Record; export type ServiceStateEvent = { [P in keyof ServiceStateMap]: ServiceStateMap[P] extends infer U ? { stateName: P; stateValue: U } : { stateName: P; stateValue: string }; @@ -12,7 +12,6 @@ export type ServiceStateValue['stateValue']; export type ServiceProcessorEvents = { - stateChange: string; internalStateChange: keyof ServiceStateMap; internalError: { critical: boolean; error: string | Error }; negotiationNeeded: void; From d41cca78e8bf1e24f6df1b054cb50dcb37951b61 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 13:02:55 -0300 Subject: [PATCH 58/72] dockerfiles --- apps/meteor/ee/server/services/Dockerfile | 3 +++ ee/apps/account-service/Dockerfile | 3 +++ ee/apps/authorization-service/Dockerfile | 3 +++ ee/apps/ddp-streamer/Dockerfile | 3 +++ ee/apps/omnichannel-transcript/Dockerfile | 3 +++ ee/apps/presence-service/Dockerfile | 3 +++ ee/apps/queue-worker/Dockerfile | 3 +++ ee/apps/stream-hub-service/Dockerfile | 3 +++ 8 files changed, 24 insertions(+) diff --git a/apps/meteor/ee/server/services/Dockerfile b/apps/meteor/ee/server/services/Dockerfile index 27df57007bb21..5185fd74ff10d 100644 --- a/apps/meteor/ee/server/services/Dockerfile +++ b/apps/meteor/ee/server/services/Dockerfile @@ -42,6 +42,9 @@ COPY --from=build /app/packages/core-typings/dist /app/packages/core-typings/dis COPY --from=build /app/packages/rest-typings/package.json /app/packages/rest-typings/package.json COPY --from=build /app/packages/rest-typings/dist /app/packages/rest-typings/dist +COPY --from=build /app/packages/media-signaling/package.json /app/packages/media-signaling/package.json +COPY --from=build /app/packages/media-signaling/dist /app/packages/media-signaling/dist + COPY --from=build /app/packages/model-typings/package.json /app/packages/model-typings/package.json COPY --from=build /app/packages/model-typings/dist /app/packages/model-typings/dist diff --git a/ee/apps/account-service/Dockerfile b/ee/apps/account-service/Dockerfile index 7e01075ce25b9..65055925598d0 100644 --- a/ee/apps/account-service/Dockerfile +++ b/ee/apps/account-service/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index c0a42903dc8c3..f5c49abbaa217 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/ddp-streamer/Dockerfile b/ee/apps/ddp-streamer/Dockerfile index 2edb33ee07cb2..e755df4aee719 100644 --- a/ee/apps/ddp-streamer/Dockerfile +++ b/ee/apps/ddp-streamer/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/omnichannel-transcript/Dockerfile b/ee/apps/omnichannel-transcript/Dockerfile index a63dcc5ca92be..c8a306a213480 100644 --- a/ee/apps/omnichannel-transcript/Dockerfile +++ b/ee/apps/omnichannel-transcript/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/presence-service/Dockerfile b/ee/apps/presence-service/Dockerfile index d4465b3c1d7ef..a23a30b405a7f 100644 --- a/ee/apps/presence-service/Dockerfile +++ b/ee/apps/presence-service/Dockerfile @@ -23,6 +23,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/queue-worker/Dockerfile b/ee/apps/queue-worker/Dockerfile index a63dcc5ca92be..c8a306a213480 100644 --- a/ee/apps/queue-worker/Dockerfile +++ b/ee/apps/queue-worker/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js diff --git a/ee/apps/stream-hub-service/Dockerfile b/ee/apps/stream-hub-service/Dockerfile index 2f823ec2e5b38..0f017c5f90ef1 100644 --- a/ee/apps/stream-hub-service/Dockerfile +++ b/ee/apps/stream-hub-service/Dockerfile @@ -20,6 +20,9 @@ COPY ./packages/core-typings/dist packages/core-typings/dist COPY ./packages/rest-typings/package.json packages/rest-typings/package.json COPY ./packages/rest-typings/dist packages/rest-typings/dist +COPY ./packages/media-signaling/package.json packages/media-signaling/package.json +COPY ./packages/media-signaling/dist packages/media-signaling/dist + COPY ./packages/message-parser/package.json packages/message-parser/package.json COPY ./packages/message-parser/dist packages/message-parser/dist COPY ./packages/message-parser/messageParser.js packages/message-parser/messageParser.js From 21a1a28380398194f0212b2f608b4edbe40c4146 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 13:03:07 -0300 Subject: [PATCH 59/72] unregisterPeerEvents --- .../src/lib/services/webrtc/Processor.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 5f6169b810d61..4fdf580d11993 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -113,6 +113,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.stopped = true; // Stop only the remote stream; the track of the local stream may still be in use by another call so it's up to the session to stop it. this.remoteStream.stopAudio(); + + this.unregisterPeerEvents(); } public startNewNegotiation(): void { @@ -240,6 +242,23 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { peer.onsignalingstatechange = () => this.onSignalingStateChange(); } + private unregisterPeerEvents() { + try { + const { peer } = this; + + peer.ontrack = null as any; + peer.onicecandidate = null as any; + peer.onicecandidateerror = null as any; + peer.onconnectionstatechange = null as any; + peer.oniceconnectionstatechange = null as any; + peer.onnegotiationneeded = null as any; + peer.onicegatheringstatechange = null as any; + peer.onsignalingstatechange = null as any; + } catch { + // suppress exceptions here + } + } + private restartIce() { this.config.logger?.debug('MediaCallWebRTCProcessor.restartIce'); this.startNewNegotiation(); From 1f3e97b070c9d46f9563e39615abe39d510dd64f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 13:09:15 -0300 Subject: [PATCH 60/72] hangup call on unavailable response from a signed client --- .../media-calls/src/internal/agents/CallSignalProcessor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index d1a0b2ae16e54..f525ea8a89b74 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -192,7 +192,12 @@ export class UserActorSignalProcessor { protected async clientIsUnavailable(): Promise { logger.debug({ msg: 'UserActorSignalProcessor.clientIsUnavailable', role: this.role, uid: this.actorId }); - // Ignore 'unavailable' responses as some other client session may have a different answer + // Ignore 'unavailable' responses from unsigned clients as some other client session may have a different answer + if (!this.signed) { + return; + } + + await MediaCallDirector.hangup(this.call, this.agent, 'unavailable'); } protected async clientHasAccepted(): Promise { From 6fed7a8a85e938f6a075ccba2238c3eeedffa3b1 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 13:17:23 -0300 Subject: [PATCH 61/72] code review --- apps/meteor/ee/server/services/Dockerfile | 1 + .../src/internal/agents/CallSignalProcessor.ts | 8 ++++++-- .../media-signaling/src/lib/services/webrtc/Processor.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/meteor/ee/server/services/Dockerfile b/apps/meteor/ee/server/services/Dockerfile index 5185fd74ff10d..1a842005a080d 100644 --- a/apps/meteor/ee/server/services/Dockerfile +++ b/apps/meteor/ee/server/services/Dockerfile @@ -17,6 +17,7 @@ COPY ./packages/core-typings packages/core-typings COPY ./packages/eslint-config packages/eslint-config COPY ./packages/tsconfig packages/tsconfig COPY ./packages/rest-typings packages/rest-typings +COPY ./packages/media-signaling packages/media-signaling COPY ./packages/model-typings packages/model-typings COPY ./packages/models packages/models diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index f525ea8a89b74..8613ec930e85a 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -79,7 +79,7 @@ export class UserActorSignalProcessor { // The code will only reach this point if one of the following conditions are true: // 1. the signal came from the exact user session where the caller initiated the call // 2. the signal came from the exact user session where the callee accepted the call - // 3. the call has not been accepted yet and the signal came from a valid sesison from the callee + // 3. the call has not been accepted yet and the signal came from a valid session from the callee switch (signal.type) { case 'local-sdp': return this.saveLocalDescription(signal.sdp, signal.negotiationId); @@ -103,7 +103,11 @@ export class UserActorSignalProcessor { } protected async saveLocalDescription(sdp: RTCSessionDescriptionInit, negotiationId: string): Promise { - logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp: stripSensitiveDataFromSdp(sdp) }); + logger.debug({ msg: 'UserActorSignalProcessor.saveLocalDescription', sdp: stripSensitiveDataFromSdp(sdp), signed: this.signed }); + + if (!this.signed) { + return; + } await MediaCallDirector.saveWebrtcSession(this.call, this.agent, { sdp, negotiationId }, this.contractId); } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 4fdf580d11993..7690a4f98966e 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -7,7 +7,7 @@ import type { ServiceStateValue } from '../../../definition/services/IServicePro import { getExternalWaiter, type PromiseWaiterData } from '../../utils/getExternalWaiter'; export class MediaCallWebRTCProcessor implements IWebRTCProcessor { - public emitter: Emitter; + public readonly emitter: Emitter; private peer: RTCPeerConnection; From acf08fa8d9c55709620c89b44705538bb7011e20 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 13:53:24 -0300 Subject: [PATCH 62/72] better handling of call expiration --- .../internal/agents/CallSignalProcessor.ts | 5 +- .../media-calls/src/server/CallDirector.ts | 62 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts index 8613ec930e85a..d892fd60cff3f 100644 --- a/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts @@ -170,7 +170,10 @@ export class UserActorSignalProcessor { if (this.role === 'callee' && this.call.state === 'none') { // Change the call state from 'none' to 'ringing' when any callee session is found - await MediaCalls.startRingingById(this.callId, MediaCallDirector.getNewExpirationTime()); + const ringUpdateResult = await MediaCalls.startRingingById(this.callId, MediaCallDirector.getNewExpirationTime()); + if (ringUpdateResult.modifiedCount) { + MediaCallDirector.scheduleExpirationCheckByCallId(this.callId); + } } // The caller contract should be signed before the call even starts, so if this one isn't, ignore its state diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index e97351644fcd2..e24370ec59d1b 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -10,7 +10,7 @@ import type { InternalCallParams, MediaCallHeader } from '../definition/common'; import { logger } from '../logger'; const EXPIRATION_TIME = 120000; -const EXPIRATION_CHECK_DELAY = 1000; +const EXPIRATION_CHECK_TIMEOUT = EXPIRATION_TIME + 1000; export type CreateCallParams = InternalCallParams & { callerAgent: IMediaCallAgent; @@ -19,6 +19,9 @@ export type CreateCallParams = InternalCallParams & { webrtcOffer?: RTCSessionDescriptionInit; }; +// expiration checks by call id +const scheduledExpirationChecks = new Map>(); + export class MediaCallDirector { public static async hangup(call: IMediaCall, actorAgent: IMediaCallAgent, reason: CallHangupReason): Promise { const { actor: endedBy } = actorAgent; @@ -43,6 +46,7 @@ export class MediaCallDirector { return; } + this.scheduleExpirationCheckByCallId(call._id); return actorAgent.oppositeAgent?.onCallActive(call._id); } @@ -65,6 +69,8 @@ export class MediaCallDirector { return false; } + this.scheduleExpirationCheckByCallId(call._id); + await calleeAgent.onCallAccepted(call._id, data.calleeContractId); await calleeAgent.oppositeAgent?.onCallAccepted(call._id, call.caller.contractId); @@ -224,6 +230,8 @@ export class MediaCallDirector { throw new Error('failed-to-retrieve-call'); } + this.scheduleExpirationCheckByCallId(newCall._id); + if (webrtcOffer) { await this.startNewNegotiation(newCall, 'caller', webrtcOffer); } @@ -260,16 +268,30 @@ export class MediaCallDirector { return this.hangupDetachedCall(call, { endedBy: call.transferredBy, reason: 'transfer' }); } - public static async hangupExpiredCalls(): Promise { + public static async hangupExpiredCalls(): Promise; + + public static async hangupExpiredCalls(expectedCallId: string): Promise; + + public static async hangupExpiredCalls(expectedCallId?: string): Promise { logger.debug('MediaCallDirector.hangupExpiredCalls'); + let expectedCallWasExpired = false; + const result = MediaCalls.findAllExpiredCalls({ projection: { _id: 1, caller: 1, callee: 1 }, }); for await (const call of result) { + if (expectedCallId && call._id === expectedCallId) { + expectedCallWasExpired = true; + } + await this.hangupByServer(call, 'timeout'); } + + if (typeof expectedCallId === 'string') { + return expectedCallWasExpired; + } } public static getNewExpirationTime(): Date { @@ -278,14 +300,40 @@ export class MediaCallDirector { public static async renewCallId(callId: string): Promise { await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime()); - this.scheduleExpirationCheck(); + this.scheduleExpirationCheckByCallId(callId); + } + + public static scheduleExpirationCheckByCallId(callId: string): void { + const oldHandler = scheduledExpirationChecks.get(callId); + if (oldHandler) { + clearTimeout(oldHandler); + scheduledExpirationChecks.delete(callId); + } + + const handler = setTimeout(async () => { + logger.debug({ msg: 'MediaCallDirector.scheduleExpirationCheckByCallId.timeout', callId }); + scheduledExpirationChecks.delete(callId); + + const expectedCallWasExpired = await this.hangupExpiredCalls(callId).catch((error) => + logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error }), + ); + + if (!expectedCallWasExpired) { + const call = await MediaCalls.findOneById(callId, { projection: { ended: 1 } }); + if (call && !call.ended) { + this.scheduleExpirationCheckByCallId(callId); + } + } + }, EXPIRATION_CHECK_TIMEOUT); + + scheduledExpirationChecks.set(callId, handler); } public static scheduleExpirationCheck(): void { - setTimeout( - () => this.hangupExpiredCalls().catch((error) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error })), - EXPIRATION_TIME + EXPIRATION_CHECK_DELAY, - ); + setTimeout(async () => { + logger.debug({ msg: 'MediaCallDirector.scheduleExpirationCheck.timeout' }); + await this.hangupExpiredCalls().catch((error) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', error })); + }, EXPIRATION_CHECK_TIMEOUT); } public static async runOnCallCreatedForAgent( From fabc48b12936f09a53cf7182c8f4858f3dc6f96d Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 15:44:58 -0300 Subject: [PATCH 63/72] improved client side state handling --- .../src/internal/SignalProcessor.ts | 10 ++ .../src/internal/agents/UserActorAgent.ts | 1 + .../media-calls/src/server/MediaCallServer.ts | 2 + .../src/definition/call/IClientMediaCall.ts | 4 +- .../src/definition/signals/server/new.ts | 1 + packages/media-signaling/src/lib/Call.ts | 7 +- packages/media-signaling/src/lib/Session.ts | 95 +++++++++---------- 7 files changed, 65 insertions(+), 55 deletions(-) diff --git a/ee/packages/media-calls/src/internal/SignalProcessor.ts b/ee/packages/media-calls/src/internal/SignalProcessor.ts index 10332eebee3e8..2db283202bfe7 100644 --- a/ee/packages/media-calls/src/internal/SignalProcessor.ts +++ b/ee/packages/media-calls/src/internal/SignalProcessor.ts @@ -150,6 +150,9 @@ export class GlobalSignalProcessor { service: call.service, kind: call.kind, role: 'caller', + self: { + ...call.caller, + }, contact: { ...call.callee, }, @@ -164,6 +167,9 @@ export class GlobalSignalProcessor { service: call.service, kind: call.kind, role: 'callee', + self: { + ...call.callee, + }, contact: { ...call.caller, }, @@ -268,6 +274,9 @@ export class GlobalSignalProcessor { service: call.service, kind: call.kind, role: 'caller', + self: { + ...call.caller, + }, contact: { ...call.callee, }, @@ -278,6 +287,7 @@ export class GlobalSignalProcessor { } private rejectCallRequest(uid: IUser['_id'], rejection: Omit): never { + logger.info({ msg: 'Call Request Rejected', uid, rejection }); this.sendSignal(uid, { type: 'rejected-call-request', ...rejection }); throw new Error(rejection.reason); diff --git a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts index 43d254928d926..cf9a67171e1a4 100644 --- a/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts +++ b/ee/packages/media-calls/src/internal/agents/UserActorAgent.ts @@ -168,6 +168,7 @@ export class UserActorAgent extends BaseMediaCallAgent { service: call.service, kind: call.kind, role: this.role, + self: this.getMyCallActor(call), contact: this.getOtherCallActor(call), ...(call.parentCallId && { replacingCallId: call.parentCallId }), ...(call.callerRequestedId && this.role === 'caller' && { requestedCallId: call.callerRequestedId }), diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 0ccf335639a9f..21e240b72c647 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -70,6 +70,8 @@ export class MediaCallServer implements IMediaCallServer { const originalId = params.requestedCallId || params.parentCallId; if (originalId && params.requestedBy?.type === 'user') { + logger.info({ msg: 'Call Request Rejected', uid: params.requestedBy.id, rejectionReason }); + this.sendSignal(params.requestedBy.id, { type: 'rejected-call-request', callId: originalId, diff --git a/packages/media-signaling/src/definition/call/IClientMediaCall.ts b/packages/media-signaling/src/definition/call/IClientMediaCall.ts index 75cfcbb602b06..411d7724bef16 100644 --- a/packages/media-signaling/src/definition/call/IClientMediaCall.ts +++ b/packages/media-signaling/src/definition/call/IClientMediaCall.ts @@ -7,11 +7,11 @@ export type CallActorType = 'user' | 'sip'; export type CallContact = { type?: CallActorType; id?: string; + contractId?: string; + displayName?: string; username?: string; sipExtension?: string; - avatarUrl?: string; - host?: string; }; export type CallRole = 'caller' | 'callee'; diff --git a/packages/media-signaling/src/definition/signals/server/new.ts b/packages/media-signaling/src/definition/signals/server/new.ts index 328eacfe2510a..89967bd3a4fd5 100644 --- a/packages/media-signaling/src/definition/signals/server/new.ts +++ b/packages/media-signaling/src/definition/signals/server/new.ts @@ -8,6 +8,7 @@ export type ServerMediaSignalNewCall = { service: CallService; kind: 'direct'; role: CallRole; + self: CallContact; contact: CallContact; /** This will only be sent to the caller, with the id it used to request this call */ diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 2310822dcc1b3..f7f92a4f683cb 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -285,7 +285,12 @@ export class ClientMediaCall implements IClientMediaCall { } // If the call was requested by this specific session, assume we're signed already. - if (this._role === 'caller' && this.acceptedLocally && (signal.requestedCallId === this.localCallId || Boolean(oldCall))) { + if ( + this._role === 'caller' && + this.acceptedLocally && + this.contractState !== 'ignored' && + (signal.requestedCallId === this.localCallId || Boolean(oldCall)) + ) { this.contractState = 'pre-signed'; } diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index fdf62edc54e98..64211902b722c 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -4,27 +4,21 @@ import { ClientMediaCall } from './Call'; import { MediaSignalTransportWrapper } from './TransportWrapper'; import type { ClientMediaSignal, - ClientState, IServiceProcessorFactoryList, MediaSignalTransport, MediaStreamFactory, RandomStringFactory, ServerMediaSignal, } from '../definition'; -import type { IClientMediaCall, CallState, CallActorType, CallContact } from '../definition/call'; +import type { IClientMediaCall, CallActorType, CallContact } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; export type MediaSignalingEvents = { sessionStateChange: void; - callContactUpdate: { call: IClientMediaCall }; - callStateChange: { call: IClientMediaCall; oldState: CallState }; - callClientStateChange: { call: IClientMediaCall; oldState: ClientState }; - callTrackStateChange: { call: IClientMediaCall }; newCall: { call: IClientMediaCall }; acceptedCall: { call: IClientMediaCall }; - activeCall: { call: IClientMediaCall }; - endedCall: { call: IClientMediaCall }; - hiddenCall: { call: IClientMediaCall }; + endedCall: void; + hiddenCall: void; }; export type MediaSignalingSessionConfig = { @@ -65,6 +59,8 @@ export class MediaSignalingSession extends Emitter { private lastRegisterTimestamp: Date | null = null; + private lastState: { hasCall: boolean; hasVisibleCall: boolean; hasBusyCall: boolean }; + public get sessionId(): string { return this._sessionId; } @@ -85,6 +81,7 @@ export class MediaSignalingSession extends Emitter { this.deviceId = null; this.currentDeviceId = null; this.callsToGetUserMedia = 0; + this.lastState = { hasCall: false, hasVisibleCall: false, hasBusyCall: false }; this.transporter = new MediaSignalTransportWrapper(this._sessionId, config.transport, config.logger); @@ -157,6 +154,8 @@ export class MediaSignalingSession extends Emitter { } } else if ('toContractId' in signal) { call.setContractState(signal.toContractId === this._sessionId ? 'signed' : 'ignored'); + } else if (signal.type === 'new' && signal.self.contractId) { + call.setContractState(signal.self.contractId === this._sessionId ? 'signed' : 'ignored'); } const oldCall = this.getReplacedCallBySignal(signal); @@ -423,15 +422,15 @@ export class MediaSignalingSession extends Emitter { transporter: this.transporter, processorFactories: this.config.processorFactories, iceGatheringTimeout: this.config.iceGatheringTimeout || 1000, - sessionId: this.sessionId, + sessionId: this._sessionId, }; const call = new ClientMediaCall(config, callId, { inputTrack: this.inputTrack }); this.knownCalls.set(callId, call); call.emitter.on('contactUpdate', () => this.onCallContactUpdate(call)); - call.emitter.on('stateChange', (oldState) => this.onCallStateChange(call, oldState)); - call.emitter.on('clientStateChange', (oldState) => this.onCallClientStateChange(call, oldState)); + call.emitter.on('stateChange', () => this.onCallStateChange(call)); + call.emitter.on('clientStateChange', () => this.onCallClientStateChange(call)); call.emitter.on('trackStateChange', () => this.onTrackStateChange(call)); call.emitter.on('initialized', () => this.onNewCall(call)); call.emitter.on('accepted', () => this.onAcceptedCall(call)); @@ -443,54 +442,29 @@ export class MediaSignalingSession extends Emitter { return call; } - private onCallContactUpdate(call: ClientMediaCall): void { + private onCallContactUpdate(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onCallContactUpdate'); - if (call.hidden) { - return; - } - - this.emit('callContactUpdate', { call }); this.onSessionStateChange(); } - private onCallStateChange(call: ClientMediaCall, oldState: CallState): void { + private onCallStateChange(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onCallStateChange'); this.onSessionStateChange(); - if (call.hidden && call.state !== 'hangup') { - return; - } - - this.emit('callStateChange', { call, oldState }); } - private onCallClientStateChange(call: ClientMediaCall, oldState: ClientState): void { + private onCallClientStateChange(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onCallClientStateChange'); this.onSessionStateChange(); - if (call.hidden && call.state !== 'hangup') { - return; - } - - this.emit('callClientStateChange', { call, oldState }); } - private onNewCall(call: ClientMediaCall): void { + private onNewCall(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onNewCall'); this.onSessionStateChange(); - if (call.hidden) { - return; - } - - this.emit('newCall', { call }); } - private onAcceptedCall(call: ClientMediaCall): void { + private onAcceptedCall(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onAcceptedCall'); this.onSessionStateChange(); - if (call.hidden) { - return; - } - - this.emit('acceptedCall', { call }); } private onAcceptingCall(_call: ClientMediaCall): void { @@ -498,36 +472,53 @@ export class MediaSignalingSession extends Emitter { this.onSessionStateChange(); } - private onTrackStateChange(call: ClientMediaCall): void { + private onTrackStateChange(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onTrackStateChange'); this.onSessionStateChange(); - this.emit('callTrackStateChange', { call }); } private onEndedCall(call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onEndedCall'); this.ignoreCall(call.callId); this.onSessionStateChange(); - this.emit('endedCall', { call }); } - private onHiddenCall(call: ClientMediaCall): void { + private onHiddenCall(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onHiddenCall'); this.onSessionStateChange(); - - if (!call.isOver()) { - this.emit('hiddenCall', { call }); - } } - private onActiveCall(call: ClientMediaCall): void { + private onActiveCall(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onActiveCall'); this.onSessionStateChange(); - this.emit('activeCall', { call }); } private onSessionStateChange(): void { + const mainCall = this.getMainCall(); + const hasCall = Boolean(mainCall); + const hasVisibleCall = Boolean(mainCall && !mainCall.hidden); + const hasBusyCall = Boolean(hasVisibleCall && mainCall?.busy); + + const hadCall = this.lastState.hasCall; + const hadVisibleCall = this.lastState.hasVisibleCall; + const hadBusyCall = this.lastState.hasBusyCall; + + this.lastState = { hasCall, hasVisibleCall, hasBusyCall }; + + if (mainCall && !hadCall) { + this.emit('newCall', { call: mainCall }); + } + if (mainCall && hasBusyCall && !hadBusyCall) { + this.emit('acceptedCall', { call: mainCall }); + } + this.emit('sessionStateChange'); this.requestInputTrackUpdate(); + + if (hadCall && !hasCall) { + this.emit('endedCall'); + } else if (hadVisibleCall && !hasVisibleCall) { + this.emit('hiddenCall'); + } } } From b5a91fed7bf98d6ce664cc1fd1e9b26a1538db3c Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 15:54:17 -0300 Subject: [PATCH 64/72] logs --- apps/meteor/server/services/media-call/service.ts | 8 ++++++-- ee/packages/media-calls/src/logger.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 508e6a1feddfa..ae2b3ebd47744 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -51,8 +51,12 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall logger.error({ msg: 'Media Call Server failed to hangup expired calls', error }); }); - if (await MediaCalls.hasUnfinishedCalls()) { - callServer.scheduleExpirationCheck(); + try { + if (await MediaCalls.hasUnfinishedCalls()) { + callServer.scheduleExpirationCheck(); + } + } catch (error) { + logger.error({ msg: 'Media Call Server failed to check if there are expired calls', error }); } } diff --git a/ee/packages/media-calls/src/logger.ts b/ee/packages/media-calls/src/logger.ts index b15554abc0194..5d5ec796236b0 100644 --- a/ee/packages/media-calls/src/logger.ts +++ b/ee/packages/media-calls/src/logger.ts @@ -1,3 +1,3 @@ import { Logger } from '@rocket.chat/logger'; -export const logger = new Logger('MediaSignalingServer'); +export const logger = new Logger('MediaCallsServer'); From dcfd74e27482360d3f12fdb1953f2f100f11ad54 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 16:34:50 -0300 Subject: [PATCH 65/72] handle situation where ICE Gathering time outs with no candidates --- packages/media-signaling/src/lib/Call.ts | 4 ++++ .../src/lib/services/webrtc/Processor.ts | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index f7f92a4f683cb..b0c7850b79e76 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -1002,6 +1002,10 @@ export class ClientMediaCall implements IClientMediaCall { private onWebRTCNegotiationNeeded(): void { if (this._state !== 'active' || !this.currentNegotiationId) { + this.config.logger?.warn('WebRTCProcessor requested a renegotiation while in a state that should not trigger any.', { + state: this._state, + currentNegotiationId: this.currentNegotiationId, + }); return; } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index 7690a4f98966e..bd5d4141c827b 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -43,6 +43,10 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private stopped = false; + private iceCandidateCount = 0; + + private lastSetLocalDescription: string | null = null; + constructor(private readonly config: WebRTCProcessorConfig) { this.localMediaStream = new MediaStream(); this.remoteMediaStream = new MediaStream(); @@ -83,6 +87,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } const offer = await this.peer.createOffer(); + if (this.lastSetLocalDescription && offer.sdp !== this.lastSetLocalDescription && !iceRestart) { + this.startNewNegotiation(); + } + + this.lastSetLocalDescription = offer.sdp || null; await this.peer.setLocalDescription(offer); return this.getLocalDescription(); @@ -120,6 +129,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { public startNewNegotiation(): void { this.iceGatheringFinished = false; this.clearIceGatheringWaiters(new Error('new-negotiation')); + this.iceCandidateCount = 0; } public async createAnswer({ sdp }: { sdp: RTCSessionDescriptionInit }): Promise<{ sdp: RTCSessionDescriptionInit }> { @@ -140,6 +150,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { const answer = await this.peer.createAnswer(); + this.lastSetLocalDescription = answer.sdp || null; await this.peer.setLocalDescription(answer); return this.getLocalDescription(); @@ -182,7 +193,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { - this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription'); + this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription', this.iceCandidateCount); if (this.stopped) { throw new Error('WebRTC Processor has already been stopped.'); } @@ -194,6 +205,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { throw new Error('no-local-sdp'); } + // If we don't have any ice candidate, trigger a service error. + if (this.iceCandidateCount === 0) { + this.emitter.emit('internalError', { critical: true, error: 'no-ice-candidates' }); + } + return { sdp, }; @@ -213,7 +229,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { return; } - this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering - timeout'); + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering.timeout', this.iceCandidateCount); this.clearIceGatheringData(iceGatheringData); this.iceGatheringTimedOut = true; this.changeInternalState('iceUntrickler'); From 3e0a01d664abf974d6091365872aebc5d247d747 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 18:18:25 -0300 Subject: [PATCH 66/72] ice candidate count --- packages/media-signaling/src/lib/services/webrtc/Processor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index bd5d4141c827b..fc87c4b06eeba 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -193,7 +193,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private async getLocalDescription(): Promise<{ sdp: RTCSessionDescriptionInit }> { - this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription', this.iceCandidateCount); + this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription'); if (this.stopped) { throw new Error('WebRTC Processor has already been stopped.'); } @@ -205,6 +205,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { throw new Error('no-local-sdp'); } + this.config.logger?.debug('MediaCallWebRTCProcessor.getLocalDescription - ice candidates: ', this.iceCandidateCount); // If we don't have any ice candidate, trigger a service error. if (this.iceCandidateCount === 0) { this.emitter.emit('internalError', { critical: true, error: 'no-ice-candidates' }); @@ -288,6 +289,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', event.candidate); + this.iceCandidateCount++; } private onIceCandidateError(event: RTCPeerConnectionIceErrorEvent) { From 433d3ced90d559307c5dae7b748633f184ac1527 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 19:03:59 -0300 Subject: [PATCH 67/72] endSession --- packages/media-signaling/src/lib/Call.ts | 8 ++++++++ packages/media-signaling/src/lib/Session.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index b0c7850b79e76..3d62f60877d29 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -524,6 +524,7 @@ export class ClientMediaCall implements IClientMediaCall { this.updateClientState(); this.reportStates(); this.mayReportStates = false; + this.clearStateTimeouts(); } public setMuted(muted: boolean): void { @@ -971,6 +972,13 @@ export class ClientMediaCall implements IClientMediaCall { } } + private clearStateTimeouts(): void { + for (const handler of this.stateTimeoutHandlers.values()) { + clearTimeout(handler.handler); + } + this.stateTimeoutHandlers.clear(); + } + private onWebRTCInternalStateChange(stateName: keyof WebRTCInternalStateMap): void { this.config.logger?.debug('ClientMediaCall.onWebRTCInternalStateChange'); if (!this.webrtcProcessor) { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 64211902b722c..8f5e2ca77d9cc 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -108,6 +108,13 @@ export class MediaSignalingSession extends Emitter { } } + public endSession(): void { + this.disableStateReport(); + for (const call of this.knownCalls.values()) { + call.ignore(); + } + } + public getCallData(callId: string): IClientMediaCall | null { return this.knownCalls.get(callId) || null; } From 0eeb0871a1beadcf459e78045fa9dbc5751ea51f Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 19:24:12 -0300 Subject: [PATCH 68/72] code review --- packages/media-signaling/src/lib/Session.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 8f5e2ca77d9cc..25321528a987f 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -110,9 +110,16 @@ export class MediaSignalingSession extends Emitter { public endSession(): void { this.disableStateReport(); + + // best‑effort: stop capturing audio + void this.setInputTrack(null).catch(() => undefined); + for (const call of this.knownCalls.values()) { + this.ignoredCalls.add(call.callId); call.ignore(); } + + this.knownCalls.clear(); } public getCallData(callId: string): IClientMediaCall | null { @@ -237,6 +244,7 @@ export class MediaSignalingSession extends Emitter { const localCall = this.knownCalls.get(signal.requestedCallId); if (localCall) { this.knownCalls.set(signal.callId, localCall); + this.knownCalls.delete(signal.requestedCallId); return localCall; } } From e29335beecc8aaf94abe93c452dd993c2be4e95a Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Mon, 15 Sep 2025 19:31:42 -0300 Subject: [PATCH 69/72] fix: getUserMedia being called too early --- packages/media-signaling/src/lib/Session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 25321528a987f..82c63c9ec4e87 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -357,7 +357,7 @@ export class MediaSignalingSession extends Emitter { private async maybeStartInputTrack(): Promise { this.config.logger?.debug('MediaSignalingSession.maybeStartInputTrack'); for (const call of this.knownCalls.values()) { - if (!call.mayNeedInputTrack()) { + if (!call.needsInputTrack()) { continue; } From e95286bebd8decc950a42c1758db3d284abfc138 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 16 Sep 2025 16:48:03 -0300 Subject: [PATCH 70/72] improve handling of input tracks --- packages/media-signaling/src/lib/Session.ts | 16 ++++-- .../src/lib/services/webrtc/LocalStream.ts | 51 ++++++------------- .../src/lib/services/webrtc/Processor.ts | 18 ++++--- .../src/lib/services/webrtc/RemoteStream.ts | 2 +- .../src/lib/services/webrtc/Stream.ts | 25 ++++++--- 5 files changed, 60 insertions(+), 52 deletions(-) diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 82c63c9ec4e87..4ca0aed820b44 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -186,6 +186,7 @@ export class MediaSignalingSession extends Emitter { return; } + this.config.logger?.debug('MediaSignalingSession.setDeviceId'); await this.setInputTrack(null); await this.startInputTrack(); } @@ -314,7 +315,7 @@ export class MediaSignalingSession extends Emitter { } private async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { - this.config.logger?.debug('MediaSignalingSession.setInputTrack'); + this.config.logger?.debug('MediaSignalingSession.setInputTrack', Boolean(newInputTrack)); const { inputTrack: oldInputTrack } = this; if (newInputTrack === oldInputTrack) { return; @@ -323,10 +324,15 @@ export class MediaSignalingSession extends Emitter { this.inputTrack = newInputTrack; for await (const call of this.knownCalls.values()) { + if (newInputTrack && (call.hidden || call.isOver())) { + continue; + } + await call.setInputTrack(newInputTrack); } if (oldInputTrack) { + this.config.logger?.debug('MediaSignalingSession.setInputTrack.stopOldTrack'); oldInputTrack.stop(); } } @@ -340,6 +346,7 @@ export class MediaSignalingSession extends Emitter { } private async updateInputTrack(): Promise { + this.config.logger?.debug('MediaSignalingSession.updatingInputTrack', this.callsToGetUserMedia); this.updatingInputTrack = true; try { @@ -350,7 +357,8 @@ export class MediaSignalingSession extends Emitter { await this.maybeStartInputTrack(); } finally { - this.updatingInputTrack = this.callsToGetUserMedia > 0; + this.updatingInputTrack = false; + this.config.logger?.debug('MediaSignalingSession.updatingInputTrack.finally', this.callsToGetUserMedia); } } @@ -374,7 +382,7 @@ export class MediaSignalingSession extends Emitter { } private async startInputTrack(): Promise { - this.config.logger?.debug('MediaSignalingSession.startInputTrack'); + this.config.logger?.debug('MediaSignalingSession.startInputTrack', this.callsToGetUserMedia); this.currentDeviceId = this.deviceId; @@ -386,6 +394,8 @@ export class MediaSignalingSession extends Emitter { this.callsToGetUserMedia--; } + this.config.logger?.debug('MediaSignalingSession.startInputTrack.done', this.callsToGetUserMedia); + // If there's multiple simultaneous attempts to get the track, only process the output of the last one if (this.callsToGetUserMedia > 0) { return; diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index 576a28e9d3554..da077722c9871 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,49 +1,30 @@ import { Stream } from './Stream'; +import type { IMediaSignalLogger } from '../../../definition'; export class LocalStream extends Stream { - private emptyTransceiver: RTCRtpTransceiver | null = null; + private audioTransceiver: RTCRtpTransceiver; - public async setTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { - if (newTrack?.kind !== 'audio') { + constructor(mediaStream: MediaStream, peer: RTCPeerConnection, logger?: IMediaSignalLogger) { + super(mediaStream, peer, logger); + // Ensures the peer will always have an audio transceiver, even if we have no audio track yet + this.audioTransceiver = this.peer.addTransceiver('audio', { direction: 'sendrecv' }); + } + + public async setTrack(newTrack: MediaStreamTrack | null): Promise { + if (newTrack && newTrack?.kind !== 'audio') { return; } + this.logger?.debug('LocalStream.setTrack'); if (newTrack) { - const matchingTrack = this.mediaStream.getTrackById(newTrack.id); - if (matchingTrack) { - matchingTrack.enabled = this.enabled; + if (!this.setAudioTrack(newTrack)) { return; } - - newTrack.enabled = this.enabled; - } - - this.removeAudioTracks(); - if (newTrack) { - this.mediaStream.addTrack(newTrack); - } - - await this.setRemoteTrack(newTrack, peer); - } - - private async setRemoteTrack(newTrack: MediaStreamTrack | null, peer: RTCPeerConnection): Promise { - // If the peer doesn't yet have any audio track, send it to them - const sender = peer.getSenders().find((sender) => sender.track?.kind === 'audio' || sender === this.emptyTransceiver?.sender); - if (!sender) { - if (newTrack) { - // This will require a re-negotiation - peer.addTrack(newTrack, this.mediaStream); - this.emptyTransceiver = null; - } else { - this.emptyTransceiver = peer.addTransceiver('audio', { direction: 'sendrecv' }); - } - return; + } else { + this.removeAudioTracks(); } - // If the peer already has a track of the same kind, we can just replace it with the new track with no issues - await sender.replaceTrack(newTrack); - if (newTrack) { - this.emptyTransceiver = null; - } + this.logger?.debug('LocalStream.setRemoteTrack'); + await this.audioTransceiver.sender.replaceTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index fc87c4b06eeba..a48a4bd62b8dc 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -53,10 +53,11 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.iceGatheringWaiters = new Set(); this.inputTrack = config.inputTrack; - this.localStream = new LocalStream(this.localMediaStream); - this.remoteStream = new RemoteStream(this.remoteMediaStream); - this.peer = new RTCPeerConnection(config.rtc); + + this.localStream = new LocalStream(this.localMediaStream, this.peer, this.config.logger); + this.remoteStream = new RemoteStream(this.remoteMediaStream, this.peer, this.config.logger); + this.emitter = new Emitter(); this.registerPeerEvents(); } @@ -86,7 +87,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.restartIce(); } - const offer = await this.peer.createOffer(); + // offerToReceiveAudio should not be needed if an audio transceiver was added, but there's no harm in using it anyway + const offer = await this.peer.createOffer({ offerToReceiveAudio: true }); if (this.lastSetLocalDescription && offer.sdp !== this.lastSetLocalDescription && !iceRestart) { this.startNewNegotiation(); } @@ -122,8 +124,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { this.stopped = true; // Stop only the remote stream; the track of the local stream may still be in use by another call so it's up to the session to stop it. this.remoteStream.stopAudio(); - this.unregisterPeerEvents(); + + this.peer.close(); } public startNewNegotiation(): void { @@ -315,7 +318,7 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } this.config.logger?.debug('MediaCallWebRTCProcessor.onTrack', event.track.kind); // Received a remote stream - this.remoteStream.setTrack(event.track, this.peer); + this.remoteStream.setTrack(event.track); } private onConnectionStateChange() { @@ -367,8 +370,9 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } private async loadInputTrack(): Promise { + this.config.logger?.debug('MediaCallWebRTCProcessor.loadInputTrack'); this.localMediaStreamInitialized = true; - await this.localStream.setTrack(this.inputTrack, this.peer); + await this.localStream.setTrack(this.inputTrack); } private onIceGatheringComplete() { diff --git a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts index dd0e44731d0b0..11befe71c2b9b 100644 --- a/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts @@ -1,7 +1,7 @@ import { Stream } from './Stream'; export class RemoteStream extends Stream { - public setTrack(newTrack: MediaStreamTrack, _peer: RTCPeerConnection): void { + public setTrack(newTrack: MediaStreamTrack): void { this.setAudioTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts index 1114e7e2e0a0f..989c5b2c8ce2c 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Stream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -1,9 +1,15 @@ +import type { IMediaSignalLogger } from '../../../definition'; + export class Stream { protected mediaStream: MediaStream; protected enabled: boolean; - constructor(mediaStream: MediaStream) { + constructor( + mediaStream: MediaStream, + protected readonly peer: RTCPeerConnection, + protected readonly logger?: IMediaSignalLogger, + ) { this.mediaStream = mediaStream; this.enabled = true; } @@ -26,6 +32,7 @@ export class Stream { } protected toggleAudioTracks(): void { + this.logger?.debug('Stream.toggleAudioTracks', this.enabled); this.mediaStream.getAudioTracks().forEach((track) => { if (!track) { return; @@ -36,6 +43,7 @@ export class Stream { } protected removeAudioTracks(): void { + this.logger?.debug('Stream.removeAudioTracks'); this.mediaStream.getAudioTracks().forEach((track) => { if (!track) { return; @@ -45,17 +53,22 @@ export class Stream { }); } - protected setAudioTrack(newTrack: MediaStreamTrack): void { - if (newTrack?.kind !== 'audio') { - return; + protected setAudioTrack(newTrack: MediaStreamTrack): boolean { + if (newTrack.kind !== 'audio') { + return false; } - if (this.mediaStream.getTrackById(newTrack.id)) { - return; + this.logger?.debug('Stream.setAudioTrack', newTrack.id); + const matchingTrack = this.mediaStream.getTrackById(newTrack.id); + if (matchingTrack) { + this.logger?.debug('Stream.setAudioTrack.return', 'track found by id'); + matchingTrack.enabled = this.enabled; + return false; } this.removeAudioTracks(); this.mediaStream.addTrack(newTrack); newTrack.enabled = this.enabled; + return true; } } From f9abc9fb07e02d955a933ce8c393f8e97b8c75e0 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 16 Sep 2025 20:34:36 -0300 Subject: [PATCH 71/72] bug fixes --- packages/media-signaling/src/lib/Call.ts | 25 +++++++++++- packages/media-signaling/src/lib/Session.ts | 16 +++++--- .../src/lib/services/webrtc/LocalStream.ts | 39 ++++++++++++------- .../src/lib/services/webrtc/Processor.ts | 28 ++++++++++++- .../src/lib/services/webrtc/Stream.ts | 2 - 5 files changed, 85 insertions(+), 25 deletions(-) diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 3d62f60877d29..adf5cbec49d4e 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -155,6 +155,8 @@ export class ClientMediaCall implements IClientMediaCall { private creationTimestamp: Date; + private pendingAnswerRequest: ServerMediaSignalRemoteSDP | null; + constructor( private readonly config: IClientMediaCallConfig, callId: string, @@ -180,6 +182,7 @@ export class ClientMediaCall implements IClientMediaCall { this.mayReportStates = true; this.inputTrack = inputTrack || null; this.creationTimestamp = new Date(); + this.pendingAnswerRequest = null; this.currentNegotiationId = null; this.earlySignals = new Set(); @@ -314,7 +317,11 @@ export class ClientMediaCall implements IClientMediaCall { return false; } - return ['active', 'renegotiating'].includes(this._state); + if (this.role === 'caller') { + return this.hasRemoteData; + } + + return this.busy; } public hasInputTrack(): boolean { @@ -365,12 +372,19 @@ export class ClientMediaCall implements IClientMediaCall { } public async setInputTrack(newInputTrack: MediaStreamTrack | null): Promise { - this.config.logger?.debug('ClientMediaCall.setInputTrack'); + this.config.logger?.debug('ClientMediaCall.setInputTrack', Boolean(newInputTrack)); + if (newInputTrack && (this.isOver() || this.hidden)) { + return; + } this.inputTrack = newInputTrack; if (this.webrtcProcessor) { await this.webrtcProcessor.setInputTrack(newInputTrack); } + + if (newInputTrack && this.pendingAnswerRequest) { + await this.processAnswerRequest(this.pendingAnswerRequest); + } } public getRemoteMediaStream(): MediaStream { @@ -743,6 +757,7 @@ export class ClientMediaCall implements IClientMediaCall { } protected async processAnswerRequest(signal: ServerMediaSignalRemoteSDP): Promise { + this.pendingAnswerRequest = null; if (this.hidden || this.shouldIgnoreWebRTC()) { return; } @@ -760,6 +775,12 @@ export class ClientMediaCall implements IClientMediaCall { } this.currentNegotiationId = negotiationId; + if (!this.hasInputTrack()) { + this.pendingAnswerRequest = signal; + this.config.logger?.debug('Delaying WebRTC Answer due to missing audio input track.'); + return; + } + let answer: { sdp: RTCSessionDescriptionInit } | null = null; try { answer = await this.webrtcProcessor.createAnswer(signal); diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 4ca0aed820b44..1b9ced5d143ec 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -324,16 +324,20 @@ export class MediaSignalingSession extends Emitter { this.inputTrack = newInputTrack; for await (const call of this.knownCalls.values()) { - if (newInputTrack && (call.hidden || call.isOver())) { - continue; - } - - await call.setInputTrack(newInputTrack); + await call.setInputTrack(newInputTrack).catch((error) => { + if (newInputTrack) { + throw error; + } + }); } if (oldInputTrack) { this.config.logger?.debug('MediaSignalingSession.setInputTrack.stopOldTrack'); - oldInputTrack.stop(); + try { + oldInputTrack.stop(); + } catch { + // + } } } diff --git a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts index da077722c9871..4d268520d91de 100644 --- a/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/LocalStream.ts @@ -1,15 +1,6 @@ import { Stream } from './Stream'; -import type { IMediaSignalLogger } from '../../../definition'; export class LocalStream extends Stream { - private audioTransceiver: RTCRtpTransceiver; - - constructor(mediaStream: MediaStream, peer: RTCPeerConnection, logger?: IMediaSignalLogger) { - super(mediaStream, peer, logger); - // Ensures the peer will always have an audio transceiver, even if we have no audio track yet - this.audioTransceiver = this.peer.addTransceiver('audio', { direction: 'sendrecv' }); - } - public async setTrack(newTrack: MediaStreamTrack | null): Promise { if (newTrack && newTrack?.kind !== 'audio') { return; @@ -17,14 +8,36 @@ export class LocalStream extends Stream { this.logger?.debug('LocalStream.setTrack'); if (newTrack) { - if (!this.setAudioTrack(newTrack)) { + const matchingTrack = this.mediaStream.getTrackById(newTrack.id); + if (matchingTrack) { + matchingTrack.enabled = this.enabled; return; } - } else { - this.removeAudioTracks(); + + newTrack.enabled = this.enabled; + } + + this.removeAudioTracks(); + if (newTrack) { + this.mediaStream.addTrack(newTrack); + await this.setRemoteTrack(newTrack); } + } + private async setRemoteTrack(newTrack: MediaStreamTrack | null): Promise { + // If the peer doesn't yet have any audio track, send it to them this.logger?.debug('LocalStream.setRemoteTrack'); - await this.audioTransceiver.sender.replaceTrack(newTrack); + const sender = this.peer.getSenders().find((sender) => sender.track?.kind === 'audio'); + if (!sender) { + if (newTrack) { + this.logger?.debug('LocalStream.setRemoteTrack.addTrack'); + // This will require a re-negotiation + this.peer.addTrack(newTrack, this.mediaStream); + } + return; + } + + // If the peer already has a track of the same kind, we can just replace it with the new track with no issues + await sender.replaceTrack(newTrack); } } diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index a48a4bd62b8dc..d39ee30f078e0 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -47,6 +47,8 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { private lastSetLocalDescription: string | null = null; + private addedEmptyTransceiver = false; + constructor(private readonly config: WebRTCProcessorConfig) { this.localMediaStream = new MediaStream(); this.remoteMediaStream = new MediaStream(); @@ -83,12 +85,23 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } await this.initializeLocalMediaStream(); + if (!this.addedEmptyTransceiver) { + // If there's no audio transceivers yet, add a new one; since it's an offer, the track can be set later + const transceivers = this.peer + .getTransceivers() + .filter((transceiver) => transceiver.sender.track?.kind === 'audio' || transceiver.receiver.track?.kind === 'audio'); + + if (!transceivers.length) { + this.peer.addTransceiver('audio', { direction: 'sendrecv' }); + this.addedEmptyTransceiver = true; + } + } + if (iceRestart) { this.restartIce(); } - // offerToReceiveAudio should not be needed if an audio transceiver was added, but there's no harm in using it anyway - const offer = await this.peer.createOffer({ offerToReceiveAudio: true }); + const offer = await this.peer.createOffer(); if (this.lastSetLocalDescription && offer.sdp !== this.lastSetLocalDescription && !iceRestart) { this.startNewNegotiation(); } @@ -143,9 +156,20 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { if (sdp.type !== 'offer') { throw new Error('invalid-webrtc-offer'); } + if (!this.inputTrack) { + throw new Error('no-input-track'); + } await this.initializeLocalMediaStream(); + const transceivers = this.peer + .getTransceivers() + .filter((transceiver) => transceiver.sender.track?.kind === 'audio' || transceiver.receiver.track?.kind === 'audio'); + + if (!transceivers.length) { + throw new Error('no-audio-transceiver'); + } + if (this.peer.remoteDescription?.sdp !== sdp.sdp) { this.startNewNegotiation(); await this.peer.setRemoteDescription(sdp); diff --git a/packages/media-signaling/src/lib/services/webrtc/Stream.ts b/packages/media-signaling/src/lib/services/webrtc/Stream.ts index 989c5b2c8ce2c..6b104b89c4259 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Stream.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Stream.ts @@ -62,13 +62,11 @@ export class Stream { const matchingTrack = this.mediaStream.getTrackById(newTrack.id); if (matchingTrack) { this.logger?.debug('Stream.setAudioTrack.return', 'track found by id'); - matchingTrack.enabled = this.enabled; return false; } this.removeAudioTracks(); this.mediaStream.addTrack(newTrack); - newTrack.enabled = this.enabled; return true; } } From 1973027983043524e1373cea83a4b15192edd0b7 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Tue, 16 Sep 2025 20:49:22 -0300 Subject: [PATCH 72/72] fix presence check on transfered calls --- ee/packages/media-calls/src/internal/InternalCallProvider.ts | 2 +- packages/model-typings/src/models/IMediaCallsModel.ts | 2 +- packages/models/src/models/MediaCalls.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ee/packages/media-calls/src/internal/InternalCallProvider.ts b/ee/packages/media-calls/src/internal/InternalCallProvider.ts index af3e489765c7a..2c84e03a19dd7 100644 --- a/ee/packages/media-calls/src/internal/InternalCallProvider.ts +++ b/ee/packages/media-calls/src/internal/InternalCallProvider.ts @@ -13,7 +13,7 @@ export class InternalCallProvider extends BaseCallProvider { throw new CallRejectedError('unsupported'); } - if (await MediaCalls.hasUnfinishedCallsByUid(params.caller.id)) { + if (await MediaCalls.hasUnfinishedCallsByUid(params.caller.id, params.parentCallId)) { throw new CallRejectedError('busy'); } if (await MediaCalls.hasUnfinishedCallsByUid(params.callee.id)) { diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index 9ed400fff59df..364dd7802e371 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -18,5 +18,5 @@ export interface IMediaCallsModel extends IBaseModel { findAllExpiredCalls(options: FindOptions | undefined): FindCursor; findAllNotOverByUid(uid: IUser['_id'], options?: FindOptions): FindCursor; hasUnfinishedCalls(): Promise; - hasUnfinishedCallsByUid(uid: IUser['_id']): Promise; + hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise; } diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index fa8dfe2d2cd0b..f62b9ab069b4c 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -186,11 +186,12 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod return count > 0; } - public async hasUnfinishedCallsByUid(uid: IUser['_id']): Promise { + public async hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise { const count = await this.countDocuments( { ended: false, uids: uid, + ...(exceptCallId && { _id: { $ne: exceptCallId } }), }, { limit: 1 }, );