Skip to content

Conversation

@pierre-lehnen-rc
Copy link
Contributor

@pierre-lehnen-rc pierre-lehnen-rc commented Jul 16, 2025

Proposed changes (including videos or screenshots)

This PR implements the base signaling system for media calls, along with a basic webrtc implementation. It doesn't integrate this new system with the rocket.chat UI so it's not usable by itself. Changes are still expected on this codebase as we implement more features and add SIP integration.

Issue(s)

VAI-14
VAI-83

Steps to test or reproduce

Further comments

This PR introduces two new packages, and modifies a few others:

media-signaling:

This package includes all of the business logic that clients need to implement in order to receive calls. It also exports all the types that are shared between client and server. It is meant to be published to npm, so it doesn't have any dependency on other packages from the workspace. That way any client that wants to implement media calls can import this package to get the full functionality while having only to implement the UI for it.

media-calls:

This package implements the server logic for the whole media call system, without integrating it with rocket.chat itself (aka this package simply being there won't do anything at all). It imports things from other packages when it made sense to implement them on existing packages (like models and some core typings), but otherwise is mostly self-contained.

other modified packages:

Some existing packages such as core-services and ddp-client (as well as the main meteor app) were modified to actually connect the rocket.chat core with the media-calls package. In general those do not include any business logic for the media call system.

Summary by CodeRabbit

  • New Features

    • 1:1 WebRTC calling: ringing/accept/active/hangup, renegotiation, transfer, per-session client API and real-time per-user media signaling.
  • Bug Fixes

    • Expired/stale calls are automatically hung up during startup/license validation to prevent lingering calls.
  • Chores

    • Added media-call service, typings, models, client libraries, notifications integration, service registration, packaging, and Docker build updates to enable media calling.

@dionisio-bot
Copy link
Contributor

dionisio-bot bot commented Jul 16, 2025

Looks like this PR is ready to merge! 🎉
If you have any trouble, please check the PR guidelines

@changeset-bot
Copy link

changeset-bot bot commented Jul 16, 2025

⚠️ No Changeset found

Latest commit: 5f9f7e3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Jul 31, 2025

PR Preview Action v1.6.2

🚀 View preview at
https://RocketChat.github.io/Rocket.Chat/pr-preview/pr-36452/

Built to branch gh-pages at 2025-08-13 18:40 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@codecov
Copy link

codecov bot commented Jul 31, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 66.33%. Comparing base (9885465) to head (5f9f7e3).
⚠️ Report is 13 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop   #36452      +/-   ##
===========================================
- Coverage    66.36%   66.33%   -0.03%     
===========================================
  Files         3390     3390              
  Lines       115279   115282       +3     
  Branches     21108    21107       -1     
===========================================
- Hits         76501    76474      -27     
- Misses       36172    36204      +32     
+ Partials      2606     2604       -2     
Flag Coverage Δ
e2e 56.95% <ø> (+<0.01%) ⬆️
unit 71.39% <100.00%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1)

13-16: Guard for non‑audio tracks is in place — good.

Matches earlier feedback and prevents misrouting video to audio sender.

🧹 Nitpick comments (7)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (3)

27-29: Surface replaceTrack failures and include track id in logs.

Catch and log errors to avoid unhandled rejections; add context to debug logs.

-		this.logger?.debug('LocalStream.setRemoteTrack');
-		await this.audioTransceiver.sender.replaceTrack(newTrack);
+		this.logger?.debug('LocalStream.setRemoteTrack', newTrack ? newTrack.id : null);
+		try {
+			await this.audioTransceiver.sender.replaceTrack(newTrack);
+		} catch (err) {
+			this.logger?.error('LocalStream.replaceTrack failed', err);
+			throw err;
+		}

14-16: Remove redundant optional chaining.

newTrack is already truthy; ?. is unnecessary.

