Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
94cf883
docs: add apps-engine-event-result-return-type.md proposal
d-gubert Jul 29, 2026
4c8917d
docs: add apps-media-call-analysis.md to proposals
d-gubert Jul 29, 2026
1529162
docs: media call events prototypes
d-gubert Jul 29, 2026
14abc5a
feat(apps-engine): new EventResult type
d-gubert Aug 3, 2026
35a076f
feat(apps-engine): type definition for media call events
d-gubert Aug 3, 2026
e1407bd
fix: lint
d-gubert Aug 3, 2026
ad5cd6b
feat(apps-engine): media call types improvementss
d-gubert Aug 3, 2026
d87ddf6
feat(apps): hook up media call events from apps-engine
d-gubert Aug 3, 2026
8065fc0
update proposal
d-gubert Aug 3, 2026
019e647
fix(media-calls): missing contact information on a call's createdBy
d-gubert Aug 4, 2026
f3ef26a
test: e2e suite
d-gubert Aug 4, 2026
98b8068
test: unit coverage for media call app events
d-gubert Aug 6, 2026
adab109
chore: add changeset for media call app events
d-gubert Aug 6, 2026
c9ac5d2
docs: update event result proposal
d-gubert Aug 11, 2026
bbe106f
chore: remove checkPreMediaCallCreated from the handler interface
d-gubert Aug 11, 2026
37ee398
docs: SIP loopback calls analysis
d-gubert Aug 12, 2026
7ad3ae1
feat(media-signaling): CallRejectionMessage type
d-gubert Aug 12, 2026
515daaf
feat(media-calls): let the pre-call-created hook explain a rejection
d-gubert Aug 12, 2026
d94680c
feat(apps): keep what an app said about the call it blocked
d-gubert Aug 12, 2026
9a73510
feat(media-calls): carry a rejection message on CallRejectedError
d-gubert Aug 12, 2026
248c9ea
feat(media-calls): send the rejection message to the caller
d-gubert Aug 12, 2026
3d76709
feat(media-signaling): surface call rejections instead of swallowing …
d-gubert Aug 12, 2026
e0500c5
feat(i18n): messages for the reasons a call gets rejected
d-gubert Aug 12, 2026
7c11b5c
feat(ui-voip): tell the caller why their call was rejected
d-gubert Aug 12, 2026
bbc57a4
test: cover the path from a blocked call to the caller's toast
d-gubert Aug 12, 2026
19da711
docs: changeset and analysis update for call rejection feedback
d-gubert Aug 12, 2026
bef99a3
feat(apps-engine): helpers to identify end call reasons
d-gubert Aug 18, 2026
ee99982
chore: adapt to new divertedBy field
d-gubert Aug 18, 2026
ae689c2
feat(apps-engine): add origin field to pre event context
d-gubert Aug 18, 2026
042e973
refactor: enumerate media call invariants declaratively
d-gubert Aug 18, 2026
782fa6a
refactor: simplify dispatching of app events on media-call
d-gubert Aug 18, 2026
a85ed7a
refactor: remove test app source, keep only package and docs
d-gubert Aug 18, 2026
2696b4f
tests: fix e2e tests
d-gubert Aug 18, 2026
6f49be1
docs: moved proposals to adrs and improved conciseness
d-gubert Aug 19, 2026
e37852a
refactor: remove the unused "prompt" event result
d-gubert Aug 19, 2026
d2641b0
fix(e2e): use the existing Widget.hangup() helper
d-gubert Aug 19, 2026
72c6518
fix(apps): notify every media-call listener without serializing them
d-gubert Aug 19, 2026
756f0f9
docs: record that the media-call pre event fails open on a timeout
d-gubert Aug 19, 2026
578276e
tests: fix e2e tests
d-gubert Aug 19, 2026
8067a91
docs: ADR sync pass
d-gubert Aug 19, 2026
b144d52
docs: ADR refinement
d-gubert Aug 19, 2026
cf7bf91
fix: pass the full call object to events instead of just ids to avoid…
d-gubert Aug 19, 2026
24d6675
tests: restore setting value on test teardown
d-gubert Aug 19, 2026
dbd6c52
fix: malformed patch response crashed the event handler
d-gubert Aug 19, 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
8 changes: 8 additions & 0 deletions .changeset/media-call-app-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/apps-engine': minor
'@rocket.chat/media-calls': minor
'@rocket.chat/apps': minor
'@rocket.chat/meteor': minor
---

