Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
96051a1
feat: allow clients to accept calls before the session is established
pierre-lehnen-rc Feb 12, 2026
a19f3c5
Merge branch 'develop' into feat/accept-media-call-via-rest
pierre-lehnen-rc Feb 13, 2026
7efd36a
cleanup
pierre-lehnen-rc Feb 16, 2026
7f15e37
use query to validate callee
pierre-lehnen-rc Feb 26, 2026
57d5163
changeset
pierre-lehnen-rc Feb 26, 2026
6c3ae5d
handle ack invalid state
pierre-lehnen-rc Mar 16, 2026
bbd36e2
handle reject validations
pierre-lehnen-rc Mar 16, 2026
bb0aed3
Merge branch 'develop' into feat/accept-media-call-via-rest
pierre-lehnen-rc Mar 16, 2026
3a3e7e9
feat: new endpoint to get the current media state from the server
pierre-lehnen-rc Apr 8, 2026
bbd6e94
split the endpoint in two
pierre-lehnen-rc Apr 9, 2026
821d79e
add param to not load initial state signals via websocket
pierre-lehnen-rc Apr 9, 2026
6c22bb4
ensure that potentially repeated signals won't revert the call state
pierre-lehnen-rc Apr 9, 2026
181a3ca
imported registration changes from the voip push PR
pierre-lehnen-rc Apr 10, 2026
ecd12e7
add changeset
pierre-lehnen-rc Apr 13, 2026
d9bb207
applying changes from code review
pierre-lehnen-rc Apr 13, 2026
e977ad2
fix type
pierre-lehnen-rc Apr 13, 2026
71cd7c6
Merge branch 'develop' into feat/accept-media-call-via-rest
pierre-lehnen-rc Apr 13, 2026
826d518
Merge branch 'feat/voip-rest-state' into feat/accept-media-call-via-rest
pierre-lehnen-rc Apr 13, 2026
00062af
Merge branch 'develop' into feat/voip-rest-state
pierre-lehnen-rc Apr 13, 2026
15732c3
Merge remote-tracking branch 'origin/feat/voip-rest-state' into feat/…
pierre-lehnen-rc Apr 13, 2026
10fad95
Merge branch 'develop' into feat/accept-media-call-via-rest
pierre-lehnen-rc Apr 14, 2026
c8d2aa7
missing param
pierre-lehnen-rc Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fair-lions-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@rocket.chat/media-calls': minor
'@rocket.chat/core-services': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/models': minor
'@rocket.chat/meteor': minor
'@rocket.chat/media-signaling': minor
---