-		if (newTrack && newTrack?.kind !== 'audio') {
+		if (newTrack && newTrack.kind !== 'audio') {

10-10: Consider sendonly for the local audio transceiver.

If receiving is handled elsewhere (e.g., a dedicated RemoteStream), prefer sendonly to avoid accidental coupling.

-		this.audioTransceiver = this.peer.addTransceiver('audio', { direction: 'sendrecv' });
+		this.audioTransceiver = this.peer.addTransceiver('audio', { direction: 'sendonly' });
packages/media-signaling/src/lib/services/webrtc/Stream.ts (4)

30-32: Release mic resources on stop.

Stopping tracks before removal avoids lingering device capture and improves UX/privacy.

  public stopAudio(): void {
-		this.removeAudioTracks();
+		// Stop local audio tracks to release the microphone, then remove them
+		this.mediaStream.getAudioTracks().forEach((track) => {
+			try {
+				track.stop();
+			} catch {}
+		});
+		this.removeAudioTracks();
  }

34-43: Drop impossible null checks in toggleAudioTracks.

getAudioTracks() yields MediaStreamTrack[]; the !track guard is dead code.

  protected toggleAudioTracks(): void {
    this.logger?.debug('Stream.toggleAudioTracks', this.enabled);
-    this.mediaStream.getAudioTracks().forEach((track) => {
-      if (!track) {
-        return;
-      }
-
-      track.enabled = this.enabled;
-    });
+    this.mediaStream.getAudioTracks().forEach((track) => {
+      track.enabled = this.enabled;
+    });
  }

45-54: Same here: remove redundant null guard in removeAudioTracks.

  protected removeAudioTracks(): void {
    this.logger?.debug('Stream.removeAudioTracks');
-    this.mediaStream.getAudioTracks().forEach((track) => {
-      if (!track) {
-        return;
-      }
-
-      this.mediaStream.removeTrack(track);
-    });
+    this.mediaStream.getAudioTracks().forEach((track) => {
+      this.mediaStream.removeTrack(track);
+    });
  }

56-73: Clarify setAudioTrack return semantics.

A boolean conflates “invalid kind” vs “already present”. Consider a discriminated union for clearer call‑site logic. Non‑blocking.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e29335b and e95286b.

📒 Files selected for processing (5)
  • packages/media-signaling/src/lib/Session.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/Processor.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/Stream.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts
  • packages/media-signaling/src/lib/services/webrtc/Processor.ts
  • packages/media-signaling/src/lib/Session.ts
🧰 Additional context used
🧬 Code graph analysis (2)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (2)
packages/media-signaling/src/lib/services/webrtc/Stream.ts (1)
  • Stream (3-74)
packages/media-signaling/src/definition/logger.ts (1)
  • IMediaSignalLogger (1-6)
packages/media-signaling/src/lib/services/webrtc/Stream.ts (1)
packages/media-signaling/src/definition/logger.ts (1)
  • IMediaSignalLogger (1-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: CodeQL-Build
  • GitHub Check: CodeQL-Build

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
packages/media-signaling/src/lib/services/webrtc/Processor.ts (4)

318-320: Sanitize ICE candidate logging and avoid counting null end-of-candidates.

Avoid logging IPs/ports; also don’t increment on null candidates.

Apply this diff:

-		this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', event.candidate);
-		this.iceCandidateCount++;
+		const c = event.candidate;
+		this.config.logger?.debug('MediaCallWebRTCProcessor.onIceCandidate', c ? { protocol: c.protocol, sdpMLineIndex: c.sdpMLineIndex } : null);
+		if (c) {
+			this.iceCandidateCount++;
+		}

100-107: Reset ICE wait state on non-restart offers too.

Without startNewNegotiation() when iceRestart is false, iceGatheringFinished may remain true and skip waiting for ICE.

Apply this diff:

 		if (iceRestart) {
 			this.restartIce();
+		} else {
+			this.startNewNegotiation();
 		}
 
 		const offer = await this.peer.createOffer();

222-235: Don’t fail on ICE timeout; proceed with available candidates.

Let offers/answers continue with partial ICE; surface timeout via state instead of throwing.

Apply this diff:

-		await this.waitForIceGathering();
+		try {
+			await this.waitForIceGathering();
+		} catch (e) {
+			if ((e as Error).message !== 'timeout') {
+				throw e;
+			}
+			// Continue with partial/no candidates; state already reflects timeout.
+		}

396-400: Initialize flag only after successful track setup.

Setting localMediaStreamInitialized = true before await setTrack(...) can leave a false-positive initialized state if it throws.

Apply this diff:

 	private async loadInputTrack(): Promise<void> {
 		this.config.logger?.debug('MediaCallWebRTCProcessor.loadInputTrack');
-		this.localMediaStreamInitialized = true;
-		await this.localStream.setTrack(this.inputTrack);
+		await this.localStream.setTrack(this.inputTrack);
+		this.localMediaStreamInitialized = true;
 	}
packages/media-signaling/src/lib/Call.ts (1)

671-676: Emit the new client state (not the old one).

Event consumers expect the current state; emitting oldClientState is misleading.

Apply this diff:

 		this.updateStateTimeouts();
 		this.requestStateReport();
 		this.oldClientState = clientState;
-		this.emitter.emit('clientStateChange', oldClientState);
+		this.emitter.emit('clientStateChange', clientState);
🧹 Nitpick comments (4)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1)

27-42: Stabilize audio sender lookup across replaceTrack(null) to avoid extra transceivers.

After you start detaching with replaceTrack(null), peer.getSenders().find(s => s.track?.kind === 'audio') won’t find the sender and you’ll create new transceivers. Persist and reuse the known audio transceiver/sender.

Within this method, prefer the known sender and fall back to discovery; persist the transceiver when first created:

-		const sender = this.peer.getSenders().find((sender) => sender.track?.kind === 'audio');
+		const knownSender = this.transceiver?.sender ?? null;
+		let sender = knownSender ?? this.peer.getSenders().find((s) => s.track?.kind === 'audio') ?? null;
 		if (!sender) {
 			if (newTrack) {
 				this.logger?.debug('LocalStream.setRemoteTrack.addTrack');
 				// This will require a re-negotiation
-				this.peer.addTrack(newTrack, this.mediaStream);
+				const added = this.peer.addTrack(newTrack, this.mediaStream);
+				this.transceiver = this.peer.getTransceivers().find((t) => t.sender === added) ?? this.transceiver;
 			}
 			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);
+		this.transceiver ??= this.peer.getTransceivers().find((t) => t.sender === sender) ?? null;

And add this field to the class:

private transceiver: RTCRtpTransceiver | null = null;
packages/media-signaling/src/lib/Call.ts (3)

709-712: Clarify targeting vs signature error in logs.

This branch fires when the signal isn’t targeted to this session; it’s not an “unsigned offer request”. Downgrade to debug or fix the message.

-			this.config.logger?.error('Received an unsigned offer request.');
+			this.config.logger?.debug('Offer request not targeted to this session. Ignoring.');

1100-1106: Verify contract/session id used for signal targeting.

Server signals use toContractId. This compares against config.sessionId, but Transport uses transporter.contractId. Mismatch will drop valid signals.

Proposed change:

-		if (signal.toContractId) {
-			return signal.toContractId === this.config.sessionId;
-		}
+		if (signal.toContractId) {
+			return signal.toContractId === this.config.transporter.contractId;
+		}

If sessionId is indeed the same value, please confirm and add a doc comment tying both together.


645-647: Confirm event payload semantics for stateChange.

Docs say “with the old state as param”. If that’s intentional, fine; otherwise switch to new state for consistency with clientStateChange.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between e95286b and f9abc9f.

📒 Files selected for processing (5)
  • packages/media-signaling/src/lib/Call.ts (1 hunks)
  • packages/media-signaling/src/lib/Session.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/Processor.ts (1 hunks)
  • packages/media-signaling/src/lib/services/webrtc/Stream.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/media-signaling/src/lib/services/webrtc/Stream.ts
  • packages/media-signaling/src/lib/Session.ts
🧰 Additional context used
🧬 Code graph analysis (3)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1)
packages/media-signaling/src/lib/services/webrtc/Stream.ts (1)
  • Stream (3-72)
packages/media-signaling/src/lib/services/webrtc/Processor.ts (6)
packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts (4)
  • IWebRTCProcessor (17-34)
  • WebRTCProcessorEvents (15-15)
  • WebRTCProcessorConfig (36-42)
  • WebRTCInternalStateMap (7-13)
packages/media-signaling/src/lib/services/webrtc/LocalStream.ts (1)
  • LocalStream (3-43)
packages/media-signaling/src/lib/services/webrtc/RemoteStream.ts (1)
  • RemoteStream (3-7)
packages/media-signaling/src/lib/utils/getExternalWaiter.ts (2)
  • PromiseWaiterData (1-7)
  • getExternalWaiter (15-70)
packages/media-signaling/src/lib/Call.ts (2)
  • muted (95-101)
  • held (104-110)
packages/media-signaling/src/definition/services/IServiceProcessor.ts (1)
  • ServiceStateValue (9-12)
packages/media-signaling/src/lib/Call.ts (12)
packages/media-signaling/src/definition/logger.ts (1)
  • IMediaSignalLogger (1-6)
packages/media-signaling/src/lib/TransportWrapper.ts (2)
  • MediaSignalTransportWrapper (12-53)
  • answer (37-39)
packages/media-signaling/src/definition/services/IServiceProcessorFactoryList.ts (1)
  • IServiceProcessorFactoryList (3-5)
packages/media-signaling/src/definition/client.ts (2)
  • ClientState (1-13)
  • ClientContractState (15-20)
packages/media-signaling/src/definition/call/CallEvents.ts (1)
  • CallEvents (4-28)
packages/media-signaling/src/definition/services/webrtc/IWebRTCProcessor.ts (2)
  • IWebRTCProcessor (17-34)
  • WebRTCInternalStateMap (7-13)
packages/media-signaling/src/definition/signals/server/MediaSignal.ts (1)
  • ServerMediaSignal (7-12)
packages/media-signaling/src/definition/signals/server/remote-sdp.ts (1)
  • ServerMediaSignalRemoteSDP (2-9)
packages/media-signaling/src/definition/signals/server/new.ts (1)
  • ServerMediaSignalNewCall (4-18)
packages/media-signaling/src/lib/services/states.ts (1)
  • isPendingState (4-6)
packages/media-signaling/src/definition/signals/server/request-offer.ts (1)
  • ServerMediaSignalRequestOffer (2-8)
packages/media-signaling/src/definition/signals/client/error.ts (1)
  • ClientMediaSignalError (4-12)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: 📦 Build Packages
  • GitHub Check: CodeQL-Build
  • GitHub Check: CodeQL-Build
  • GitHub Check: CodeQL-Build

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/models/src/models/MediaCalls.ts (1)

40-53: Caller-scoped lookup fixes the prior auth confusion.

Looks good and addresses the earlier $or bypass risk by scoping to caller.

🧹 Nitpick comments (6)
packages/models/src/models/MediaCalls.ts (6)

29-37: Add an index for expiry scans; consider uniqueness for callerRequestedId.

  • findAllExpiredCalls filters by ended + expiresAt but current compound index is ended,uids,expiresAt. Without uids equality, the query won’t leverage the expiresAt range. Add a supporting index.
  • If semantics require a single callerRequestedId per caller, make that composite sparse index unique to prevent ambiguous matches.

Apply:

 			{
-				key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 },
-				sparse: true,
+				key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 },
+				unique: true, // enforce one requestedId per caller
+				sparse: true,
 			},