Adds media call lifecycle events to the Apps-Engine: an app implementing the new `IMediaCallHandler` interface can now observe calls starting, being answered and ending, and can block a call or change the features it was requested with before it is created
6 changes: 6 additions & 0 deletions .changeset/media-call-created-by-contact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/media-calls': patch
'@rocket.chat/meteor': patch
---

Fixes the `createdBy` of a voice call being stored with no contact information on it: every call that was not created by a transfer ended up with a `createdBy` carrying only the requester's id, while the caller and callee carried their username and display name. This also affected the `transferredBy` reported to clients.
9 changes: 9 additions & 0 deletions .changeset/media-call-rejection-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@rocket.chat/media-signaling': minor
'@rocket.chat/media-calls': minor
'@rocket.chat/ui-voip': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Tells the caller why a voice call they placed was rejected, instead of showing the call widget for an instant and nothing else. An app that blocks a call through `IMediaCallHandler` can now have its own message shown to the caller, and rejections the server was already sending — the callee being unavailable, the caller not being allowed to place the call — are explained rather than silent
7 changes: 7 additions & 0 deletions apps/meteor/app/apps/server/bridges/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/e
import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent';
import { LivechatTransferEventType } from '@rocket.chat/apps-engine/definition/livechat';
import { isLivechatRoom } from '@rocket.chat/apps-engine/definition/livechat/ILivechatRoom';
import type { MediaCallEvent } from '@rocket.chat/apps-engine/definition/mediaCalls';
import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata';
import type { UIKitIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit';
import type { IUIKitLivechatIncomingInteraction } from '@rocket.chat/apps-engine/definition/uikit/livechat';
Expand Down Expand Up @@ -167,6 +168,12 @@ type HandleDefaultEvent =
| {
event: AppInterface.IPreEmailSent;
payload: [IPreEmailSentContext];
}
// Media call payloads are already app-shaped when they get here — see
// apps/meteor/server/services/media-call/appEvents.ts
| {
event: AppInterface.IMediaCallHandler;
payload: [MediaCallEvent];
};

type HandleFileUploadEvent = {
Expand Down
270 changes: 270 additions & 0 deletions apps/meteor/server/services/media-call/appEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type {
IAcceptedMediaCall as IAppsAcceptedMediaCall,
IActiveMediaCall as IAppsActiveMediaCall,
IEndedMediaCall as IAppsEndedMediaCall,
IMediaCall as IAppsMediaCall,
IMediaCallActor as IAppsMediaCallActor,
IMediaCallContact as IAppsMediaCallContact,
IPreMediaCallCreatedContext,
MediaCallEvent,
MediaCallOrigin,
PreMediaCallCreatedOutcome,
} from '@rocket.chat/apps-engine/definition/mediaCalls';
import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata';
import type { IMediaCall, MediaCallActor, MediaCallContact, ServerActor } from '@rocket.chat/core-typings';
import type { PreCallCreatedHookParams, PreCallCreatedHookResult } from '@rocket.chat/media-calls';
import { callFeatureList, type CallFeature, type CallRejectionMessage } from '@rocket.chat/media-signaling';

import { logger } from './logger';

/**
* Maps media calls onto the shapes apps see and dispatches the media-call
* lifecycle events to the Apps-Engine.
*
* Every event travels under the single `IMediaCallHandler` interface; the
* `method` on the envelope is what tells the listener manager which of the
* handler's optional methods to call.
*/

/** Contacts carry a per-session signing token, which is a credential: only these fields may reach an app. */
function toAppContact(contact: MediaCallContact): IAppsMediaCallContact {
return {
type: contact.type,
id: contact.id,
...(contact.username && { username: contact.username }),
...(contact.displayName && { displayName: contact.displayName }),
...(contact.sipExtension && { sipExtension: contact.sipExtension }),
};
}

/**
* The two contacts are the origin: a sip caller means the call arrived from the
* PBX, a sip callee means it was placed out through it, and neither means it never
* leaves the workspace. Both contacts are final before any event is built, so
* apps do not have to reimplement the routing rules to tell the cases apart.
*
* A sip/sip pair cannot occur: an external callee requires a user caller, and an
* inbound INVITE requires a user callee.
*/
function getCallOrigin(caller: MediaCallContact, callee: MediaCallContact): MediaCallOrigin {
if (caller.type === 'sip') {
return 'sip-inbound';
}

if (callee.type === 'sip') {
return 'sip-outbound';
}

return 'internal';
}

function toAppActor(actor: MediaCallActor | ServerActor): IAppsMediaCallActor {
return {
type: actor.type,
id: actor.id,
};
}

function toAppMediaCall(call: IMediaCall): IAppsMediaCall {
return {
id: call._id,
service: call.service,
kind: call.kind,
state: call.state,
origin: getCallOrigin(call.caller, call.callee),
createdBy: toAppContact(call.createdBy),
createdAt: call.createdAt,
caller: toAppContact(call.caller),
callee: toAppContact(call.callee),
features: call.features,
uids: call.uids,
ended: call.ended,
...(call.endedAt && { endedAt: call.endedAt }),
...(call.endedBy && { endedBy: toAppActor(call.endedBy) }),
...(call.hangupReason && { hangupReason: call.hangupReason }),
...(call.acceptedAt && { acceptedAt: call.acceptedAt }),
...(call.activatedAt && { activatedAt: call.activatedAt }),
...(call.parentCallId && { parentCallId: call.parentCallId }),
...(call.divertedBy && { divertedBy: toAppContact(call.divertedBy) }),
};
}

/**
* Each post event promises the apps one timestamp on the call it carries. The
* event is dispatched after the write that sets it, so the timestamp is there.
* A call that arrives without it cannot keep the promise, and an app that acts on
* a made-up time is worse off than an app that never hears about the call, so the
* event is dropped instead.
*/
function getEventTimestamp(call: IMediaCall, field: 'activatedAt' | 'acceptedAt' | 'endedAt'): Date | undefined {
if (!call[field]) {
logger.warn({ msg: 'Skipped a media call event for a call that carries no timestamp for it', callId: call._id, field });
}

return call[field];
}

function toAppActiveMediaCall(call: IMediaCall): IAppsActiveMediaCall | undefined {
const activatedAt = getEventTimestamp(call, 'activatedAt');

return activatedAt && { ...toAppMediaCall(call), activatedAt };
}

function toAppAcceptedMediaCall(call: IMediaCall): IAppsAcceptedMediaCall | undefined {
const acceptedAt = getEventTimestamp(call, 'acceptedAt');

return acceptedAt && { ...toAppMediaCall(call), acceptedAt };
}

function toAppEndedMediaCall(call: IMediaCall): IAppsEndedMediaCall | undefined {
const endedAt = getEventTimestamp(call, 'endedAt');

return endedAt && { ...toAppMediaCall(call), ended: true, endedAt };
}

/** `0` for a call that never became active, and never negative. */
function getCallDurationInMs(activatedAt: Date | undefined, endedAt: Date): number {
if (!activatedAt) {
return 0;
}

return Math.max(0, endedAt.valueOf() - activatedAt.valueOf());
}

function isCallFeature(feature: string): feature is CallFeature {
return (callFeatureList as readonly string[]).includes(feature);
}

async function triggerMediaCallEvent(event: MediaCallEvent): Promise<unknown> {
return Apps.self?.triggerEvent(AppEvents.IMediaCallHandler, event);
}

/**
* Every post event is reported from the call as it was when the event happened. The call is never
* read again on the way here: by then it may already have moved on, and an app that is told about
* an accepted call has to be told about the call that was accepted. A workspace with no apps
* skips the work.
*/
export async function notifyAppsOfMediaCallStarted(call: IMediaCall): Promise<void> {
if (!Apps.self) {
return;
}

const activeCall = toAppActiveMediaCall(call);
if (!activeCall) {
// `getEventTimestamp` already logged what the call is missing
return;
}

await triggerMediaCallEvent({ method: AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED, context: { call: activeCall } });
}

export async function notifyAppsOfMediaCallParticipantJoined(call: IMediaCall): Promise<void> {
if (!Apps.self) {
return;
}

// Calls are strictly two-party, so the side that joins is always `call.callee`
const acceptedCall = toAppAcceptedMediaCall(call);
if (!acceptedCall) {
return;
}

await triggerMediaCallEvent({ method: AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED, context: { call: acceptedCall } });
}

export async function notifyAppsOfMediaCallEnded(call: IMediaCall): Promise<void> {
if (!Apps.self) {
return;
}

const endedCall = toAppEndedMediaCall(call);
if (!endedCall) {
return;
}

await triggerMediaCallEvent({
method: AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED,
context: {
call: endedCall,
durationMs: getCallDurationInMs(call.activatedAt, endedCall.endedAt),
},
});
}

/** An app's explanation is shown in a toast, so it can't be allowed to be arbitrarily long. */
const MAX_REJECTION_TEXT_LENGTH = 200;

/**
* Turns what an app said about a call it blocked into something the caller can
* be shown. An app's translations are registered on the client under a namespace
* of its own, so an `i18n` key is only resolvable together with the id of the app
* that produced it.
*/
function toRejectionMessage(outcome: PreMediaCallCreatedOutcome & { prevented: true }): CallRejectionMessage | undefined {
if (outcome.i18n) {
return {
type: 'i18n',
key: outcome.i18n.key,
ns: `app-${outcome.appId}`,
...(outcome.i18n.args && { args: outcome.i18n.args }),
};
}

if (outcome.reason) {
return { type: 'text', text: outcome.reason.slice(0, MAX_REJECTION_TEXT_LENGTH) };
}

return undefined;
}

/**
* Runs the pre-media-call-created event and translates its outcome back into
* something the media call server understands. Apps may block the call or change
* the features it was requested with; anything else they try to patch is dropped
* by the listener manager.
*/
export async function runPreMediaCallCreatedAppHook(params: PreCallCreatedHookParams): Promise<PreCallCreatedHookResult> {
if (!Apps.self) {
return { prevented: false };
}

const context: IPreMediaCallCreatedContext = {
caller: toAppContact(params.caller),
callee: toAppContact(params.callee),
createdBy: toAppContact(params.createdBy),
features: [...params.features],
origin: getCallOrigin(params.caller, params.callee),
...(params.parentCallId && { parentCallId: params.parentCallId }),
...(params.divertedBy && { divertedBy: toAppContact(params.divertedBy) }),
};

const outcome = (await triggerMediaCallEvent({
method: AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED,
context,
})) as PreMediaCallCreatedOutcome | undefined;

if (!outcome) {
return { prevented: false };
}

if (outcome.prevented) {
logger.info({
msg: 'An app prevented a media call from being created',
appId: outcome.appId,
reason: outcome.reason || outcome.i18n?.key,
});

return {
prevented: true,
reason: outcome.reason || outcome.i18n?.key,
message: toRejectionMessage(outcome),
};
}

// Apps are free to ask for features that don't exist; only the known ones move on
const features = outcome.context.features.filter(isCallFeature);

return { prevented: false, features };
}
31 changes: 29 additions & 2 deletions apps/meteor/server/services/media-call/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';

import {
notifyAppsOfMediaCallEnded,
notifyAppsOfMediaCallParticipantJoined,
notifyAppsOfMediaCallStarted,
runPreMediaCallCreatedAppHook,
} from './appEvents';
import { logger } from './logger';
import { sendVoipPushNotification } from './push/sendVoipPushNotification';
import { i18n } from '../../lib/i18n';
Expand All @@ -35,12 +41,18 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
super();
callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal));
callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params));
callServer.emitter.on('callActivated', ({ callId, uids }) => this.setPresenceForUsers(uids, callId));
callServer.emitter.on('callEnded', ({ callId, uids }) => this.clearPresenceForUsers(uids, callId));
callServer.emitter.on('callActivated', ({ call }) => this.setPresenceForUsers(call.uids, call._id));
callServer.emitter.on('callEnded', ({ call }) => this.clearPresenceForUsers(call.uids, call._id));
callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId)));
callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event));
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));