Adds a new REST endpoint to accept or reject media calls without an active media session
86 changes: 85 additions & 1 deletion apps/meteor/app/api/server/v1/media-calls.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MediaCall } from '@rocket.chat/core-services';
import type { IMediaCall } from '@rocket.chat/core-typings';
import type { ServerMediaCallSignal } from '@rocket.chat/media-signaling';
import type { CallAnswer, CallFeature, ServerMediaCallSignal } from '@rocket.chat/media-signaling';
import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling';
import { MediaCalls } from '@rocket.chat/models';
import {
ajv,
Expand All @@ -14,6 +15,89 @@ import type { JSONSchemaType } from 'ajv';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';

type MediaCallsAnswer = {
callId: string;
contractId: string;

answer: CallAnswer;

supportedFeatures?: CallFeature[];
};

const MediaCallsAnswerSchema: JSONSchemaType<MediaCallsAnswer> = {
type: 'object',
properties: {
callId: {
type: 'string',
},
contractId: {
type: 'string',
},
answer: {
type: 'string',
enum: callAnswerList,
},
supportedFeatures: {
type: 'array',
items: {
type: 'string',
enum: callFeatureList,
},
nullable: true,
},
},
required: ['callId', 'contractId', 'answer'],
additionalProperties: false,
};

export const isMediaCallsAnswerProps = ajv.compile<MediaCallsAnswer>(MediaCallsAnswerSchema);

const mediaCallsAnswerEndpoints = API.v1.post(
'media-calls.answer',
{
response: {
200: ajv.compile<{
call: IMediaCall;
}>({
additionalProperties: false,
type: 'object',
properties: {
call: {
type: 'object',
$ref: '#/components/schemas/IMediaCall',
description: 'The updated call information.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['call', 'success'],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
404: validateNotFoundErrorResponse,
},
body: isMediaCallsAnswerProps,
authRequired: true,
},
async function action() {
const call = await MediaCall.answerCall(this.userId, this.bodyParams);

return API.v1.success({
call,
});
},
);

type MediaCallsAnswerEndpoints = ExtractRoutesFromAPI<typeof mediaCallsAnswerEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends MediaCallsAnswerEndpoints {}
}

type MediaCallsStateSignalsParams = {
contractId: string;
};
Expand Down
65 changes: 61 additions & 4 deletions apps/meteor/server/services/media-call/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import type {
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
import type { CallFeature, ClientMediaSignal, ServerMediaSignal, ServerMediaCallSignal } from '@rocket.chat/media-signaling';
import type {
CallFeature,
ClientMediaSignal,
ServerMediaSignal,
ServerMediaCallSignal,
ClientMediaSignalAnswer,
} from '@rocket.chat/media-signaling';
import { isClientMediaSignal } from '@rocket.chat/media-signaling';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
Expand Down Expand Up @@ -41,21 +47,72 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
this.configureMediaCallServer();
}

public async answerCall(uid: IUser['_id'], params: Omit<ClientMediaSignalAnswer, 'type'>): Promise<IMediaCall> {
const { callId, answer } = params;

const call = await MediaCalls.findOneByIdAndCallee<Pick<IMediaCall, '_id'>>(
callId,
{ type: 'user', id: uid },
{ projection: { _id: 1 } },
);
if (!call) {
throw new Error('not-found');
}

const signal: ClientMediaSignalAnswer = {
type: 'answer',
...params,
};

await callServer.receiveSignal(uid, signal, { throwIfSkipped: true });

const updatedCall = await MediaCalls.findOneById(callId);
if (!updatedCall) {
throw new Error('internal-error');
}

switch (answer) {
case 'ack':
if (updatedCall.acceptedAt || updatedCall.ended) {
throw new Error('invalid-call-state');
}
break;
case 'reject':
if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) {
throw new Error('invalid-call-state');
}
break;
case 'accept':
if (updatedCall.callee.contractId !== signal.contractId) {
if (updatedCall.callee.contractId) {
throw new Error('invalid-call-state');
}
throw new Error('internal-error');
}
break;
}

return updatedCall;
}

public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise<void> {
try {
callServer.receiveSignal(uid, signal);
await callServer.receiveSignal(uid, signal);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, signal, uid });
}
}

public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise<void> {
let signalType: string | null = null;

try {
const deserialized = await this.deserializeClientSignal(signal);
signalType = deserialized.type;

callServer.receiveSignal(uid, deserialized);
await callServer.receiveSignal(uid, deserialized);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, uid });
logger.error({ msg: 'failed to process client signal', err, uid, type: signalType });
}
}

Expand Down
4 changes: 2 additions & 2 deletions ee/packages/media-calls/src/definition/IMediaCallServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings';
import type { Emitter } from '@rocket.chat/emitter';
import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling';

import type { InternalCallParams } from './common';
import type { InternalCallParams, SignalProcessingOptions } from './common';

export type MediaCallServerEvents = {
callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> };
Expand Down Expand Up @@ -42,7 +42,7 @@ export interface IMediaCallServer {
updateCallHistory(params: { callId: string }): void;

// functions that are run on events
receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal): void;
receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise<void>;
receiveCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void;

// extra functions available to the service
Expand Down
6 changes: 6 additions & 0 deletions ee/packages/media-calls/src/definition/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ export class CallRejectedError extends Error {
super(message || 'call-rejected');
}
}

export type SignalProcessingOptions = {
// Some signals can be safely skipped when they are not relevant to the current call state, but
// if the signal was received via REST, we shouldn't return success without processing anything, so we throw an error instead
throwIfSkipped?: boolean;
};
21 changes: 16 additions & 5 deletions ee/packages/media-calls/src/internal/SignalProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
import { MediaCalls } from '@rocket.chat/models';

import { DEFAULT_CALL_FEATURES } from '../constants';
import type { InternalCallParams } from '../definition/common';
import type { InternalCallParams, SignalProcessingOptions } from '../definition/common';
import { logger } from '../logger';
import { mediaCallDirector } from '../server/CallDirector';
import { UserActorAgent } from './agents/UserActorAgent';
Expand All @@ -32,7 +32,7 @@ export class GlobalSignalProcessor {
this.emitter = new Emitter();
}