+			// Supports ended + expiresAt range scans used by findAllExpiredCalls
+			{ key: { ended: 1, expiresAt: 1 }, unique: false },

If uniqueness is not desired, omit the unique flag; otherwise confirm no existing duplicates before rollout.


63-71: Gate ringing transition on not-ended.

Prevent state changes on already-ended calls.

 		return this.updateOne(
 			{
 				_id: callId,
-				state: 'none',
+				state: 'none',
+				ended: false,
 			},
 			{ $set: { state: 'ringing', expiresAt } },
 		);

73-89: Accept transition should also ensure not-ended; verify callee.contractId schema.

  • Add ended: false to the filter for robustness.
  • Confirm MediaCallContactInformation defines contractId to avoid schema drift.
 			{
 				_id: callId,
 				state: { $in: ['none', 'ringing'] },
+				ended: false,
 			},
 			{
 				$set: {
-					'state': 'accepted',
+					state: 'accepted',
 					'callee.contractId': calleeContractId,
 					expiresAt,
 				},
 			},

91-104: Activate transition should ensure not-ended.

Add ended: false to avoid re-activating a call that raced to hangup/expire.

 			{
 				_id: callId,
 				state: 'accepted',
+				ended: false,
 			},

138-157: Block transfers on ended calls.

Guard on ended: false alongside state and transferredAt checks.

 			{
 				_id: callId,
-				state: {
-					$in: ['accepted', 'active'],
-				},
-				transferredAt: {
-					$exists: false,
-				},
+				state: { $in: ['accepted', 'active'] },
+				ended: false,
+				transferredAt: { $exists: false },
 			},