// Apps-Engine media call events
callServer.emitter.on('callAccepted', ({ call }) => this.notifyApps(call, notifyAppsOfMediaCallParticipantJoined));
callServer.emitter.on('callActivated', ({ call }) => this.notifyApps(call, notifyAppsOfMediaCallStarted));
callServer.emitter.on('callEnded', ({ call }) => this.notifyApps(call, notifyAppsOfMediaCallEnded));
callServer.setHooks({ onPreCallCreated: runPreMediaCallCreatedAppHook });

this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
if (setting._id.startsWith('VoIP_TeamCollab_')) {
setImmediate(() => this.configureMediaCallServer());
Expand Down Expand Up @@ -145,6 +157,21 @@ export class MediaCallService extends ServiceClassInternal implements IMediaCall
return signals;
}

/**
* Apps observe calls, they don't take part in them: never let one delay or break call
* signaling. The event carries the call as it was when the event happened, so a notification
* that waits still describes the transition it belongs to.
*
* One call's events reach an app in the order they happened, and nothing here has to arrange
* that: `setImmediate` runs the notifications in the order they were queued, and a notification
* awaits nothing between here and the JSON-RPC request the app receives.
*/
private notifyApps(call: IMediaCall, notify: (call: IMediaCall) => Promise<void>): void {
setImmediate(() => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
notify(call).catch((err) => logger.error({ msg: 'Failed to notify apps about a media call event', err, callId: call._id }));
});
}

private async saveCallToHistory(callId: IMediaCall['_id']): Promise<void> {
logger.info({ msg: 'saving media call to history', callId });

Expand Down
Loading
Loading