public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise<void> {
public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions): Promise<void> {
switch (signal.type) {
case 'register':
return this.processRegisterSignal(uid, signal);
Expand All @@ -41,7 +41,7 @@ export class GlobalSignalProcessor {
}

if ('callId' in signal) {
return this.processCallSignal(uid, signal);
return this.processCallSignal(uid, signal, options);
}

logger.error({ msg: 'Unrecognized media signal', signal: stripSensitiveDataFromSignal(signal) });
Expand All @@ -58,6 +58,7 @@ export class GlobalSignalProcessor {
private async processCallSignal(
uid: IUser['_id'],
signal: Exclude<ClientMediaSignal, ClientMediaSignalRegister | ClientMediaSignalRequestCall>,
{ throwIfSkipped }: SignalProcessingOptions,
): Promise<void> {
try {
const call = await MediaCalls.findOneById(signal.callId);
Expand Down Expand Up @@ -93,6 +94,9 @@ export class GlobalSignalProcessor {

// Ignore signals from different sessions if the actor is already signed
if (!skipContractCheck && callActor.contractId && callActor.contractId !== signal.contractId) {
if (throwIfSkipped) {
throw new Error('invalid-contract');
}
return;
}

Expand All @@ -102,10 +106,17 @@ export class GlobalSignalProcessor {
const { [role]: agent } = agents;

if (!(agent instanceof UserActorAgent)) {
throw new Error('Actor agent is not prepared to process signals');
logger.error({
msg: 'Actor agent is not prepared to process signals',
method: 'processSignal',
signal: stripSensitiveDataFromSignal(signal),
isCaller,
isCallee,
});
throw new Error('internal-error');
}

await agent.processSignal(call, signal);
await agent.processSignal(call, signal, { throwIfSkipped });
} catch (e) {
logger.error({ err: e });
throw e;
Expand Down
38 changes: 29 additions & 9 deletions ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.ch

import { DEFAULT_CALL_FEATURES } from '../../constants';
import type { IMediaCallAgent } from '../../definition/IMediaCallAgent';
import type { SignalProcessingOptions } from '../../definition/common';
import { logger } from '../../logger';
import { mediaCallDirector } from '../../server/CallDirector';
import { getMediaCallServer } from '../../server/injection';
Expand Down Expand Up @@ -60,6 +61,8 @@ export class UserActorSignalProcessor {

public readonly ignored: boolean;

private throwIfSkipped: boolean;

constructor(
protected readonly agent: IMediaCallAgent,
protected readonly call: IMediaCall,
Expand All @@ -69,6 +72,7 @@ export class UserActorSignalProcessor {

this.signed = Boolean(actor.contractId && actor.contractId === channel.contractId);
this.ignored = Boolean(actor.contractId && actor.contractId !== channel.contractId);
this.throwIfSkipped = false;
}

public async requestWebRTCOffer(params: { negotiationId: string }): Promise<void> {
Expand All @@ -82,7 +86,7 @@ export class UserActorSignalProcessor {
});
}

public async processSignal(signal: ClientMediaSignal): Promise<void> {
public async processSignal(signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise<void> {
if (signal.type !== 'local-state') {
logger.debug({
msg: 'UserActorSignalProcessor.processSignal',
Expand All @@ -92,6 +96,8 @@ export class UserActorSignalProcessor {
});
}

this.throwIfSkipped = options.throwIfSkipped || false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

it is not smart changing the state value of throwIfSkipped on every processSignal call, this could in theory affect async calls after multiple calls to processSignal.


// 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
Expand Down Expand Up @@ -296,13 +302,11 @@ export class UserActorSignalProcessor {
}

protected async clientHasRejected(): Promise<void> {
if (!this.isCallPending()) {
if (!this.validatePendingCallee()) {
return;
}

if (this.role === 'callee') {
return mediaCallDirector.hangup(this.call, this.agent, 'rejected');
}
return mediaCallDirector.hangup(this.call, this.agent, 'rejected');
}

protected async clientIsUnavailable(): Promise<void> {
Expand All @@ -315,13 +319,11 @@ export class UserActorSignalProcessor {
}

protected async clientHasAccepted(supportedFeatures: CallFeature[]): Promise<void> {
if (!this.isCallPending()) {
if (!this.validatePendingCallee()) {
return;
}

if (this.role === 'callee') {
await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures });
}
await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures });
}

protected async clientIsActive(): Promise<void> {
Expand All @@ -344,6 +346,24 @@ export class UserActorSignalProcessor {
return ['active', 'hangup'].includes(this.call.state);
}

protected validatePendingCallee(): boolean {
if (this.role !== 'callee') {
if (this.throwIfSkipped) {
throw new Error('invalid-call-role');
}
return false;
}

if (!this.isCallPending()) {
if (this.throwIfSkipped) {
throw new Error('invalid-call-state');
}
return false;
}

return true;
}

private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise<void> {
if (!this.signed) {
return;
Expand Down
Loading
Loading