159-169: This query will benefit from ended+expiresAt index.

As written, it can’t use the expiresAt range efficiently with the current ended,uids,expiresAt index. The additional { ended: 1, expiresAt: 1 } index proposed above will optimize this path.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f9abc9f and 1973027.

📒 Files selected for processing (3)
  • ee/packages/media-calls/src/internal/InternalCallProvider.ts (1 hunks)
  • packages/model-typings/src/models/IMediaCallsModel.ts (1 hunks)
  • packages/models/src/models/MediaCalls.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • ee/packages/media-calls/src/internal/InternalCallProvider.ts
  • packages/model-typings/src/models/IMediaCallsModel.ts
🧰 Additional context used
🧬 Code graph analysis (1)
packages/models/src/models/MediaCalls.ts (3)
packages/core-typings/src/mediaCalls/IMediaCall.ts (4)
  • IMediaCall (35-63)
  • MediaCallActorType (5-5)
  • MediaCallSignedActor (15-15)
  • MediaCallContact (28-28)
packages/model-typings/src/models/IMediaCallsModel.ts (1)
  • IMediaCallsModel (6-22)
packages/core-typings/src/IUser.ts (1)
  • IUser (186-252)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: 📦 Build Packages
  • GitHub Check: CodeQL-Build
  • GitHub Check: CodeQL-Build
  • GitHub Check: CodeQL-Build
