Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
58 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
9d42d28
regression: populate permission to new federated-external role (#39854)
sampaiodiego Mar 27, 2026
f831593
regression: Video recorder popup keep displaying while file is upload…
dougfabris Mar 27, 2026
1b4dbed
Release 8.3.0-rc.2
rocketchat-github-ci Mar 28, 2026
df94285
regression: Voice call "Share screen" action fails silently (#39872)
gabriellsh Mar 30, 2026
a28b469
regression: Register username flicker (#39982)
yash-rajpal Mar 30, 2026
a376d44
regression: require confirmation before adding a banned user back to …
ggazzo Mar 30, 2026
2b7ee36
fix(federation): revoke invite when banning a user who has not yet ac…
ggazzo Mar 30, 2026
dd52a22
Release 8.3.0-rc.3
rocketchat-github-ci Mar 30, 2026
4bce595
test(federation): add tests for re-inviting RC users after leaving or…
sampaiodiego Mar 31, 2026
413d925
regression: Omnichannel `inquiries.take` endpoint returning `[error]`…
KevLehman Apr 2, 2026
7de4d06
regression: Sending thread message scrolls main channel to bottom (#4…
Apr 2, 2026
fcf9bd4
regression: enable REST type coercion back (#40035)
sampaiodiego Apr 2, 2026
89e4693
regression: Allow ban actions for non-federated rooms as well (#39987)
Apr 2, 2026
aad2007
Release 8.3.0-rc.4
rocketchat-github-ci Apr 2, 2026
788c161
feat: Remove Federation from Beta (#40006)
Apr 7, 2026
f214d87
Release 8.3.0
ggazzo Apr 7, 2026
00a1efa
Release 8.3.0
rocketchat-github-ci Apr 7, 2026
2031ba8
test: Reorg flextabs locators (#38533)
dougfabris Apr 7, 2026
58fc21c
ci: upgrade actions/cache to v5 (#40064)
ggazzo Apr 7, 2026
a23c43b
fix: delete all users subscriptions on re-join after being banned (#4…
sampaiodiego Apr 7, 2026
dd55029
test(federation): reorganize display name change tests for clarity an…
sampaiodiego Apr 7, 2026
b0f026b
ci: improve cache keys, observability, and node_modules caching strat…
ggazzo Apr 7, 2026
bb97a68
chore(deps): bump actions/setup-node from 6.2.0 to 6.3.0 (#39319)
dependabot[bot] Apr 7, 2026
df43b79
chore(deps): bump docker/login-action from 3 to 4 (#39355)
dependabot[bot] Apr 7, 2026
25e988b
chore(deps): bump codecov/codecov-action from 5 to 6 (#39895)
dependabot[bot] Apr 8, 2026
2cebe41
chore(deps): bump @xmldom/xmldom from 0.8.10 to 0.8.12 in /.github/ac…
dependabot[bot] Apr 8, 2026
3340757
Merge remote-tracking branch 'origin/master' into develop
ggazzo Apr 8, 2026
f21ed3a
fix: require unban before using inviting link (#40087)
ggazzo Apr 8, 2026
46faa43
chore: update Meteor and package versions to latest stable releases 3…
ggazzo Apr 8, 2026
c7744b1
feat: auto-wrap selected text in composer with matching delimiters (#…
Copilot Apr 8, 2026
a298a27
chore: Enable Meteor's *Modern Build Stack* (#37614)
ggazzo Apr 8, 2026
84754bb
fix: slack importer not importing messages correctly (#40053)
nazabucciarelli Apr 8, 2026
93e3193
chore: remove outdated oplog error message from admin workspace page …
abhinavkrin Apr 9, 2026
b490ed5
test(federation): fix display name test flakyness (#40085)
sampaiodiego Apr 9, 2026
85f001c
ci: Update cache key for packages-build in CI workflow (#40101)
ggazzo Apr 9, 2026
a6c863a
fix: engagement dashboard becoming unresponsive when clicking "See on…
abhinavkrin Apr 9, 2026
eb78ae4
fix: DDP method calls returning 404 for void methods (#40057)
abhinavkrin Apr 9, 2026
9875f67
chore: add helper descriptions to Auto Away user preference settings …
ricardogarim Apr 9, 2026
5052af2
chore: migrate client-side code from moment to date-fns (#40076)
ggazzo Apr 9, 2026
0f8350d
chore: update deno.lock and freeze install during build (#40038)
d-gubert Apr 9, 2026
cd79677
fix(a11y): Add inline error for UserAvatarEditor's URL input (#36656)
ergot-rp Apr 9, 2026
dd56c98
test: Enforce exact match for button locators in team modal (#40104)
dougfabris Apr 10, 2026
18a26c8
regression: fix multiple compounding keys in sequence (#40108)
ggazzo Apr 10, 2026
619ec70
perf: slim react-aria and react-stately barrel imports (-489 KB)
ggazzo Apr 10, 2026
fee3902
perf: patch zod barrel to remove unused locales (-144 KB)
ggazzo Apr 10, 2026
bf45c37
chore: slim barrel imports for react-aria, react-stately and zod (-79…
ggazzo Apr 10, 2026
68927b8
chore: slim @react-aria/i18n barrel and replace lodash import (-110 K…
ggazzo Apr 10, 2026
9760d84
refactor: Remove unused `description` from attachments render (#40067)
dougfabris Apr 10, 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
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
1 change: 1 addition & 0 deletions apps/meteor/app/api/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import './v1/integrations';
import './v1/invites';
import './v1/import';
import './v1/ldap';
import './v1/media-calls';
import './v1/misc';
import './v1/permissions';
import './v1/presence';
Expand Down
99 changes: 99 additions & 0 deletions apps/meteor/app/api/server/v1/media-calls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { MediaCall } from '@rocket.chat/core-services';
import type { IMediaCall } from '@rocket.chat/core-typings';
import type { CallAnswer, CallFeature } from '@rocket.chat/media-signaling';
import { callFeatureList, callAnswerList } from '@rocket.chat/media-signaling';
import {
ajv,
validateNotFoundErrorResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';
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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment thread
KevLehman marked this conversation as resolved.
},
},
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.',
nullable: true,
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['call', 'success'],
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 {}
}
44 changes: 40 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,8 @@ import type {
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { callServer, type IMediaCallServerSettings } from '@rocket.chat/media-calls';
import { isClientMediaSignal, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling';
import type { ClientMediaSignal, ServerMediaSignal, 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';
import { getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';
Expand Down Expand Up @@ -39,21 +40,56 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
this.configureMediaCallServer();
}

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

const call = await MediaCalls.findOneById<Pick<IMediaCall, '_id' | 'callee' | 'acceptedAt'>>(callId, {
projection: { callee: 1, acceptedAt: 1 },
});
if (!call || call.callee.type !== 'user' || call.callee.id !== uid) {
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
Outdated
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');
}

if (updatedCall.callee.contractId !== signal.contractId) {
if (updatedCall.callee.contractId) {
throw new Error('invalid-call-state');
}
throw new Error('internal-error');
}

return updatedCall;
}
Comment thread
pierre-lehnen-rc marked this conversation as resolved.

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 { 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 @@ -41,7 +41,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;
};
19 changes: 15 additions & 4 deletions ee/packages/media-calls/src/internal/SignalProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
} from '@rocket.chat/media-signaling';
import { MediaCalls } from '@rocket.chat/models';

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 @@ -29,7 +29,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 @@ -38,7 +38,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 @@ -55,6 +55,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 @@ -90,6 +91,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 @@ -99,7 +103,14 @@ 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);
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 @@ -20,6 +20,7 @@ import type {
import { MediaCallChannels, MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';

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 @@ -58,6 +59,8 @@ export class UserActorSignalProcessor {

public readonly ignored: boolean;

private throwIfSkipped: boolean;

constructor(
protected readonly agent: IMediaCallAgent,
protected readonly call: IMediaCall,
Expand All @@ -67,6 +70,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 @@ -80,7 +84,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 @@ -90,6 +94,8 @@ export class UserActorSignalProcessor {
});
}

this.throwIfSkipped = options.throwIfSkipped || false;

// 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 @@ -290,13 +296,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 @@ -309,13 +313,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 @@ -338,6 +340,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