🔇 Additional comments (1)
packages/models/src/models/MediaCalls.ts (1)

24-27: LGTM on scaffolding and straightforward helpers.

  • Class/constructor and updateOneById are clean.
  • hangupCallById and setExpiresAtById guard on ended as expected.
  • findAllNotOverByUid and the boolean checks use efficient filters with count limit 1.

Also applies to: 55-61, 106-136, 171-199

Copy link
Contributor

@tassoevan tassoevan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tassoevan tassoevan added this to the 7.11.0 milestone Sep 18, 2025
Copy link
Member

@KevLehman KevLehman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still finishing, halfway done lol

@pierre-lehnen-rc pierre-lehnen-rc removed the request for review from sampaiodiego September 19, 2025 17:23
@pierre-lehnen-rc pierre-lehnen-rc added the stat: QA assured Means it has been tested and approved by a company insider label Sep 19, 2025
@dionisio-bot dionisio-bot bot added the stat: ready to merge PR tested and approved waiting for merge label Sep 19, 2025
@scuciatto scuciatto modified the milestone: 7.11.0 Sep 22, 2025
@dionisio-bot dionisio-bot bot added stat: ready to merge PR tested and approved waiting for merge and removed stat: ready to merge PR tested and approved waiting for merge labels Sep 22, 2025
@ggazzo ggazzo merged commit 75b9402 into develop Sep 22, 2025
110 of 114 checks passed
@ggazzo ggazzo deleted the feat/media-calls branch September 22, 2025 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stat: QA assured Means it has been tested and approved by a company insider stat: ready to merge PR tested and approved waiting for merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.