diff --git a/.changeset/media-call-app-events.md b/.changeset/media-call-app-events.md new file mode 100644 index 0000000000000..bc9250fce7747 --- /dev/null +++ b/.changeset/media-call-app-events.md @@ -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 diff --git a/.changeset/media-call-created-by-contact.md b/.changeset/media-call-created-by-contact.md new file mode 100644 index 0000000000000..0c504a4ad757e --- /dev/null +++ b/.changeset/media-call-created-by-contact.md @@ -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. diff --git a/.changeset/media-call-rejection-feedback.md b/.changeset/media-call-rejection-feedback.md new file mode 100644 index 0000000000000..92bc60627f2b9 --- /dev/null +++ b/.changeset/media-call-rejection-feedback.md @@ -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 diff --git a/apps/meteor/app/apps/server/bridges/listeners.ts b/apps/meteor/app/apps/server/bridges/listeners.ts index c3731d26be134..f8f7c2e8f9056 100644 --- a/apps/meteor/app/apps/server/bridges/listeners.ts +++ b/apps/meteor/app/apps/server/bridges/listeners.ts @@ -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'; @@ -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 = { diff --git a/apps/meteor/server/services/media-call/appEvents.ts b/apps/meteor/server/services/media-call/appEvents.ts new file mode 100644 index 0000000000000..552b470be897d --- /dev/null +++ b/apps/meteor/server/services/media-call/appEvents.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/apps/meteor/server/services/media-call/service.ts b/apps/meteor/server/services/media-call/service.ts index 3e740e43a36bf..600067826ee2c 100644 --- a/apps/meteor/server/services/media-call/service.ts +++ b/apps/meteor/server/services/media-call/service.ts @@ -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'; @@ -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 => { if (setting._id.startsWith('VoIP_TeamCollab_')) { setImmediate(() => this.configureMediaCallServer()); @@ -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 { + setImmediate(() => { + 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 { logger.info({ msg: 'saving media call to history', callId }); diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index 092b049e3487c..9b166fce3da06 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -19,6 +19,33 @@ describe('My tests', () => { }); ``` +Playwright tests use the equivalent helpers from `tests/e2e/utils/apps.ts` (`installLocalTestPackage`, `uninstallApp`, `getAppLogs`, `findAppLogItem`) instead. + +## How to rebuild a package + +Every package here is pre-built, and its source is in a `
` block below. To change one, copy that +source into a scratch app directory, edit it, and package it again: + +```sh +rc-apps package # @rocket.chat/apps-cli +cp dist/_.zip +``` + +Copy the new source back into the `
` block, and delete the scratch directory. + +Three things to know: + +- The scratch dir **must** be inside this repo. `@rocket.chat/apps-engine` is not installed there; it resolves + upward to the monorepo root `node_modules`, which is a symlink to `packages/apps-engine`. This is what lets + a fixture typecheck against unreleased engine APIs with no install step. +- `@rocket.chat/apps-engine/*` is left **external** in the bundle (see `external:` in + `packages/apps/src/server/runtime/base/bundler.ts`), and the Deno runtime maps it back to + `packages/apps-engine/` (`packages/apps/deno-runtime/deno.jsonc`). So a packaged app runs against the + *server's* engine, not a frozen copy of it — an app can call engine APIs that did not exist when it was + packaged, and the zip does not need rebuilding when those APIs change. +- Keep an app to a single class file. `rc-apps package` bundles the whole app into one file anyway, and one + file is what this document can show. + ### Available apps #### IPreFileUpload handler @@ -667,3 +694,233 @@ export class UiKitRoomTestApp extends App implements IUIKitInteractionHandler { ```
+ +#### Media call lifecycle events (IMediaCallHandler) + +File name: `media-call-events-test_0.0.1.zip` + +An app implementing every method of `IMediaCallHandler`. It records what each handler received in the app +logs, which is how `tests/e2e/apps/media-call-events.spec.ts` asserts the events actually arrived, and it +answers the pre-create event according to a mode the test sets beforehand. + +**Mode endpoint:** + +- `POST /api/apps/public/:appId/mode` with `{ "mode": "pass" | "prevent" | "drop-screen-share" }` +- `GET /api/apps/public/:appId/mode` returns the current mode (defaults to `pass`) + +The mode drives `executePreMediaCallCreated`: + +| Mode | Returns | +| --- | --- | +| `pass` | `EventResult.pass()` | +| `prevent` | `EventResult.prevent({ reason: 'blocked by media-call-events-test' })` | +| `drop-screen-share` | `EventResult.patch({ features })` with `screen-share` removed | + +Driving the outcome from a mode rather than from the callee's username matters: a call that fails because the +callee was unreachable looks identical in the UI to one an app blocked, so the tests need to run the *same* +user pair through both a passing and a prevented call. + +**Log labels** (read with `findAppLogItem`): `pre_created_mode`, `pre_created_caller`, `pre_created_callee`, +`pre_created_created_by`, `pre_created_features`, `pre_created_caller_keys`, `post_started_*`, +`post_joined_*`, `post_ended_*`. `pre_created_caller_keys` / `post_joined_participant_keys` list the keys of +the contact the app received, so a test can assert the per-session signing token (`contractId`) never +crossed into the app. + +A post event's context holds the call and nothing the call already holds, so the app reads the moment of the +event and the participant that joined off the call itself: `post_started_activated_at`, +`post_joined_participant` (`call.callee`), `post_joined_accepted_at` and `post_ended_at`. + +Three of the end-event labels exist to exercise the outcome helpers, since there is no separate event for +a call nobody answered: + +| Label | Value | +| --- | --- | +| `post_ended_outcome` | `answered` \| `rejected` \| `missed`, from `isAnsweredCall` / `isRejectedCall` / `isMissedCall`. `unreachable` would mean the three stopped partitioning every ended call, and the spec asserts it never appears. | +| `post_ended_accepted_at` | Logged **only** inside the `isAnsweredCall` branch, so its presence is the guard firing and its absence is the guard correctly refusing. | +| `post_ended_reason_known` | `isKnownMediaCallHangupReason(context.call.hangupReason)`. A `false` here means `MediaCallHangupReason` has drifted from what the server records. | + +
+App source code + +```typescript +import { App } from '@rocket.chat/apps-engine/definition/App'; +import type { + IAppAccessors, + IConfigurationExtend, + IHttp, + ILogger, + IModify, + IPersistence, + IPersistenceRead, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { HttpStatusCode } from '@rocket.chat/apps-engine/definition/accessors'; +import type { IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { ApiEndpoint, ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { EventResult } from '@rocket.chat/apps-engine/definition/eventResult'; +import { isAnsweredCall, isKnownMediaCallHangupReason, isMissedCall, isRejectedCall } from '@rocket.chat/apps-engine/definition/mediaCalls'; +import type { + IMediaCallContact, + IMediaCallEndedContext, + IMediaCallHandler, + IMediaCallParticipantJoinedContext, + IMediaCallStartedContext, + IPreMediaCallCreatedContext, + MediaCallCreateEventResult, +} from '@rocket.chat/apps-engine/definition/mediaCalls'; +import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { AppMethod, RocketChatAssociationModel, RocketChatAssociationRecord } from '@rocket.chat/apps-engine/definition/metadata'; + +/** + * How the app should answer the next `executePreMediaCallCreated`. Tests set this + * over the `mode` endpoint before driving a call, so a single user pair can be run + * through every outcome instead of encoding the outcome in the callee's username. + */ +type Mode = 'pass' | 'prevent' | 'drop-screen-share'; + +const MODES: Mode[] = ['pass', 'prevent', 'drop-screen-share']; + +const association = new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, 'media-call-events-test-mode'); + +/** + * Exercises every method of `IMediaCallHandler` and records what it saw in the app + * logs, which is how the e2e spec asserts the events actually arrived. + */ +export class MediaCallEventsTestApp extends App implements IMediaCallHandler { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + public async [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]( + context: IPreMediaCallCreatedContext, + read: IRead, + ): Promise { + const mode = await readMode(read.getPersistenceReader()); + + this.getLogger().debug('pre_created_mode', mode); + this.getLogger().debug('pre_created_caller', context.caller.username); + this.getLogger().debug('pre_created_callee', context.callee.username); + this.getLogger().debug('pre_created_created_by', context.createdBy.username); + this.getLogger().debug('pre_created_features', [...context.features].sort().join(',')); + // Proves at the real serialization boundary that no credential rode along with + // the contact - `contractId` is the per-session signing token the host strips. + this.getLogger().debug('pre_created_caller_keys', contactKeys(context.caller)); + this.getLogger().debug('pre_created_origin', context.origin); + + if (mode === 'prevent') { + return EventResult.prevent({ reason: 'blocked by media-call-events-test' }); + } + + if (mode === 'drop-screen-share') { + return EventResult.patch({ features: context.features.filter((feature) => feature !== 'screen-share') }); + } + + return EventResult.pass(); + } + + public async [AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED](context: IMediaCallStartedContext): Promise { + this.getLogger().debug('post_started_call', context.call.id); + this.getLogger().debug('post_started_state', context.call.state); + this.getLogger().debug('post_started_features', [...context.call.features].sort().join(',')); + this.getLogger().debug('post_started_activated_at', context.call.activatedAt.toISOString()); + // Both shapes carry the origin, so the app can prove the pre context and the call agree + this.getLogger().debug('post_started_origin', context.call.origin); + } + + public async [AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED](context: IMediaCallParticipantJoinedContext): Promise { + this.getLogger().debug('post_joined_call', context.call.id); + // Calls are two-party, so the side that joined is the callee of the call itself + this.getLogger().debug('post_joined_participant', context.call.callee.username); + this.getLogger().debug('post_joined_participant_keys', contactKeys(context.call.callee)); + this.getLogger().debug('post_joined_accepted_at', context.call.acceptedAt.toISOString()); + } + + public async [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED](context: IMediaCallEndedContext): Promise { + this.getLogger().debug('post_ended_call', context.call.id); + this.getLogger().debug('post_ended_ended', String(context.call.ended)); + this.getLogger().debug('post_ended_at', context.call.endedAt.toISOString()); + this.getLogger().debug('post_ended_by_type', context.call.endedBy?.type ?? 'none'); + this.getLogger().debug('post_ended_reason', context.call.hangupReason ?? 'none'); + this.getLogger().debug('post_ended_duration_ms', String(context.durationMs)); + this.getLogger().debug('post_ended_reason_known', String(isKnownMediaCallHangupReason(context.call.hangupReason))); + this.getLogger().debug('post_ended_outcome', describeOutcome(context)); + + if (isAnsweredCall(context)) { + // The guard narrows `acceptedAt` to a required Date, so this needs no assertion. + this.getLogger().debug('post_ended_accepted_at', context.call.acceptedAt.toISOString()); + } + } + + protected override async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [ + /** `POST /api/apps/public/:appId/mode` with `{ "mode": "pass" | "prevent" | "drop-screen-share" }`. */ + new (class extends ApiEndpoint { + public override path = 'mode'; + + public async post( + request: IApiRequest, + _endpoint: IApiEndpointInfo, + _read: IRead, + _modify: IModify, + _http: IHttp, + persistence: IPersistence, + ): Promise { + const { mode } = (request.content || {}) as { mode?: Mode }; + + if (!mode || !MODES.includes(mode)) { + return { + status: HttpStatusCode.BAD_REQUEST, + content: { error: `mode must be one of ${MODES.join(', ')}` }, + }; + } + + await persistence.updateByAssociation(association, { mode }, true); + + return { status: HttpStatusCode.OK, content: { mode } }; + } + + public async get(_request: IApiRequest, _endpoint: IApiEndpointInfo, read: IRead): Promise { + return { status: HttpStatusCode.OK, content: { mode: await readMode(read.getPersistenceReader()) } }; + } + })(this), + ], + }); + } +} + +async function readMode(persistenceRead: IPersistenceRead): Promise { + const [record] = (await persistenceRead.readByAssociation(association)) as { mode?: Mode }[]; + + return record?.mode ?? 'pass'; +} + +function contactKeys(contact: IMediaCallContact): string { + return Object.keys(contact).sort().join(','); +} + +/** + * There is no event for a call nobody answered, so an app has to read the outcome + * off the end event. `'unreachable'` can never be logged: the three guards partition + * every ended call, and the e2e spec asserts the label never appears. + */ +function describeOutcome(context: IMediaCallEndedContext): string { + if (isAnsweredCall(context)) { + return 'answered'; + } + + if (isRejectedCall(context)) { + return 'rejected'; + } + + if (isMissedCall(context)) { + return 'missed'; + } + + return 'unreachable'; +} +``` + +
diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts index af73089e8c12c..0d06ec1d88c2a 100644 --- a/apps/meteor/tests/data/apps/app-packages/index.ts +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -23,3 +23,5 @@ export const appExternalIdTest = path.resolve(__dirname, './external-id-test_0.0 export const messageReactionTest = path.resolve(__dirname, './message-updater-test_0.0.1.zip'); export const appPresenceStateTest = path.resolve(__dirname, './presence-state-test_0.0.1.zip'); + +export const appMediaCallEventsTest = path.resolve(__dirname, './media-call-events-test_0.0.1.zip'); diff --git a/apps/meteor/tests/data/apps/app-packages/media-call-events-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/media-call-events-test_0.0.1.zip new file mode 100644 index 0000000000000..ac295e5810097 Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/media-call-events-test_0.0.1.zip differ diff --git a/apps/meteor/tests/e2e/apps/media-call-events.spec.ts b/apps/meteor/tests/e2e/apps/media-call-events.spec.ts new file mode 100644 index 0000000000000..c220988658668 --- /dev/null +++ b/apps/meteor/tests/e2e/apps/media-call-events.spec.ts @@ -0,0 +1,358 @@ +import type { Page } from '@playwright/test'; + +import { appMediaCallEventsTest } from '../../data/apps/app-packages'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeChannel } from '../page-objects'; +import { getSettingValueById, setSettingValueById } from '../utils'; +import { + findAppLogItem, + getAppLogValue, + getAppLogs, + getNewestAppLog, + installLocalTestPackage, + uninstallApp, + waitForNewAppLog, +} from '../utils/apps'; +import type { BaseTest } from '../utils/test'; +import { expect, test } from '../utils/test'; + +/** Matches the modes the fixture app understands - see tests/data/apps/app-packages/README.md. */ +type Mode = 'pass' | 'prevent' | 'drop-screen-share'; + +/** `entries[].args[1]` for a label, within a single already-located log group. */ +const entryValue = (log: { entries: { args: string[] }[] } | undefined, label: string): string | undefined => + log?.entries.find((entry) => entry.args[0] === label)?.args[1]; + +/** + * Split into two serial groups on purpose: within a group the tests share one pair of calls' + * worth of state and have to run in order, but a failure in one group must not skip the other. + */ +test.describe('Apps > Media call events', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let appId: string; + let sessions: { page: Page; poHomeChannel: HomeChannel }[]; + let screenSharingWasEnabled: unknown; + + /** + * Tells the fixture app how to answer the next `executePreMediaCallCreated`. + * + * The outcome is driven by this rather than by the callee's username because a call that fails + * because the callee was unreachable looks identical in the UI to one an app blocked - so the + * same user pair has to be able to run through both a passing and a prevented call. + */ + const setMode = async (api: BaseTest['api'], mode: Mode): Promise => { + const response = await api.post(`/apps/public/${appId}/mode`, { mode }, '/api'); + + await expect(response).toBeOK(); + }; + + /** Places a call from user1 to user2 and has user2 answer it. */ + const placeAndAnswerCall = async (): Promise => { + const [user1, user2] = sessions; + + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + await user2.poHomeChannel.voiceCalls.widget.acceptCall(); + }; + + test.beforeAll(async ({ api }) => { + // Set rather than assumed: `screen-share` only reaches the app's feature list while this is + // on, and other specs turn it off for the length of their own run. The value it had is put + // back in `afterAll`, so this spec leaves the workspace as it found it. + screenSharingWasEnabled = await getSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled'); + await setSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled', true); + + const result = await installLocalTestPackage(appMediaCallEventsTest); + appId = result.app.id; + + await Promise.all([ + api.post('/users.setStatus', { status: 'online', username: 'user1' }), + api.post('/users.setStatus', { status: 'online', username: 'user2' }), + ]); + }); + + test.beforeAll(async ({ browser }) => { + sessions = await Promise.all([ + createAuxContext(browser, Users.user1).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + createAuxContext(browser, Users.user2).then(({ page }) => ({ page, poHomeChannel: new HomeChannel(page) })), + ]); + }); + + /** + * A test that fails partway through can leave a call up, and a user already in a call cannot + * place another one - which would fail every test that follows it for an unrelated reason. + * The groups no longer skip each other on failure, so the state has to be cleaned up for real. + */ + test.afterEach(async () => { + for (const { poHomeChannel } of sessions) { + const { widget } = poHomeChannel.voiceCalls; + const { controls } = widget; + + for (const button of [controls.hangup, controls.cancel, controls.reject]) { + if (await button.isVisible()) { + // The opposite side's widget may be closing at this very moment; cleanup must not + // turn a passing test into a failing one + await button.click({ timeout: 5000 }).catch(() => undefined); + break; + } + } + + // A refused call leaves the widget up on the dialer it was opened with, and the next test + // cannot open a fresh one over it + if (await widget.content.isVisible()) { + await widget.btnClose.click({ timeout: 5000 }).catch(() => undefined); + } + } + }); + + test.afterAll(async ({ api }) => { + await Promise.all(sessions.map(({ page }) => page.close())); + await uninstallApp(appId); + await setSettingValueById(api, 'VoIP_TeamCollab_Screen_Sharing_Enabled', screenSharingWasEnabled); + }); + + test.describe.serial('pre-create decisions', () => { + test('should prevent a call when the app returns prevent', async ({ api }) => { + const [user1, user2] = sessions; + + await setMode(api, 'prevent'); + + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await user1.poHomeChannel.content.btnVoiceCall.click(); + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + + // Deliberately not `widget.initiateCall()`: that helper asserts the call starts ringing, + // which is exactly what must not happen here. + await user1.poHomeChannel.voiceCalls.widget.controls.call.click(); + + await test.step('the caller is told why, in the words of the app that blocked the call', async () => { + await user1.poHomeChannel.toastMessage.waitForDisplay({ type: 'error', message: 'blocked by media-call-events-test' }); + }); + + await test.step('the call never starts and the callee is never rung', async () => { + // The widget stays up on the dialer it was opened with, so the state to read is the + // controls: a call that started would offer `Cancel` instead of `Call`. + await expect(user1.poHomeChannel.voiceCalls.widget.controls.cancel).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.controls.call).toBeVisible(); + await expect(user2.poHomeChannel.voiceCalls.widget.content).not.toBeVisible(); + }); + + await test.step('the callee is told nothing', async () => { + await expect(user2.poHomeChannel.toastMessage.toast('error')).not.toBeVisible(); + }); + + await test.step('the app ran and saw the call it blocked', async () => { + const { logs } = await getAppLogs(api, appId); + + const preCreated = findAppLogItem(logs, 'executePreMediaCallCreated', ['pre_created_mode', 'prevent']); + expect(preCreated, 'executePreMediaCallCreated did not run in prevent mode').toBeTruthy(); + + expect(entryValue(preCreated, 'pre_created_caller')).toBe('user1'); + expect(entryValue(preCreated, 'pre_created_callee')).toBe('user2'); + expect(entryValue(preCreated, 'pre_created_created_by')).toBe('user1'); + // Two workspace users and no PBX in this workspace, so the call never leaves it + expect(entryValue(preCreated, 'pre_created_origin')).toBe('internal'); + }); + + await test.step('the contact handed to the app carries no session credential', async () => { + const { logs } = await getAppLogs(api, appId); + const keys = getAppLogValue(logs, 'executePreMediaCallCreated', 'pre_created_caller_keys')?.split(','); + + expect(keys, 'the app did not report the keys of the contact it received').toBeTruthy(); + // `contractId` is the per-session signing token; the host strips it on the way in. + expect(keys).not.toContain('contractId'); + expect(keys).toContain('username'); + }); + }); + + test('should drop screen-share when the app patches the requested features', async ({ api }) => { + const [user1, user2] = sessions; + + await setMode(api, 'drop-screen-share'); + + const previousStarted = await getNewestAppLog(api, appId, 'executePostMediaCallStarted'); + + await placeAndAnswerCall(); + + await test.step('the app was offered screen-share before patching it out', async () => { + const { logs } = await getAppLogs(api, appId); + const preCreated = findAppLogItem(logs, 'executePreMediaCallCreated', ['pre_created_mode', 'drop-screen-share']); + + expect(preCreated, 'executePreMediaCallCreated did not run in drop-screen-share mode').toBeTruthy(); + expect(entryValue(preCreated, 'pre_created_features')).toContain('screen-share'); + }); + + await test.step('neither side can share their screen', async () => { + await expect(user2.poHomeChannel.voiceCalls.widget.controls.shareScreen).not.toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.widget.controls.shareScreen).not.toBeVisible(); + }); + + await test.step('the caller gets the widget rather than the screen-capable room view', async () => { + // The view router only routes a peer DM to the room section when the call supports + // screen-share, so the patch is observable in which view the caller lands on. + await expect(user1.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await expect(user1.poHomeChannel.voiceCalls.roomSection.content).not.toBeVisible(); + }); + + await test.step('the call the app saw kept the patched feature list', async () => { + const started = await waitForNewAppLog(api, appId, 'executePostMediaCallStarted', previousStarted?._id); + + expect(entryValue(started, 'post_started_features')).not.toContain('screen-share'); + }); + + await user2.poHomeChannel.voiceCalls.widget.hangup(); + }); + }); + + test.describe.serial('post events', () => { + test('should notify the app when a call is answered and when media starts flowing', async ({ api }) => { + const [, user2] = sessions; + + await setMode(api, 'pass'); + + const previousJoined = await getNewestAppLog(api, appId, 'executePostMediaCallParticipantJoined'); + const previousStarted = await getNewestAppLog(api, appId, 'executePostMediaCallStarted'); + + await placeAndAnswerCall(); + + await test.step('executePostMediaCallParticipantJoined receives the callee', async () => { + const joined = await waitForNewAppLog(api, appId, 'executePostMediaCallParticipantJoined', previousJoined?._id); + + expect(entryValue(joined, 'post_joined_participant')).toBe('user2'); + expect(entryValue(joined, 'post_joined_accepted_at')).toBeTruthy(); + expect(entryValue(joined, 'post_joined_call')).toBeTruthy(); + expect(entryValue(joined, 'post_joined_participant_keys')?.split(',')).not.toContain('contractId'); + }); + + await test.step('executePostMediaCallStarted receives an active call', async () => { + const started = await waitForNewAppLog(api, appId, 'executePostMediaCallStarted', previousStarted?._id); + + expect(entryValue(started, 'post_started_call')).toBeTruthy(); + expect(entryValue(started, 'post_started_state')).toBe('active'); + expect(entryValue(started, 'post_started_activated_at')).toBeTruthy(); + // The pre context reported the same origin for this pair of users + expect(entryValue(started, 'post_started_origin')).toBe('internal'); + expect(entryValue(started, 'post_started_features')).toContain('screen-share'); + }); + + await user2.poHomeChannel.voiceCalls.widget.hangup(); + }); + + test('should notify the app when a call ends, with who ended it and how long it ran', async ({ api }) => { + const [, user2] = sessions; + + await setMode(api, 'pass'); + + const previousEnded = await getNewestAppLog(api, appId, 'executePostMediaCallEnded'); + const previousStarted = await getNewestAppLog(api, appId, 'executePostMediaCallStarted'); + + await placeAndAnswerCall(); + + // Wait for the call to be active and to have run for a measurable amount of time, so the + // reported duration is deterministically greater than zero. + await waitForNewAppLog(api, appId, 'executePostMediaCallStarted', previousStarted?._id); + await expect.poll(() => user2.poHomeChannel.voiceCalls.widget.getTimerContentInSeconds()).toBeGreaterThanOrEqual(1); + + await user2.poHomeChannel.voiceCalls.widget.hangup(); + + const ended = await waitForNewAppLog(api, appId, 'executePostMediaCallEnded', previousEnded?._id); + + expect(entryValue(ended, 'post_ended_call')).toBeTruthy(); + expect(entryValue(ended, 'post_ended_ended')).toBe('true'); + expect(entryValue(ended, 'post_ended_at')).toBeTruthy(); + expect(entryValue(ended, 'post_ended_by_type')).toBe('user'); + expect(Number(entryValue(ended, 'post_ended_duration_ms'))).toBeGreaterThan(0); + + await test.step('the app reads the call as answered', async () => { + expect(entryValue(ended, 'post_ended_outcome')).toBe('answered'); + // Logged only inside the `isAnsweredCall` branch, so its presence is the guard firing. + expect(entryValue(ended, 'post_ended_accepted_at')).toBeTruthy(); + }); + }); + }); + + /** + * There is no event for a call nobody answered - an app has to read the outcome off the + * end event. These drive the three outcomes through the real UI, because the thing worth + * proving is that a declined call and an unanswered one do not look alike to an app. + */ + test.describe.serial('missed and rejected calls', () => { + test('should read a call the callee declined as rejected, not as missed', async ({ api }) => { + const [user1, user2] = sessions; + + await setMode(api, 'pass'); + + const previousEnded = await getNewestAppLog(api, appId, 'executePostMediaCallEnded'); + + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + + // While ringing, the callee's button reads `Reject` rather than `End call`. + await expect(user2.poHomeChannel.voiceCalls.widget.controls.reject).toBeVisible(); + await user2.poHomeChannel.voiceCalls.widget.reject(); + + const ended = await waitForNewAppLog(api, appId, 'executePostMediaCallEnded', previousEnded?._id); + + expect(entryValue(ended, 'post_ended_outcome')).toBe('rejected'); + expect(entryValue(ended, 'post_ended_reason')).toBe('rejected'); + expect(entryValue(ended, 'post_ended_duration_ms')).toBe('0'); + // The answered branch never ran, so the guard did not narrow the wrong way. + expect(entryValue(ended, 'post_ended_accepted_at')).toBeUndefined(); + }); + + test('should read a call nobody answered as missed', async ({ api }) => { + const [user1, user2] = sessions; + + await setMode(api, 'pass'); + + const previousEnded = await getNewestAppLog(api, appId, 'executePostMediaCallEnded'); + + await user1.poHomeChannel.navbar.openChat('user2'); + await expect(user1.poHomeChannel.composer.inputMessage).toBeVisible(); + + await user1.poHomeChannel.content.btnVoiceCall.click(); + await user1.poHomeChannel.voiceCalls.widget.initiateCall(); + + // The caller gives up while it is still ringing. Waiting out the real ring timeout + // would take longer than a test should, and the callee misses the call either way. + await expect(user2.poHomeChannel.voiceCalls.widget.content).toBeVisible(); + await user1.poHomeChannel.voiceCalls.widget.controls.cancel.click(); + + const ended = await waitForNewAppLog(api, appId, 'executePostMediaCallEnded', previousEnded?._id); + + expect(entryValue(ended, 'post_ended_outcome')).toBe('missed'); + expect(entryValue(ended, 'post_ended_reason')).not.toBe('rejected'); + expect(entryValue(ended, 'post_ended_duration_ms')).toBe('0'); + expect(entryValue(ended, 'post_ended_accepted_at')).toBeUndefined(); + }); + + test('should name every reason it reports, and place every call in one outcome', async ({ api }) => { + const { logs } = await getAppLogs(api, appId); + const ended = logs.filter((log) => log.method.includes('executePostMediaCallEnded')); + + expect(ended.length, 'no call ended during this run').toBeGreaterThan(0); + + for (const log of ended) { + // `unreachable` means the three guards failed to partition an ended call. + expect(entryValue(log, 'post_ended_outcome')).not.toBe('unreachable'); + + // A reason the SDK cannot name means MediaCallHangupReason has drifted from the + // server. Calls that recorded no reason at all have nothing to check. + if (entryValue(log, 'post_ended_reason') !== 'none') { + expect(entryValue(log, 'post_ended_reason_known'), `unnamed reason: ${entryValue(log, 'post_ended_reason')}`).toBe('true'); + } + } + }); + }); +}); diff --git a/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts b/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts index 850e3ef4111d1..0c1d7dc3b61aa 100644 --- a/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts +++ b/apps/meteor/tests/e2e/apps/uikit-interactions.spec.ts @@ -2,7 +2,7 @@ import { appUiKitRoomTest } from '../../data/apps/app-packages'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { HomeChannel } from '../page-objects'; -import { getAppLogs, installLocalTestPackage, uninstallApp } from '../utils/apps'; +import { findAppLogItem, getAppLogs, installLocalTestPackage, uninstallApp } from '../utils/apps'; import { expect, test } from '../utils/test'; test.use({ storageState: Users.user1.state }); @@ -27,22 +27,6 @@ test.describe.serial('Apps > UIKit interactions data', () => { await uninstallApp(appId); }); - /** - * Finds a log entry matching a handler method and a specific debug label. - * The app logs using `this.getLogger().debug(label, value)`, creating entries with args = [label, value]. - * Each handler invocation creates a log group with `method` like `app:executeBlockActionHandler`. - */ - function findLogItem( - logs: Awaited>['logs'], - methodFragment: string, - [arg0, arg1]: [arg0: string, arg1?: string], - ) { - return logs.find( - (log) => - log.method.includes(methodFragment) && log.entries.some((entry) => arg0 === entry.args[0] && (!arg1 || arg1 === entry.args[1])), - ); - } - test('should include correct data in executeBlockActionHandler when triggered in a message', async ({ api, page }) => { const seed = Date.now().toString(); @@ -60,7 +44,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { const logsResult = await getAppLogs(api, appId); expect(logsResult.logs).toBeDefined(); - const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + const blockActionLog = findAppLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); expect(blockActionLog, 'Block action handler log not found for message').toBeTruthy(); // Verify room is present (GENERAL room) @@ -109,7 +93,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { expect(logsResult.logs).toBeDefined(); // Find the most recent block action log with ctx-button actionId - const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + const blockActionLog = findAppLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); expect(blockActionLog, 'Block action handler log not found for contextual bar').toBeTruthy(); // Verify room is present @@ -147,7 +131,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { expect(logsResult.logs).toBeDefined(); // Find the most recent block action log with modal-button actionId - const blockActionLog = findLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); + const blockActionLog = findAppLogItem(logsResult.logs, 'executeBlockActionHandler', ['block_action_value', seed]); expect(blockActionLog, 'Block action handler log not found for modal').toBeTruthy(); // Verify user is present @@ -184,7 +168,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { const logsResult = await getAppLogs(api, appId); expect(logsResult.logs).toBeDefined(); - const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `modal-${seed}`]); + const viewSubmitLog = findAppLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `modal-${seed}`]); expect(viewSubmitLog, 'View submit handler log not found for modal').toBeTruthy(); // Verify user is present @@ -217,7 +201,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { expect(logsResult.logs).toBeDefined(); // Find the most recent view submit log - const viewSubmitLog = findLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `ctx-${seed}`]); + const viewSubmitLog = findAppLogItem(logsResult.logs, 'executeViewSubmitHandler', ['view_submit_id', `ctx-${seed}`]); expect(viewSubmitLog, 'View submit handler log not found for contextual bar').toBeTruthy(); // Verify room is present @@ -246,7 +230,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { const logsResult = await getAppLogs(api, appId); expect(logsResult.logs).toBeDefined(); - const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `modal-${seed}`]); + const viewClosedLog = findAppLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `modal-${seed}`]); expect(viewClosedLog, 'View closed handler log not found for modal').toBeTruthy(); // Verify user is present @@ -275,7 +259,7 @@ test.describe.serial('Apps > UIKit interactions data', () => { expect(logsResult.logs).toBeDefined(); // Find the most recent view closed log - const viewClosedLog = findLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `ctx-${seed}`]); + const viewClosedLog = findAppLogItem(logsResult.logs, 'executeViewClosedHandler', ['view_closed_id', `ctx-${seed}`]); expect(viewClosedLog, 'View closed handler log not found for contextual bar').toBeTruthy(); // Verify room is present diff --git a/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts b/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts index 58245ea97ee2d..14bb4bb12e54e 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/toast-messages.ts @@ -10,6 +10,11 @@ export class ToastMessages { error: this.page.locator('.rcx-toastbar--error'), }; + /** For asserting a toast is *absent*; `waitForDisplay` covers the positive case. */ + toast(type: 'success' | 'error') { + return this.toastByType[type]; + } + async dismissToast(type: 'success' | 'error' = 'success') { await this.toastByType[type].last().getByRole('button', { name: 'Dismiss alert' }).click(); await this.page.mouse.move(0, 0); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts index d23e44b4b6eb6..6f0914b83e2f9 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/voice-calls.ts @@ -134,6 +134,11 @@ export class Widget { return this.root.getByRole('button', { name: 'Show call here' }); } + /** Dismisses the widget while it sits on the dialer, before a call is placed. */ + get btnClose(): Locator { + return this.root.getByRole('button', { name: 'Close', exact: true }); + } + async showCallHere(): Promise { await this.btnShowCallHere.click(); await expect(this.btnShowCallHere).not.toBeVisible(); diff --git a/apps/meteor/tests/e2e/utils/apps.ts b/apps/meteor/tests/e2e/utils/apps.ts index bbdb2933f4fe8..d7a4f2dfaf703 100644 --- a/apps/meteor/tests/e2e/utils/apps.ts +++ b/apps/meteor/tests/e2e/utils/apps.ts @@ -55,3 +55,67 @@ export async function getAppLogs(api: BaseTest['api'], appId: string): Promise>['logs']; + +/** + * Finds a log entry matching a handler method and a specific debug label. + * Apps log using `this.getLogger().debug(label, value)`, creating entries with args = [label, value]. + * Each handler invocation creates a log group with `method` like `app:executeBlockActionHandler`. + * + * Pass only `arg0` to match on the label alone, or both to also require a specific value. + */ +export function findAppLogItem(logs: AppLogs, methodFragment: string, [arg0, arg1]: [arg0: string, arg1?: string]) { + return logs.find( + (log) => + log.method.includes(methodFragment) && log.entries.some((entry) => arg0 === entry.args[0] && (!arg1 || arg1 === entry.args[1])), + ); +} + +/** Reads the value logged under `label` by a given handler, or undefined if it never logged it. */ +export function getAppLogValue(logs: AppLogs, methodFragment: string, label: string): string | undefined { + const log = findAppLogItem(logs, methodFragment, [label]); + + return log?.entries.find((entry) => entry.args[0] === label)?.args[1]; +} + +/** The newest log group for a handler, or undefined. Logs come back newest-first. */ +export async function getNewestAppLog(api: BaseTest['api'], appId: string, methodFragment: string): Promise { + const { logs } = await getAppLogs(api, appId); + + return logs.find((log) => log.method.includes(methodFragment)); +} + +/** + * Waits for a handler to log something it hadn't logged before, and returns that log group. + * + * Needed for fire-and-forget app events: nothing in the request/response cycle waits on them, so + * there is no response to await. Pass the `_id` of the newest log for that handler taken *before* + * the action, so a log left behind by an earlier test in the same spec isn't mistaken for this one. + */ +export async function waitForNewAppLog( + api: BaseTest['api'], + appId: string, + methodFragment: string, + previousLogId?: string, +): Promise { + let found: AppLogs[number] | undefined; + + await expect + .poll( + async () => { + const newest = await getNewestAppLog(api, appId, methodFragment); + + if (!newest || newest._id === previousLogId) { + return false; + } + + found = newest; + return true; + }, + { message: `Timed out waiting for a new "${methodFragment}" app log`, timeout: 20_000 }, + ) + .toBe(true); + + return found as AppLogs[number]; +} diff --git a/apps/meteor/tests/unit/server/services/media-call/appEvents.spec.ts b/apps/meteor/tests/unit/server/services/media-call/appEvents.spec.ts new file mode 100644 index 0000000000000..8553f09bd3af2 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/media-call/appEvents.spec.ts @@ -0,0 +1,533 @@ +import { + isAnsweredCall, + isKnownMediaCallHangupReason, + isMissedCall, + isRejectedCall, + mediaCallHangupReasonList, +} from '@rocket.chat/apps-engine/definition/mediaCalls'; +import type { + IMediaCallEndedContext, + IMediaCallParticipantJoinedContext, + IMediaCallStartedContext, + MediaCallEvent, +} from '@rocket.chat/apps-engine/definition/mediaCalls'; +import { AppInterface, AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; +import type { IMediaCall } from '@rocket.chat/core-typings'; +import type { PreCallCreatedHookParams } from '@rocket.chat/media-calls'; +import { callHangupReasonList } from '@rocket.chat/media-signaling'; +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const triggerEvent = sinon.stub(); +const AppsMock: { self: { triggerEvent: sinon.SinonStub } | undefined } = { self: { triggerEvent } }; +const loggerMock = { warn: sinon.stub(), info: sinon.stub() }; + +const { notifyAppsOfMediaCallStarted, notifyAppsOfMediaCallParticipantJoined, notifyAppsOfMediaCallEnded, runPreMediaCallCreatedAppHook } = + proxyquire.noCallThru().load('../../../../../server/services/media-call/appEvents', { + '@rocket.chat/apps': { Apps: AppsMock, AppEvents: AppInterface }, + './logger': { logger: loggerMock }, + }); + +/** + * Contacts as they are stored: every one of them carries a `contractId`, which is + * the credential that must never reach an app. + */ +function makeCall(overrides: Partial = {}): IMediaCall { + return { + _id: 'call-id', + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { type: 'user', id: 'caller-id', username: 'caller', contractId: 'created-by-contract' }, + createdAt: new Date('2026-01-01T10:00:00.000Z'), + caller: { + type: 'user', + id: 'caller-id', + username: 'caller', + displayName: 'The Caller', + sipExtension: '1001', + contractId: 'caller-contract', + }, + callee: { type: 'user', id: 'callee-id', username: 'callee', contractId: 'callee-contract' }, + ended: true, + endedAt: new Date('2026-01-01T10:01:00.000Z'), + expiresAt: new Date('2026-01-01T11:00:00.000Z'), + uids: ['caller-id', 'callee-id'], + features: ['audio'], + ...overrides, + } as IMediaCall; +} + +function hookParams(overrides: Partial = {}): PreCallCreatedHookParams { + return { + caller: { type: 'user', id: 'caller-id', username: 'caller', contractId: 'caller-contract' }, + callee: { type: 'user', id: 'callee-id', username: 'callee', contractId: 'callee-contract' }, + createdBy: { type: 'user', id: 'caller-id', username: 'caller', contractId: 'created-by-contract' }, + features: ['audio'], + ...overrides, + } as PreCallCreatedHookParams; +} + +/** The single event handed to the Apps-Engine, asserting it travelled under `IMediaCallHandler`. */ +function dispatchedEvent(): MediaCallEvent { + expect(triggerEvent.callCount).to.equal(1); + + const [interfaceName, event] = triggerEvent.firstCall.args; + expect(interfaceName).to.equal(AppInterface.IMediaCallHandler); + + return event; +} + +describe('media call app events', () => { + beforeEach(() => { + triggerEvent.reset(); + triggerEvent.resolves(undefined); + loggerMock.warn.reset(); + loggerMock.info.reset(); + AppsMock.self = { triggerEvent }; + }); + + afterEach(() => sinon.restore()); + + describe('contact mapping', () => { + it('never lets a contractId reach an app', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ activatedAt: new Date('2026-01-01T10:00:05.000Z') })); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + expect(context.call.caller).to.not.have.property('contractId'); + expect(context.call.callee).to.not.have.property('contractId'); + expect(context.call.createdBy).to.not.have.property('contractId'); + }); + + it('never lets a contractId reach an app through the pre-create context', async () => { + await runPreMediaCallCreatedAppHook(hookParams()); + + const { context } = dispatchedEvent() as { context: Record }; + + expect(context.caller).to.not.have.property('contractId'); + expect(context.callee).to.not.have.property('contractId'); + expect(context.createdBy).to.not.have.property('contractId'); + }); + + it('copies every allowed contact field over', async () => { + await notifyAppsOfMediaCallEnded(makeCall()); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + expect(context.call.caller).to.deep.equal({ + type: 'user', + id: 'caller-id', + username: 'caller', + displayName: 'The Caller', + sipExtension: '1001', + }); + }); + + it('maps the contact that diverted the call, and omits it when the call was not diverted', async () => { + await notifyAppsOfMediaCallEnded( + makeCall({ + divertedBy: { type: 'sip', id: '1005', displayName: 'Front Desk', sipExtension: '1005', contractId: 'diverted-by-contract' }, + }), + ); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + // A diversion is not a transfer, so the call carries no parentCallId alongside it + expect(context.call.divertedBy).to.deep.equal({ type: 'sip', id: '1005', displayName: 'Front Desk', sipExtension: '1005' }); + expect(context.call).to.not.have.property('parentCallId'); + + triggerEvent.resetHistory(); + await notifyAppsOfMediaCallEnded(makeCall()); + + expect((dispatchedEvent().context as { call: Record }).call).to.not.have.property('divertedBy'); + }); + + it('omits the optional contact fields that are not set rather than sending them as undefined', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ callee: { type: 'sip', id: 'callee-id', contractId: 'callee-contract' } })); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + expect(Object.keys(context.call.callee)).to.deep.equal(['type', 'id']); + }); + }); + + describe('notifyAppsOfMediaCallStarted', () => { + it('dispatches the started event with the moment media started flowing', async () => { + const activatedAt = new Date('2026-01-01T10:00:05.000Z'); + await notifyAppsOfMediaCallStarted(makeCall({ state: 'active', ended: false, activatedAt })); + + const event = dispatchedEvent(); + + expect(event.method).to.equal(AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED); + expect(event.context).to.have.nested.property('call.id', 'call-id'); + expect((event.context as IMediaCallStartedContext).call.activatedAt).to.deep.equal(activatedAt); + // The call carries the timestamp, so the event has nothing to add next to it + expect(Object.keys(event.context)).to.deep.equal(['call']); + }); + + it('warns and dispatches nothing when the call has no activation timestamp', async () => { + await notifyAppsOfMediaCallStarted(makeCall({ activatedAt: undefined })); + + expect(triggerEvent.callCount).to.equal(0); + expect(loggerMock.warn.firstCall.firstArg).to.include({ callId: 'call-id', field: 'activatedAt' }); + }); + }); + + describe('notifyAppsOfMediaCallParticipantJoined', () => { + it('dispatches a call whose callee is the side that joined', async () => { + const acceptedAt = new Date('2026-01-01T10:00:03.000Z'); + await notifyAppsOfMediaCallParticipantJoined(makeCall({ state: 'accepted', ended: false, acceptedAt })); + + const event = dispatchedEvent(); + + expect(event.method).to.equal(AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED); + + // Calls are strictly two-party, so the side that joined is always the callee + const { call } = event.context as IMediaCallParticipantJoinedContext; + expect(call.callee).to.deep.equal({ type: 'user', id: 'callee-id', username: 'callee' }); + expect(call.acceptedAt).to.deep.equal(acceptedAt); + expect(Object.keys(event.context)).to.deep.equal(['call']); + }); + + it('warns and dispatches nothing when the call has no acceptance timestamp', async () => { + await notifyAppsOfMediaCallParticipantJoined(makeCall({ acceptedAt: undefined })); + + expect(triggerEvent.callCount).to.equal(0); + expect(loggerMock.warn.firstCall.firstArg).to.include({ callId: 'call-id', field: 'acceptedAt' }); + }); + }); + + describe('notifyAppsOfMediaCallEnded', () => { + it('dispatches who ended the call and why when both are known', async () => { + const endedAt = new Date('2026-01-01T10:01:00.000Z'); + await notifyAppsOfMediaCallEnded( + makeCall({ + endedAt, + endedBy: { type: 'user', id: 'callee-id', contractId: 'callee-contract' }, + hangupReason: 'not-answered', + }), + ); + + const event = dispatchedEvent(); + + expect(event.method).to.equal(AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED); + + const context = event.context as Record; + expect(context.call.endedAt).to.deep.equal(endedAt); + // Actors are mapped down to type and id alone, so no contractId travels here either + expect(context.call.endedBy).to.deep.equal({ type: 'user', id: 'callee-id' }); + expect(context.call.hangupReason).to.equal('not-answered'); + // Only durationMs sits next to the call, because the call does not carry it + expect(Object.keys(context).sort()).to.deep.equal(['call', 'durationMs']); + }); + + it('omits endedBy and hangupReason when the call recorded neither', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ endedAt: new Date('2026-01-01T10:01:00.000Z') })); + + const context = dispatchedEvent().context as Record; + + expect(context.call).to.not.have.property('endedBy'); + expect(context.call).to.not.have.property('hangupReason'); + }); + + it('reports a server actor as the one that ended the call', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ endedBy: { type: 'server', id: 'server' }, hangupReason: 'expired' })); + + expect((dispatchedEvent().context as Record).call.endedBy).to.deep.equal({ type: 'server', id: 'server' }); + }); + + it('warns and dispatches nothing when the call has no end timestamp', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ endedAt: undefined })); + + expect(triggerEvent.callCount).to.equal(0); + expect(loggerMock.warn.firstCall.firstArg).to.include({ callId: 'call-id', field: 'endedAt' }); + }); + + describe('durationMs', () => { + async function durationOf(overrides: Partial): Promise { + await notifyAppsOfMediaCallEnded(makeCall(overrides)); + + return (dispatchedEvent().context as { durationMs: number }).durationMs; + } + + it('is zero for a call that never became active', async () => { + expect(await durationOf({ activatedAt: undefined, endedAt: new Date('2026-01-01T10:01:00.000Z') })).to.equal(0); + }); + + it('is the time between activation and the end of the call', async () => { + const duration = await durationOf({ + activatedAt: new Date('2026-01-01T10:00:05.000Z'), + endedAt: new Date('2026-01-01T10:01:05.000Z'), + }); + + expect(duration).to.equal(60_000); + }); + + it('is clamped to zero when the call ended before it was activated', async () => { + const duration = await durationOf({ + activatedAt: new Date('2026-01-01T10:01:05.000Z'), + endedAt: new Date('2026-01-01T10:00:05.000Z'), + }); + + expect(duration).to.equal(0); + }); + }); + }); + + /** + * The two contact types are the whole of the origin, so the same three cases have + * to come out the same way on the pre context and on the persisted call - an app + * that keys on one and then the other must not see them disagree. + */ + describe('call origin', () => { + const user1 = { type: 'user', id: 'caller-id', username: 'caller', contractId: 'caller-contract' } as const; + const user2 = { type: 'user', id: 'callee-id', username: 'callee', contractId: 'callee-contract' } as const; + const extension = { type: 'sip', id: '1002', sipExtension: '1002', contractId: 'sip-contract' } as const; + + const cases = [ + { origin: 'internal', caller: user1, callee: user2, description: 'a call that never leaves the workspace' }, + { origin: 'sip-outbound', caller: user1, callee: extension, description: 'a call placed out through the PBX' }, + { origin: 'sip-inbound', caller: extension, callee: user2, description: 'a call that arrived from the PBX' }, + ] as const; + + cases.forEach(({ origin, caller, callee, description }) => { + it(`reports ${description} as ${origin} on the pre-create context`, async () => { + await runPreMediaCallCreatedAppHook(hookParams({ caller, callee })); + + expect((dispatchedEvent().context as Record).origin).to.equal(origin); + }); + + it(`reports ${description} as ${origin} on the call the post events carry`, async () => { + await notifyAppsOfMediaCallEnded(makeCall({ caller, callee })); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + expect(context.call.origin).to.equal(origin); + }); + }); + + it('reads the origin off the contacts rather than off the service the call is carried by', async () => { + await notifyAppsOfMediaCallEnded(makeCall({ caller: extension, callee: user2 })); + + const { context } = dispatchedEvent() as { context: { call: Record } }; + + // `service` stays `'webrtc'` on a SIP leg too, which is why the origin is not named after it + expect(context.call.service).to.equal('webrtc'); + expect(context.call.origin).to.equal('sip-inbound'); + }); + }); + + describe('post event guards', () => { + it('dispatches nothing when the Apps-Engine is not running', async () => { + AppsMock.self = undefined; + + await notifyAppsOfMediaCallStarted(makeCall({ activatedAt: new Date('2026-01-01T10:00:05.000Z') })); + await notifyAppsOfMediaCallParticipantJoined(makeCall({ acceptedAt: new Date('2026-01-01T10:00:03.000Z') })); + await notifyAppsOfMediaCallEnded(makeCall()); + + expect(triggerEvent.callCount).to.equal(0); + expect(loggerMock.warn.callCount).to.equal(0); + }); + }); + + describe('runPreMediaCallCreatedAppHook', () => { + it('lets the call through without consulting any app when the Apps-Engine is not running', async () => { + AppsMock.self = undefined; + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ prevented: false }); + expect(triggerEvent.callCount).to.equal(0); + }); + + it('dispatches the pre-create event with a copy of the requested features', async () => { + const params = hookParams({ features: ['audio', 'hold'] }); + + await runPreMediaCallCreatedAppHook(params); + + const event = dispatchedEvent(); + + expect(event.method).to.equal(AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED); + + const { features } = event.context as { features: string[] }; + expect(features).to.deep.equal(['audio', 'hold']); + // Apps must not be handed the array the caller still holds + expect(features).to.not.equal(params.features); + }); + + it('carries the parent call id of a transfer, and omits it otherwise', async () => { + await runPreMediaCallCreatedAppHook(hookParams({ parentCallId: 'parent-call-id' })); + expect(dispatchedEvent().context).to.have.property('parentCallId', 'parent-call-id'); + + triggerEvent.resetHistory(); + + await runPreMediaCallCreatedAppHook(hookParams()); + expect(dispatchedEvent().context).to.not.have.property('parentCallId'); + }); + + it('carries the contact that diverted the call, and omits it otherwise', async () => { + await runPreMediaCallCreatedAppHook( + hookParams({ divertedBy: { type: 'sip', id: '1005', displayName: 'Front Desk', contractId: 'diverted-by-contract' } }), + ); + + // The diverting party is a contact like any other: its contract stays behind + expect((dispatchedEvent().context as Record).divertedBy).to.deep.equal({ + type: 'sip', + id: '1005', + displayName: 'Front Desk', + }); + + triggerEvent.resetHistory(); + + await runPreMediaCallCreatedAppHook(hookParams()); + expect(dispatchedEvent().context).to.not.have.property('divertedBy'); + }); + + it('lets the call through when no app answered the event', async () => { + triggerEvent.resolves(undefined); + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ prevented: false }); + }); + + it('fails the call rather than letting it through when the event itself fails', async () => { + const failure = new Error('the app subprocess is gone'); + triggerEvent.rejects(failure); + + // The hook is a policy decision: an outcome nobody could produce must not read as `pass` + const error = await runPreMediaCallCreatedAppHook(hookParams()).then( + () => undefined, + (error: unknown) => error, + ); + + expect(error).to.equal(failure); + }); + + it('reports the reason an app prevented the call', async () => { + triggerEvent.resolves({ prevented: true, appId: 'blocking-app', reason: 'callee is on a do-not-disturb list' }); + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ + prevented: true, + reason: 'callee is on a do-not-disturb list', + message: { type: 'text', text: 'callee is on a do-not-disturb list' }, + }); + expect(loggerMock.info.callCount).to.equal(1); + expect(loggerMock.info.firstCall.firstArg).to.have.property('appId', 'blocking-app'); + }); + + it('keeps the i18n key and its args, namespaced to the app that produced them', async () => { + triggerEvent.resolves({ + prevented: true, + appId: 'blocking-app', + i18n: { key: 'callee_is_dnd', args: { username: 'callee' } }, + }); + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ + prevented: true, + reason: 'callee_is_dnd', + message: { type: 'i18n', key: 'callee_is_dnd', ns: 'app-blocking-app', args: { username: 'callee' } }, + }); + }); + + it('returns the features an app patched in', async () => { + triggerEvent.resolves({ + prevented: false, + context: { ...hookParams(), features: ['audio', 'hold'] }, + }); + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ prevented: false, features: ['audio', 'hold'] }); + }); + + it('drops the features an app asked for that the workspace does not know about', async () => { + triggerEvent.resolves({ + prevented: false, + context: { ...hookParams(), features: ['audio', 'teleportation', 'hold'] }, + }); + + expect(await runPreMediaCallCreatedAppHook(hookParams())).to.deep.equal({ prevented: false, features: ['audio', 'hold'] }); + }); + }); +}); + +describe('media call hangup reasons', () => { + /** + * The Apps-Engine ships the app-facing SDK on its own, so it cannot import the + * internal list and keeps a copy. Nothing but this test stops the copy rotting. + */ + it('covers every reason the internal list defines', () => { + expect(mediaCallHangupReasonList).to.include.members([...callHangupReasonList]); + }); + + it('recognises the codes only the server writes', () => { + expect(isKnownMediaCallHangupReason('expired')).to.be.true; + expect(isKnownMediaCallHangupReason('sip-refer-failed')).to.be.true; + expect(isKnownMediaCallHangupReason('sip-error-486')).to.be.true; + }); + + it('rejects a reason it does not document, and an absent one', () => { + expect(isKnownMediaCallHangupReason('teleportation-failure')).to.be.false; + expect(isKnownMediaCallHangupReason(undefined)).to.be.false; + }); +}); + +describe('media call outcome helpers', () => { + function endedContext(overrides: Partial = {}): IMediaCallEndedContext { + return { + call: { ...(makeAppCall() as IMediaCallEndedContext['call']), ...overrides }, + durationMs: 0, + }; + } + + function makeAppCall() { + return { + id: 'call-id', + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: { type: 'user', id: 'caller-id' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + caller: { type: 'user', id: 'caller-id' }, + callee: { type: 'user', id: 'callee-id' }, + features: ['audio'], + uids: ['caller-id', 'callee-id'], + ended: true, + endedAt: new Date('2026-01-01T00:01:00Z'), + }; + } + + it('reads a call the callee accepted as answered, whatever ended it', () => { + const context = endedContext({ acceptedAt: new Date('2026-01-01T00:00:10Z'), hangupReason: 'media-error' }); + + expect(isAnsweredCall(context)).to.be.true; + expect(isMissedCall(context)).to.be.false; + expect(isRejectedCall(context)).to.be.false; + }); + + it('reads a decline as rejected, not as missed', () => { + const context = endedContext({ hangupReason: 'rejected' }); + + expect(isRejectedCall(context)).to.be.true; + expect(isMissedCall(context)).to.be.false; + expect(isAnsweredCall(context)).to.be.false; + }); + + it('reads an unanswered call as missed whether the caller timed out or the server expired it', () => { + for (const hangupReason of ['not-answered', 'expired', 'unavailable', 'sip-error-408', undefined]) { + const context = endedContext({ hangupReason }); + + expect(isMissedCall(context), `hangupReason: ${hangupReason}`).to.be.true; + expect(isAnsweredCall(context), `hangupReason: ${hangupReason}`).to.be.false; + } + }); + + it('puts every ended call in exactly one of the three outcomes', () => { + for (const hangupReason of [...mediaCallHangupReasonList, 'something-new-and-unknown']) { + for (const acceptedAt of [undefined, new Date('2026-01-01T00:00:10Z')]) { + const context = endedContext({ acceptedAt, hangupReason }); + const matched = [isAnsweredCall(context), isRejectedCall(context), isMissedCall(context)].filter(Boolean); + + expect(matched, `hangupReason: ${hangupReason}, acceptedAt: ${acceptedAt}`).to.have.lengthOf(1); + } + } + }); +}); diff --git a/docs/adr/0002-unified-event-result-for-pre-events.md b/docs/adr/0002-unified-event-result-for-pre-events.md new file mode 100644 index 0000000000000..d6991483e6a28 --- /dev/null +++ b/docs/adr/0002-unified-event-result-for-pre-events.md @@ -0,0 +1,481 @@ +# ADR 0002 — A unified `EventResult` return type for apps-engine pre-events + +## Status + +**Accepted — partially implemented.** The type, the `EventResult.*` factories and the +`isEventResult()` guard landed in `packages/apps-engine/src/definition/eventResult/`, and the +media-call pre-create event is their first consumer (see +[ADR 0003](./0003-media-call-events-for-apps.md)). Two parts of this decision are deliberately not +in the code yet: + +- **`prompt` has no type definitions.** No event permits it today, and no flow can suspend and + resume to serve it. The variant is specified below and lands with the first event that needs it — + see [Follow-ups](#follow-ups) item 4. The shipped union is `pass | patch | prevent`. +- **The existing message / room / upload / email handlers are not widened.** That is Strategy B + below, still open. + +- **Date:** 2026-08 +- **Scope:** `packages/apps-engine` (definitions), `packages/apps` (engine runtime), + `apps/meteor/app/apps/server/bridges` (host) +- **Supersedes:** the `apps-engine-event-result-return-type` proposal + +## Decision + +One uniform return type for apps-engine pre-events — `pass` / `patch` / `prevent` / `prompt` — +replaces the five inconsistent return contracts in use today (boolean, entity-object, +`IEmailDescriptor`, void+throw, fire-and-forget). `prompt` is the one genuinely new capability; +`pass` / `patch` / `prevent` unify mechanisms that already exist across messages, rooms, uploads and +email. + +The author-facing shape is a marker-free discriminated union: + +```ts +type EventResult = + | { type: 'pass' } // allow unchanged + | { type: 'patch'; patch: Partial } // patch the subject (message/room/upload/…) + | { type: 'prevent'; reason: string } // literal, pre-formatted + | { type: 'prevent'; i18n: { key: string; args?: { [key: string]: string | number } } } + | { type: 'prompt'; message: string } // simple text form … + | { type: 'prompt'; i18n: { key: string; args?: { [key: string]: string | number } } } + | { // … or rich form (the confirmation UI payload) + type: 'prompt'; + title?: TextObject; + text?: TextObject; + blocks?: Block[]; + confirmLabel?: string; // default "Send" + cancelLabel?: string; // default "Cancel" + }; +``` + +`TextObject` is `@rocket.chat/ui-kit`'s `TextObject` (`PlainText | Markdown`); the apps-engine +`ITextObject` is deprecated in favor of it. Because ui-kit text renders through the +app-translation-aware surface renderer (`useStringFromTextObject` → `useAppTranslation`), a rich +prompt's `title`/`text` can itself be an app i18n key resolved client-side at render time. The +`i18n: { key, args? }` member on `prevent` and on the simple `prompt` is the explicit translation +channel for the non-UIKit paths (a thrown `Meteor.Error`, a plain modal). + +**`prompt` is specified here, not implemented.** `PromptEventResult`, its payload type and the +`EventResult.prompt()` factory are absent from `packages/apps-engine`, and `MarkedEventResult` is +`pass | patch | prevent`. Shipping a variant no event accepts would put a factory in every app +author's autocomplete that can only ever fail to typecheck, and would pull `@rocket.chat/ui-kit`'s +`Block` and `TextObject` into the definitions for a payload nothing reads. The variant is designed +in full so the first event that needs it inherits the decisions rather than reopening them; adding +it later is purely additive, because `isEventResult()` dispatches on `@kind` and the executors +already treat an unknown variant as `pass`. + +### The ten design decisions + +1. **`patch` chains the patched *subject*, not the wrapper.** The manager unwraps a `patch` + decision, applies it to the subject, and feeds the **patched subject** to the next app — + identical to today's Modify chain (`msg = await app.call(…, msg)`). What must never flow to the + next app is the raw `EventResult` wrapper. "Break the chain" only ever means "don't forward the + wrapper." +2. **Short-circuit execution.** `prevent` stops the listener loop immediately (matching today's + "first truthy prevents"). `prompt` suspends the operation immediately (first-prompt-wins); on + resume the gate re-runs from the top and a later app may prompt again. We accept that a `prompt` + can fire even though a later, un-consulted app would have `prevent`ed — the alternative (run + every app before resolving) forfeits short-circuiting on hot paths. `prevent` still beats + `prompt` within one app's decision and across a resume. +3. **Strategy B is the destination; Strategy A is the bootstrap.** Existing handlers are ultimately + re-typed to return their restricted `EventResult` union (semantics unchanged), with consumption + sites accepting **both** legacy shapes and `EventResult` behind the guard. We bootstrap with the + guard plus new, additive handlers (Strategy A), then widen existing events per event. +4. **Reserved discriminator `'@kind': 'EventResult'`.** A single `isEventResult(x)` guard checks + `@kind` and runs **before** any legacy `typeof === 'object'` / truthiness branch. Authors never + write `@kind`; the factories stamp it. +5. **`EventResult.*` companion-object factories.** `EventResult` is simultaneously the marker-free + union *type* and a *value* namespace of factory functions. Factories stamp the marker and return + **branded per-variant types**. Per-event narrowing comes from **each handler interface's + restricted return-type alias** — not from a new accessor and not from a generic `IModify`. +6. **`patch` merge is shallow** — one level of spread over the subject, as `Object.assign` does at the + existing Modify call sites; nested objects and arrays are replaced wholesale. +7. **Patchable fields mirror the builder surface** per subject (an explicit allow-list constant + tracking `IMessageBuilder` / `IRoomBuilder` / …). Re-validate **once, at end of pass**. +8. **`prevent` carries either `reason` (literal) or `i18n: { key, args? }`** — two mutually + exclusive union members. `i18n` translation is **client-side**, via the `error-app-prevented` + error `details`. Surfacing is standardized across **all** call sites. +9. **Upload confirmation is the intended first application of `EventResult.prompt`**, via a new + upload handler, superseding the standalone `IUploadConfirmationRequest`. That work has its own + proposal and is not in this repo yet. +10. **Disallowed variant at runtime → log and treat as `pass`** (fail-open); the static types make + this unreachable for well-formed apps. + +## Context + +Apps-engine has **16** `IPre*` handler interfaces, and they do **not** share a contract. Five +distinct consumption patterns already exist: + +| Pattern | Return | "Block" mechanism | Events | +| --- | --- | --- | --- | +| **Boolean-prevent** | `Promise` (first truthy short-circuits) | return `true` | `IPreMessageSentPrevent`, `IPreMessageDeletePrevent`, `IPreMessageUpdatedPrevent`, `IPreRoomCreatePrevent`, `IPreRoomDeletePrevent` | +| **Object-chain Modify/Extend** | `Promise` / `Promise` (result replaces subject, fed to next app) | n/a | `IPreMessageSent{Extend,Modify}`, `IPreMessageUpdated{Extend,Modify}`, `IPreRoomCreate{Extend,Modify}` | +| **Object-replace + fallback** | `Promise` | throw | `IPreEmailSent` | +| **Void + throw-to-block** | `Promise` | throw a typed exception | `IPreFileUpload` (`FileUploadNotAllowedException`), `IPreLivechatRoomCreatePrevent` (`AppsEngineException`), `IPreRoomUserJoined` / `IPreRoomUserLeave` (`UserNotAllowedException`) | +| **Fire-and-forget** | `Promise` | n/a | all `IPost*` | + +None of them can express *"prompt the user and proceed only if they accept,"* and there is no single +vocabulary an app author can reach for regardless of which event they hook. + +Existing inconsistencies, which are the evidence this space needs unifying: + +- `IPreLivechatRoomCreatePrevent` carries the `Prevent` suffix but is void/throw, not boolean — and + its `AppMethod` string even drops the `Pre` (`executeLivechatRoomCreatePrevent`). +- In the listener type map `IListenerExecutor` + (`packages/apps/src/server/managers/AppListenerManager.ts:47`), `IPreMessageUpdatedExtend` is + typed `result: boolean` while its sibling `IPreMessageSentExtend` is `IMessage`; + `IPreMessageUpdatedPrevent` is `result: unknown`; `IPreEmailSent` is typed `IUIKitResponse` but + the implementation returns `IEmailDescriptor`. +- Prevention exists in **three** forms today (boolean return, thrown exception, and — for email — + either). +- `prevent` reasons are surfaced **inconsistently**: `createRoom.ts:267`, `FileUpload.ts:206` and + `addUserToRoom.ts:75` throw the app's `error.message`, but `updateMessage.ts:34` and + `deleteMessage.ts:42` throw a canned generic string and discard the app's reason entirely. + +Requirements taken as given: **must not break existing apps**, and **different events may allow +different subsets** of `EventResult` (upload may allow `prompt`; a login pre-event may not). + +### Where return values are actually interpreted + +This is the crux of the non-breaking analysis. Return values are consumed at three tiers: + +1. **`AppListenerManager`** — the per-event executors. Every one consumes the app's return with a + **blind cast, no shape inspection**: `as boolean` (`:512`) or `as IMessage` / `as IRoom` (`:561`, + `:741`, `:834`). Prevent loops short-circuit on the first truthy value (`:514`); Modify/Extend + **chain** the returned object into the next app (`msg = await app.call(...)`, `:538`, `:561`). +2. **`AppListenerBridge`** (`apps/meteor/app/apps/server/bridges/listeners.ts`) — only **two** sites + duck-type the result, and coarsely: `messageEvent` (`:377`) and `roomEvent` (`:421`) both treat + "boolean or undefined → pass through, anything else → it is the entity, convert it". +3. **The Rocket.Chat server call sites** — `sendMessage.ts:241`/`:252`, `createRoom.ts:265`, + `deleteMessage.ts:40`, `updateMessage.ts:32`, `FileUpload.ts:203`, `email/api.ts:176`. + +Two facts fall out of this and drive the whole design: + +- **The `patch` variant is not new machinery.** `sendMessage.ts:252` and `createRoom.ts` already do + `Object.assign(subject, result)` (shallow) with the object an app returns from Modify/Extend, and + re-validate once afterward. `EventResult.patch` formalizes what the object-chain pattern already + does implicitly. +- **The interpretation points are few and centralized** — the manager executors, two bridge + duck-type sites, and about six call sites. An `EventResult` can be recognized and dispatched at + these known choke points. + +## Architecture + +### Serialization boundary — not a problem + +App handlers run in a sandboxed runtime; `ProxiedApp.call` (`packages/apps/src/server/ProxiedApp.ts:64`) +dispatches over JSON-RPC. Any return must be JSON-serializable. This has a useful corollary for the +authoring API: a class *instance* would arrive **stripped of its prototype and methods**, so the +manager always sees plain data (`{ '@kind': 'EventResult', type: 'prevent', … }`) regardless of how +the author constructed it. `EventResult` is a plain object, and `Block[]` already crosses this +boundary for UIKit interactions and for `IMessage.blocks`, so all four variants serialize with no +new work. + +Today's "throw to prevent" relies on `AppsEngineException` crossing back as a JSON-RPC error +(`ProxiedApp.ts:70`). `EventResult.prevent` gives a **non-exceptional** prevention channel, which +removes the `error.name === AppsEngineException.name` string-matching at call sites. + +### The discriminator — reserved `'@kind': 'EventResult'` + +The naïve design (discriminate on `.type`) has a concrete collision: **`IMessage` has a top-level +`type?: MessageType` field** (`IMessage.ts:37`), and `IRoom` similarly carries a `type`. So +`'type' in result` cannot distinguish an `EventResult` from a legitimate message or room, and a +value allow-list (`result.type in {pass,patch,prevent,prompt}`) is fragile — a future `MessageType` +value, or an app that sets a custom `type`, could collide and make `Object.assign` merge an +`EventResult`'s fields onto a real message on a hot path. + +The decision is a **reserved marker the entities never carry** — `'@kind': 'EventResult'` — checked +by a single `isEventResult(x)` type guard used everywhere, running **before** any legacy branch. The +`@` prefix keeps it out of the space of legitimate property names, so there is zero overlap with +`IMessage` / `IRoom` / `IEmailDescriptor`. Internally the manager uses a `MarkedEventResult` type +carrying `@kind`; the public `EventResult` type authors annotate against omits it. + +### Would an `EventResult` be misinterpreted if returned today? Yes + +If an app returned an `EventResult` from an existing handler *without* the consumers being updated: + +- from a **Modify/Extend** handler it would be chained forward as the new message and pushed through + `convertAppMessage` and `Object.assign` — corrupting the message; +- from a **Prevent** handler any non-empty object is truthy, so `{type:'pass'}` would wrongly + **block**; +- from a **void** handler it would be silently dropped. + +This confirms the two ordering rules below: the guard must run *before* the existing branches, and +consumers must be taught about `EventResult` before any handler is allowed to return one. + +### Non-breaking guarantee + +Existing apps return `boolean` / `IMessage` / `IRoom` / `IEmailDescriptor` / `void` / throw. As long +as (1) the `isEventResult()` guard is checked **before** the legacy `typeof === 'object'` and +truthiness branches at every consumption site, and (2) legacy return shapes keep flowing down the +unchanged path, then apps that never return an `EventResult` behave exactly as before. + +Legacy ↔ `EventResult` mapping, mechanical at each guarded site: + +- Legacy `boolean true` from a Prevent handler ≡ `EventResult.prevent`. +- A legacy returned entity from a Modify/Extend handler ≡ a full `patch`: both funnel through the + same shallow `Object.assign(subject, x)` plus a single end-of-pass validate, and the **accumulated + subject** is what chains forward. + +This mapping is what lets an app migrate `return true` → `EventResult.prevent(…)` or +`return message` → `EventResult.patch(…)` with identical runtime behavior. + +### Authoring API — `EventResult.*` factories + +`EventResult` is a **companion object**: the same name is both the marker-free union *type* and a +*value* namespace of factories. This gives one discoverable entry point, keeps the marker an +implementation detail, and lets per-event restriction come from the **handler's return type** rather +than a new accessor. + +```ts +// value namespace stamps '@kind':'EventResult' and returns BRANDED per-variant +// types, so disallowed variants fail to typecheck at the `return`. +export const EventResult = { + pass: (): PassEventResult => ({ '@kind': 'EventResult', type: 'pass' }), + prevent: (o): PreventEventResult => ({ '@kind': 'EventResult', type: 'prevent', ...o }), + patch: (p: Partial): PatchEventResult => ({ '@kind': 'EventResult', type: 'patch', patch: p }), + // not shipped — lands with the first event that permits it: + // prompt: (o): PromptEventResult => ({ '@kind': 'EventResult', type: 'prompt', ...o }), +}; +``` + +Each handler interface declares its allowed union as its return type; the branded factory returns +then enforce the subset: + +```ts +type MessageModifyEventResult = PassEventResult | PatchEventResult; + +interface IPreMessageSentModify { + executePreMessageSentModify(msg, builder, read, http, persis): Promise; +} + +// app author: +async executePreMessageSentModify(msg, builder): Promise { + return EventResult.prevent({ reason: 'no' }); // ❌ PreventEventResult ∉ pass | patch + return EventResult.patch({ text: 'redacted' }); // ✅ patch checked as Partial + return EventResult.pass(); // ✅ +} +``` + +- **No generic `IModify`, no new `IModifyDecide` accessor.** `IModify` is a single shared interface + that cannot infer the event; putting `decide` there would give the same unrestricted union + everywhere. The event identity already lives in *which interface the author implements* and *what + that interface returns*. +- The restricted return type also gives `EventResult.patch(...)` correct `Partial` checking via + contextual typing. +- **Tradeoff accepted:** typing `EventResult.` lists all four variants in autocomplete; disallowed + ones simply fail to typecheck at the `return`. + +### Multi-app composition and precedence + +1. **Precedence:** `prevent` > `prompt` > `patch` > `pass`. Any `prevent` wins immediately. +2. **`patch` composition:** apply sequentially in listener order, chaining like Modify does today — + each app sees the accumulated patched subject. Re-validate once at the end. +3. **Multiple `prompt`s:** first prompt wins; on resume the gate re-runs and the next app may prompt + again — the same "one challenge resolved per round-trip" model TOTP uses. +4. **Short-circuit consequence:** because the loop stops at the first `prompt`, a later app that + would have `prevent`ed is not consulted in that pass; on resume the loop re-runs from the top and + that app's `prevent` blocks then. + +### Runtime enforcement + +The capability matrix is enforced **statically** (each handler's declared return-type union) and, as +defense in depth, **at runtime**. If an app returns a variant the event does not permit — reachable +only via a bug or a tampered JSON-RPC payload — the manager **logs a warning and treats the decision +as `pass`** (fail-open). A disallowed variant is never a legitimate block (a well-formed `prevent` +is allowed on every event in the matrix), and proceeding as `pass` degrades more gracefully than +turning a malformed return into a user-facing outage on a hot path. + +## Per-variant feasibility + +| Variant | Feasibility | Notes | +| --- | --- | --- | +| `pass` | Trivial | Equivalent to today's "return `false`" (prevent) / "return the unchanged subject" (modify) / "return void". | +| `patch` | Easy | Already effectively implemented as shallow `Object.assign(subject, result)` at `sendMessage.ts:252` / `createRoom.ts`. Merge is shallow. Patchable fields mirror the builder's setter surface per subject (an explicit allow-list constant), giving parity with what Modify can already change — so widening stays non-breaking, while identity/server fields (`_id`, `ts`, `_updatedAt`) are excluded because the builder never exposes them. Re-validate once at end of pass. | +| `prevent` | Easy | Unifies the boolean-return and throw-exception channels. Maps onto `Meteor.Error('error-app-prevented', …)` with the key/args in `details` for client-side translation against the user's actual UI locale. Surfacing is standardized across all call sites — including `updateMessage` / `deleteMessage`, which currently discard the reason. | +| `prompt` | **Hard, event-dependent** | Genuinely new. Requires the triggering flow to suspend and resume — a TOTP-style challenge. Two shapes: simple (`{ message }` or `{ i18n }`) and rich (`{ title?, text?, blocks?, confirmLabel?, cancelLabel? }`). | + +### Which events can support `prompt` + +`prompt` is only realizable when the operation can be aborted and safely retried after the user +answers, without losing work or double-applying side effects. + +- **File upload — yes.** The two-step `rooms.media` → `rooms.mediaConfirm` flow already stages the + bytes and defers the message; the prompt fits on the confirm step. Upload's `prevent` and `prompt` + live on **different handlers**: hard blocks stay on the block-only `IPreFileUpload` at the + `rooms.media` pre-stage, while `prompt`/`pass` go on a new confirmation handler at + `rooms.mediaConfirm`. +- **Message send / update, room create — possible but heavy.** These run inside a synchronous server + pipeline. Prompting means aborting with a challenge error and having the client re-issue the send + with a confirmation token — the client message-send path would need the same challenge/re-send + plumbing 2FA has. +- **Delete — possible** (the client can re-issue with a token). +- **Room user join/leave, livechat room create, email** — often triggered by non-interactive or + server-side flows with no user to prompt. `prompt` is **disallowed** for these. + +## Per-event capability matrix + +| Event | pass | patch | prevent | prompt | +| --- | --- | --- | --- | --- | +| Media call created (pre) | ✅ | ✅ | ✅ | ⚠️ later | +| File upload | ✅ | ⚠️ later | ✅¹ | ✅¹ | +| Message sent / updated | ✅ | ✅ | ✅ | ⚠️ later | +| Message delete | ✅ | — | ✅ | ⚠️ later | +| Room create | ✅ | ✅ | ✅ | ⚠️ later | +| Room delete | ✅ | — | ✅ | ⚠️ later | +| Room user join / leave | ✅ | — | ✅ | ❌ | +| Livechat room create | ✅ | — | ✅ | ❌ | +| Email sent | ✅ | ✅ | ✅ | ❌ | +| (future) Login | ✅ | — | ✅ | ❌ | + +¹ Upload's `prevent` and `prompt` live on different handlers, as described above. The row is an +aggregate across both hooks, not a single four-way union. Upload `patch` is deferred: there is no +upload-modify path today, and `patch` needs its own `IUploadDetails` allow-list that nothing yet +depends on. + +## Rollout + +1. **Done.** Land the `EventResult` type, the `EventResult.*` factories and the `isEventResult()` + guard in `@rocket.chat/apps-engine` (`definition/`). Both packages are in-repo, so no external + package release is required — only the definitions are published + (`files: ["definition/**"]`). +2. **Done.** Implement Strategy A for the media-call event hooks, returning the correct + `EventResult` variants. This establishes the pattern later events reuse — see + [ADR 0003](./0003-media-call-events-for-apps.md). +3. **Open.** Widen the existing events (Strategy B) per event: re-type each `execute*` to its + restricted union, add the guard ahead of every legacy branch, and standardize `prevent` reason + surfacing (fixing `updateMessage` / `deleteMessage`). +4. **Open, optional.** Deprecate the throw-to-prevent exceptions in favor of `EventResult.prevent`. + +Backward compatibility holds at every step, because new handlers use new `AppMethod` keys and +widened handlers accept legacy shapes behind the guard-before-legacy ordering. + +## Alternatives considered + +- **Discriminate on `.type` alone.** Rejected: `IMessage.type` and `IRoom.type` collide with it, and + a value allow-list is fragile on a hot path. +- **A class instance (`new PreventResult(…)`) as the authoring API.** Rejected as pointless: the + JSON-RPC boundary strips the prototype, so the manager sees plain data either way. The choice is + purely one of authoring ergonomics, and the companion object gives narrowing for free. +- **A new `IModifyDecide` accessor, or a generic `IModify`.** Rejected: `IModify` cannot + infer the event, so `decide` there would offer the same unrestricted union everywhere. Restriction + belongs on the handler's return type. A narrowed accessor that hides invalid variants from + autocomplete could be layered on later without changing the return types. +- **"Strategy A forever"** — keep existing handlers frozen on legacy types and only ever add + parallel `EventResult` interfaces. Rejected: it leaves two ways to do the same thing indefinitely + and makes the capability matrix aspirational rather than honest. Strategy B makes each event + express its real, restricted capability in one vocabulary. +- **RFC 6902 JSON Patch written by app authors.** Rejected for `patch`: stringly-typed JSON Pointers + forfeit the compile-time `Partial` checking that per-event narrowing depends on, and under the + fail-open rule a single typo'd pointer would silently discard an app's entire patch. See the + builder-generated alternative under Follow-ups. + +## Consequences + +- App authors get one vocabulary across every pre-event, and one discoverable entry point + (`EventResult.`). +- The engine gains a non-exceptional prevention channel, so preventing a call no longer requires + throwing across the JSON-RPC boundary. +- Strategy B will have to edit the hottest paths in the product (message send, room create). The + guard-before-legacy ordering is the only thing keeping those edits safe, so it is a review + invariant, not a stylistic preference. +- `patch` is deliberately less expressive than the builders (see Follow-ups); an app migrating + `return builder.getMessage()` → `EventResult.patch(…)` gives up positional operations. + +## Follow-ups + +> **Nothing below this heading is decided.** These are open problems and candidate answers, +> recorded so the analysis is not redone: the op-log encoding, the builder-generated patches and +> the `prompt` design are sketches, and where an item says a future implementation "must" do +> something, read it as the constraint that sketch depends on, not as a rule the current code +> already follows. Item 4 is the one with a named first consumer, and even that is unscheduled. + +1. **`patch` allow-list drift.** The allow-list constant must stay in sync with the builder surface + as subjects gain fields; re-validation at end-of-pass is the backstop. +2. **`patch` cannot express intent, appends, positional edits or deletion.** A shallow `Partial` + merge can only say *replace this field*, while the builders Modify handlers still receive expose + operations that are structurally not merges: appends (`addAttachment`, `addBlocks`, + `IRoomBuilder.addUsername`), positional edits (`replaceAttachment(position, …)`, + `removeAttachment(position)`) and keyed inserts that reject an existing key (`addCustomField`). + Under `patch` an app expresses an append as read-modify-write, which is **correct** — the + listener loop chains strictly sequentially, so there is no lost-update hazard — but *intent* is + lost: the server cannot distinguish "appended one attachment" from "replaced the whole array". + That costs audit fidelity and rules out path-granular gating. Two further consequences: shallow + merge forces whole-subtree rewrites for nested fields, and because `patch` is JSON-serialized — + where `JSON.stringify` drops `undefined` — it cannot express **field deletion** at all. + + **The v1 constraint that keeps the door open:** treat `{ type: 'patch'; patch: Partial }` as + *one* patch **encoding**, not as the definition of `patch`. The manager must branch on the patch + payload's shape at apply time rather than assuming `Partial`, so a future + `{ type: 'patch'; ops: [...] }` sibling is purely additive. This costs nothing now, but + retrofitting it later would be a wire-format break. +3. **Builder-generated patches.** The tractable way to close item 2 is to have `EventResult.patch` + **generate** the ops, so the authoring surface stays typed and the op encoding stays an + implementation detail. Most of the generator already exists: `MessageBuilder` + (`packages/apps/base-runtime/src/lib/accessors/builders/MessageBuilder.ts`) is already a change + recorder — it keeps `private changes: Partial` (`:17`) and exposes `getChanges()` + (`:254`) — and it already demonstrates the exact intent erasure described above: + `addAttachment`, `setAttachments`, `replaceAttachment(position, …)` and + `removeAttachment(position)` all collapse to one `attachmentsChanged` flag (`:122`–`:164`), so + `getChanges()` emits the whole array wholesale (`:257`). `RoomBuilder.getChanges()` (`:182`) has + the same shape. The work is to upgrade `changes` from `Partial` plus two boolean flags into an + op log, making `EventResult.patch()` approximately `builder.getPatch()`. Notable properties: + + - **No new API for app authors.** The builder methods they already call *are* the declarative + surface; `removeAttachment(2)` emits `remove /attachments/2`. A future `unset` emits + `remove /alias`, closing the deletion hole. + - **It also fixes `ModifyUpdater`** + (`packages/apps/base-runtime/src/lib/accessors/modify/ModifyUpdater.ts:92`, `:122`), which today + ships the entire attachments array to the server for any single-attachment change. + - **It rules out two tempting alternatives.** Structural diffing (`patch(before, after)`) cannot + recover the append-vs-replace distinction that is the whole point, and Immer's + `produceWithPatches` would mean bundling a dependency into every app sandbox — `apps-engine` + publishes only `definition/**` with exactly one runtime dep (`uuid`), and the `base-runtime` + builders are dependency-free plain-object code. + - **A closed generator shrinks the server-side validator.** Because the generator is the only + producer, the op vocabulary is a known subset: no `move`, no `copy`, no arbitrary deep + pointers, and paths rooted in allow-listed fields by construction. + - **New hazard: index staleness — conditional, not general.** Within the listener chain, + index-based ops are safe for the same reason read-modify-write is: each app sees the + accumulated patched subject. Staleness only arises if ops are applied somewhere other than the + chain step that produced them — concretely the `prompt` resume path. The rule is **apply + immediately, or regenerate on resume**. +4. **Implement `prompt` with the first event that permits it.** That means adding `PromptPayload`, + `PromptEventResult` and the `EventResult.prompt()` factory, widening `MarkedEventResult`, and + giving that event's executor a suspend/resume path — the variant is inert without one. Upload + confirmation is the intended first case; message and room prompting needs the client send-path + challenge plumbing and should be its own proposal. +5. **Prompt UX across multiple apps.** v1 is sequential re-prompting (first-prompt-wins, re-run on + resume); revisit if prompt fatigue proves a problem. +6. **Client-side `i18n` resolution.** The `prevent` `i18n` and the simple `prompt` `i18n` form rely + on the client having the app's translations loaded (as it does for UIKit) and knowing the user's + locale. Non-UI and REST consumers only get the raw `key`/`args` in `details` — there is no + literal fallback for the `i18n` member, so apps that need one should return the `reason` member + instead. Rich prompts inherit UIKit's existing translation path. + +## Implementation record + +- `packages/apps-engine/src/definition/eventResult/EventResult.ts` — the `pass | patch | prevent` + union, the branded per-variant types, `MarkedEventResult`, and the `EventResult.*` factories. + `prompt` is absent, as recorded in Status. +- `packages/apps-engine/src/definition/eventResult/isEventResult.ts` — the `@kind` guard. +- `packages/apps-engine/src/definition/eventResult/index.ts` — exports. +- First consumer: `packages/apps-engine/src/definition/mediaCalls/MediaCallEventResult.ts` + (`MediaCallCreateEventResult` = `pass | patch | prevent`), dispatched by + `packages/apps/src/server/managers/AppListenerManager.ts`. +- The fail-open backstop for an unknown variant lives in that manager's + `executePreMediaCallCreated` `default` branch, covered by + `packages/apps/tests/server/managers/AppListenerManager.mediaCalls.test.ts`. The test hand-builds + a `@kind`-marked payload, because no factory can produce a variant the types do not declare. +- `packages/apps-engine/definition/` is build output and gitignored; only `src/definition/` is + edited. + +## Reference index + +- Handler definitions: `packages/apps-engine/src/definition/{messages,rooms,uploads,email,livechat}/IPre*.ts` +- Enums: `packages/apps-engine/src/definition/metadata/AppInterface.ts`, `.../AppMethod.ts` +- Listener type map, executors and blind casts: `packages/apps/src/server/managers/AppListenerManager.ts:47` (map), `:359` (dispatch), `:493`/`:522`/`:545` (prevent/extend/modify templates), `:1191` (upload), `:1199` (email) +- Runtime / JSON-RPC boundary: `packages/apps/src/server/ProxiedApp.ts:64` +- Bridge duck-typing: `apps/meteor/app/apps/server/bridges/listeners.ts:377` (message), `:421` (room), `:198`/`:291` (upload) +- Server call sites: `apps/meteor/server/lib/messages/sendMessage.ts:241`/`:252`, `.../deleteMessage.ts:40`, `.../updateMessage.ts:32`, `apps/meteor/server/lib/rooms/createRoom.ts:265`, `apps/meteor/server/lib/media/file-upload/lib/FileUpload.ts:203`, `apps/meteor/server/lib/notifications/email/api.ts:176` +- Builder setter surface (the `patch` allow-list's source of truth): `packages/apps-engine/src/definition/accessors/IMessageBuilder.ts:23`/`:56`/`:68` +- Collision field: `packages/apps-engine/src/definition/messages/IMessage.ts:37` (`type?: MessageType`) diff --git a/docs/adr/0003-media-call-events-for-apps.md b/docs/adr/0003-media-call-events-for-apps.md new file mode 100644 index 0000000000000..7cd1da6c0911e --- /dev/null +++ b/docs/adr/0003-media-call-events-for-apps.md @@ -0,0 +1,760 @@ +# ADR 0003 — Media-call lifecycle events for apps + +## TL;DR + +- Apps implement a single `IMediaCallHandler` interface with one optional method per event. +- Four Phase 1 lifecycle events dispatch: post-started, post-participant-joined, post-ended, and + pre-created (preventable and patchable). +- Every event context carries `origin`, derived from contact types; apps classify calls without + knowing the routing rules. +- Two unlinked events fire for internal calls routed over SIP; a correlation from PBX signals would + be unreliable. + +## Status + +**Accepted — Phase 1 implemented.** What this ADR decides is the Phase 1 surface in +[Decision](#decision); that part is built and shipped. Phases 2 to 4 (act, intervene, provide) are +**surveyed, not decided** — see the note at the head of +[Follow-ups](#follow-ups--the-remaining-phases). One part is rejected: linking the SIP loop-back leg +to the call it duplicates. See +[Rejected — link the loop-back leg](#rejected--link-the-loop-back-leg-to-the-call-it-duplicates). + +- **Date:** 2026-08 +- **Scope:** `packages/apps-engine` (definitions), `packages/apps` (listener manager), + `apps/meteor/app/apps/server/bridges` and `apps/meteor/server/services/media-call` (host), + `ee/packages/media-calls` (hook bus) +- **Depends on:** [ADR 0002](./0002-unified-event-result-for-pre-events.md) — the media-call + pre-create event is `EventResult`'s first consumer +- **Supersedes:** the `apps-media-call-analysis` and `apps-media-call-origin-and-sip-loopback` + proposals + +The feature in scope is the Rocket.Chat **media call** — the 1:1 WebRTC/SIP direct-call system in +`ee/packages/media-calls` plus `packages/media-signaling`, surfaced through the `media-call` +core-service. It is a **distinct feature from Video Conferences**; video conf appears here only as +the architectural precedent for how apps-engine exposes a call-like domain. + +## Decision + +1. **One app-facing interface, one optional method per event.** `IMediaCallHandler` + (`packages/apps-engine/src/definition/mediaCalls/`) has one optional method per event, keyed by + `AppMethod` — the shape `IUIKitActionHandler` already established here. Per-event return-type + narrowing and per-event opt-out survive, and an app never writes an internal `eventType` `switch` + (the dispatch-side router of decision 2 is the engine's, not the app's). +2. **One `AppInterface` member and one envelope, not one per event.** Because the interface *is* the + subscription, all four events travel under `AppInterface.IMediaCallHandler`: the host sends a + `MediaCallEvent` envelope, the `ListenerBridge` has a single case for it, and + `AppListenerManager.executeMediaCallEvent` (`:1303-1310`) routes on the envelope's `method`. This + is the one place media calls depart from the message and room events, which spend one + `AppInterface` member, one bridge case and one executor per event. Under the router there are two + executors, one per event *kind* — the serial pre loop `executePreMediaCallCreated` + (`:1312-1362`) and the post fan-out `executePostMediaCallEvent` (`:1375-1398`) — so prevent + short-circuiting and patch chaining still live in an ordinary listener loop rather than in a + reimplementation. Per-event return-type narrowing and per-event opt-out come from the handler + interface, not from the dispatch, so collapsing the members costs neither. +3. **Four events in Phase 1.** + - `executePostMediaCallStarted` — from `callActivated` + - `executePostMediaCallParticipantJoined` — from a new `callAccepted` emitter event + - `executePostMediaCallEnded` — from `callEnded` + - `executePreMediaCallCreated` — preventable and patchable +4. **The pre event needs a hook bus in the EE engine, not an emitter subscription.** A veto has to + be awaited, and the emitter is fire-and-forget. `IMediaCallServer.setHooks` / + `runPreCallCreatedHook` is consulted synchronously inside `MediaCallDirector.createCall`. + Prevention reuses the existing `CallRejectedError('forbidden')` contract. What an app patches is + clamped on the way out: the features of a call with a `sip` contact are filtered to + `SIP_CALL_FEATURES` again (`CallDirector.ts:34-47`), because the transport decides what it can + carry and a patch must not put `screen-share` back on a PBX leg. +5. **Each post-event context carries the call snapshot and nothing the snapshot already holds.** The + moment, the participant that joined and the way the call ended are all already on the call + (`activatedAt` / `acceptedAt` / `endedAt`, `callee`, `endedBy` / `hangupReason`); a flat copy + beside `call` would only be a second place to read the same value. Each context narrows its + snapshot so the timestamp it is named after is a required `Date` (`IActiveMediaCall`, + `IAcceptedMediaCall`, `IEndedMediaCall`). The one field that stays outside is + `IMediaCallEndedContext.durationMs`: the call carries the two timestamps it is computed from, not + the result. +6. **The transition hands the call over, and one call's events reach an app in order.** The three + guarded state changes are `findOneAndUpdate`s that return the call the transition produced + (`MediaCalls.ts:90-155`), and `callAccepted` / `callActivated` / `callEnded` carry that document + (`IMediaCallServer.ts:16-29`, emitted at `CallDirector.ts:74,100,436`). Nothing reads the call + again on the way to an app, so the snapshot is the call **as of the transition**: an event + describes the call it is about, even when the call moves on while the notification waits, and a + post event costs no query of its own. Losing that read is also what puts the events of one call + in order: `MediaCallService.notifyApps` defers each one with `setImmediate`, which runs them in + the order they were queued, and a notification now awaits nothing between there and the JSON-RPC + request the app receives (`triggerEvent` → `handleEvent` → `executeListener` → + `executePostMediaCallEvent` → `ProxiedApp.call` → `sendRequest`, all synchronous up to the write + to the subprocess). An app is told a call started before it is told the call ended. What a + context still only guarantees is the timestamp it is named after, so `getEventTimestamp` + (`appEvents.ts:100-106`) drops the event and logs rather than emit one without it. +7. **The prevention reason reaches the caller as a toast.** A `CallRejectionMessage` + (`packages/media-signaling/src/definition/call/common.ts`) carries plain text or an `i18n` key + with its `args`, threaded through `PreCallCreatedHookResult.message` → + `CallRejectedError.rejectionMessage` → the `rejected-call-request` signal → the client's + `rejected` call event and `rejectedCall` session event → `useCallRejectionToast` + (`packages/ui-voip/src/providers/`). App keys resolve against the `app-${appId}` namespace the + client registers translations under, and fall back to a workspace message. The same path carries + the rejections the server already sent on its own (`Call_rejected_*` in `packages/i18n`); the + protocol-level ones stay silent on purpose. +8. **Every event carries an `origin`, derived at dispatch time from the two contacts.** It is not + persisted and not patchable. Without it, an app's only signal for where a call comes from is + `caller.type` / `callee.type` plus knowledge of the routing rules — host knowledge apps should + not have to reimplement. See [`origin`](#origin--where-a-call-comes-from). +9. **Nothing links the two legs of a SIP-routed internal call.** The correlation cannot be made + reliable from what the PBX tells us, and a wrong link is worse than no link. Both legs fire their + own events, each labelled with its own `origin`. + +### What an app receives today + +The three payloads the nine decisions above add up to, as an app sees them. + +A plain WebRTC call between two workspace users: + +```jsonc +// executePreMediaCallCreated +{ + "caller": { "type": "user", "id": "aaa", "username": "user1" }, + "callee": { "type": "user", "id": "bbb", "username": "user2" }, + "createdBy": { "type": "user", "id": "aaa", "username": "user1" }, + "features": ["audio", "video"], + "origin": "internal" +} +``` + +The same user-to-user call with SIP integration enabled for internal calls fires +`executePreMediaCallCreated` **twice** (decision 9), and each run is labelled but unlinked: + +```jsonc +// run #1 — the leg Rocket.Chat sends to the PBX +{ "caller": { "type": "user", "id": "aaa", "username": "user1" }, + "callee": { "type": "sip", "id": "1002", "username": "user2", "sipExtension": "1002" }, + "createdBy": { "type": "user", "id": "aaa", "username": "user1" }, + "features": ["audio"], + "origin": "sip-outbound" } + +// run #2 — the INVITE the PBX routes straight back in (same conversation) +{ "caller": { "type": "sip", "id": "1001", "username": "user1", "sipExtension": "1001" }, + "callee": { "type": "user", "id": "bbb", "username": "user2" }, + "createdBy": { "type": "sip", "id": "1001", "username": "user1", "sipExtension": "1001" }, + "features": ["audio"], + "origin": "sip-inbound" } +``` + +A genuinely inbound call from outside the workspace looks like run #2. That is the ambiguity +decision 9 leaves open — why the two runs cannot be linked is +[The open problem](#the-open-problem--one-call-two-events); how they are produced is +[How one call becomes two](#how-one-call-becomes-two). + +## Consequences + +- Apps get one cohesive interface to implement, with per-event opt-out and per-event return-type + narrowing, and they inherit prevent/patch composition from the listener loop. +- Apps can classify every media call as `internal`, `sip-outbound` or `sip-inbound` without knowing + the routing rules. Calls already in the database report the correct origin; no migration, no + persisted field. +- A new media-call event is cheaper than a new event elsewhere in the engine, because the envelope + dispatch of decision 2 is already wired for the whole family: a post event needs no listener-manager + change at all, and none of the four events needs an `AppInterface` member or a bridge case of its + own. The cost moves rather than disappearing — the family shares one `IListenerExecutor` `result` + union, so a pre event with a new return shape widens it for every member. See + [the wiring recipe](#adding-an-event--the-wiring-recipe). +- **Apps still see one internal SIP-routed conversation as two unlinked calls**, and still see + `callee.type === 'sip'` for a call between two workspace users. An app that needs one record per + conversation has no host-provided way to deduplicate. +- An app returning `prevent` on the inbound leg rejects only that leg — the outbound leg is already + ringing by then, so the result is a call the PBX cannot complete rather than a cleanly refused + one. **An app that means to block a call must act on the outbound leg**, that is, on the event + where `origin` is `sip-outbound` or `internal`. +- The two legs are not equivalent, which matters for any app that picks one. The **outbound** leg's + timestamps track the PBX dialog (`OutgoingSipCall.createDialog:113`, the accept at `:266`, + `sipDialog.on('destroy'):207`), so it spans the actual conversation and its duration and hangup + reason are meaningful; its `uids` lists only `user1`. The **inbound** leg carries `user2` in + `uids` and reports `user2`'s own accept, so it is the leg that says the callee actually answered. + +## Deliberate gaps in Phase 1 + +Recorded so they are not mistaken for oversights. + +- **No `IMediaCallRead`.** Apps see only the calls they are handed by an event. There is no accessor + for reading a call by id, so no way to answer "is this user on a call right now?". +- **No `MEDIA_CALL` association.** Nothing lets an app declare that it handles media calls, so the + events dispatch to every app implementing `IMediaCallHandler` with no way to narrow the + subscription. +- **The loop-back legs stay unlinked**, as decided above. +- **The pre event fails open when the app's request times out.** `executePreMediaCallCreated` + rethrows the errors it sees, so an app that throws blocks the call — a policy handler that could + not decide must not read as `pass`. A *timed-out* request never reaches that branch: + `ProxiedApp.call` (`packages/apps/src/server/ProxiedApp.ts:64-86`) rethrows only + `AppsEngineException` and `JSONRPC_METHOD_NOT_FOUND`, and a timeout rejects with a plain `Error` + whose `code` is `undefined`, so both range checks are false and the method returns `undefined` + with nothing logged. The listener loop reads that as "no result" and the call is created. + This is not specific to media calls — every pre-event in the engine fails open the same way + (`executePreMessageSentPrevent` and the rest) — and closing it means either a call path that does + not swallow, or a sentinel that separates method-not-found from a swallowed failure. Left as is + in Phase 1 deliberately: fail-closed here means a slow app subprocess stops users from placing + calls, and that trade is the engine's to make once, not this event's to make alone. +- **The event order of decision 6 holds inside one instance, and rests on a synchronous dispatch + path.** Both the emitter and `Apps.self.triggerEvent` are in-process, so an instance tells its own + apps about the transitions it performed itself; a call whose transitions land on two instances is + reported by each of them, in no particular order between the two. An `await` added anywhere + between `notifyApps` and `ProxiedApp.call` would also reorder the events of a single call, and + nothing in the engine enforces that it stays out. +- **`ee/packages/media-calls` has no test harness at all** — no `test` script in its `package.json` + and no spec files. `CallDirector`'s pre-hook branch and the `IncomingSipCall` rejection mapping are + therefore covered by the Playwright suite only. Standing up mocha (or `node:test`) for that + package is a prerequisite for unit-testing anything further in Phase 2 or 3. + +## Context + +### The three layers of apps-engine in this monorepo + +The historical standalone apps-engine is split across three locations. Any media-call extension +touches all three. + +| Layer | Location | Contents | +|---|---|---| +| **SDK / definitions** (published `@rocket.chat/apps-engine`) | `packages/apps-engine/src/definition/` | `AppInterface`, `AppMethod`, handler interfaces, accessor interfaces, context/permission/association types. `package.json` ships only `definition/**` (`packages/apps-engine/package.json:37-39`). | +| **Engine runtime** (published `@rocket.chat/apps`) | `packages/apps/src/` + `packages/apps/base-runtime/src/` | `src/server/managers/` (`AppListenerManager`, `AppVideoConfProviderManager`), abstract `src/server/bridges/`, `src/server/{AppManager,ProxiedApp}.ts`, `src/converters/`. The **concrete accessors are not here** — they live in `base-runtime/src/lib/accessors/` (`read/`, `modify/`, builders, extenders) and are assembled in `accessors/mod.ts`; there is no `AppAccessorManager` — see [ADR 0001](./0001-app-accessor-logic-in-base-runtime.md). | +| **Host (real Rocket.Chat)** | `apps/meteor/app/apps/server/` + `apps/meteor/ee/server/apps/` | Concrete bridge subclasses, converters, orchestrator (`ee/server/apps/orchestrator.ts`). | + +The host imports the engine from `@rocket.chat/apps/dist/...`, so **the `packages/apps` build must +be regenerated** for host changes to see new engine code +(`apps/meteor/app/apps/server/bridges/bridges.js:1`). + +### The media call domain object + +Persisted record — `IMediaCall` (`packages/core-typings/src/mediaCalls/IMediaCall.ts:35-74`): + +- `service: 'webrtc'`, `kind: 'direct'` — only 1:1 direct WebRTC/SIP calls exist today (`:36-37`). +- `state: 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'` (`:33,39`). The stored enum is + deliberately smaller than the client state machine, which also has `renegotiating`. +- Actors: `caller: MediaCallSignedContact`, `callee: MediaCallContact`, `createdBy: MediaCallContact` + (`:41,44-45`). `MediaCallActorType = 'user' | 'sip'` (`:5`); `contractId` is the per-session + signing token (`:7-15`). +- Lifecycle timestamps `acceptedAt`, `activatedAt`, `expiresAt` (`:52-57`); end fields `ended`, + `endedBy`, `endedAt`, `hangupReason` (`:47-50`); transfer fields `transferredBy/To/At`, + `parentCallId` (`:60,63-65`); `divertedBy` for a call the PBX forwarded (`:68`, RFC 5806 + `Diversion`) — a diversion is not a transfer and carries no `parentCallId`. +- `uids: string[]` (`:70`), `features: string[]` (`:73`) — the negotiated capability set, finalized + on accept. + +Negotiation record — `IMediaCallNegotiation`: one document per SDP (re)negotiation round. **SDP +offer/answer payloads are persisted there.** + +State transitions are enforced as **race-safe guarded updates** on the model — this is where the +persisted state machine actually lives: `startRingingById` (`MediaCalls.ts:80-88`), `acceptCallById` +(`:90-117`), `activateCallById` (`:119-134`), `hangupCallById` (`:136-155`), `transferCallById` +(`:169-188`). The three that a post event reports are `findOneAndUpdate`s and return the call they +produced; the others return an `UpdateResult`, because nothing needs the document. + +### The lifecycle engine and its event emitter + +`callServer = new MediaCallServer()` (`ee/packages/media-calls/src/server/configuration.ts:6`) is the +singleton gateway. `MediaCallDirector` (`ee/packages/media-calls/src/server/CallDirector.ts`) is the +**state-machine authority — every DB transition converges there**, which makes it the natural +interception choke point. + +Before this work the only outward event channel was a typed `Emitter`, `MediaCallServerEvents` +(`ee/packages/media-calls/src/definition/IMediaCallServer.ts:16-24`): `callUpdated`, +`callActivated`, `callEnded`, `signalRequest`, `historyUpdate`, `pushNotificationRequest`. +`callAccepted` is the one member this work added, and every payload carries ids only — see +decision 6. + +### The integration seam + +`MediaCallService` (`apps/meteor/server/services/media-call/service.ts`) is the thin Meteor adapter +over the EE `callServer` engine. Its constructor (`service.ts:40-63`) wires the emitter into the rest +of Rocket.Chat — `signalRequest`, `callUpdated`, `callActivated` (sets Presence BUSY), `callEnded` +(clears Presence), `historyUpdate` (`saveCallToHistory`), `pushNotificationRequest` — and is exactly +where the apps-engine dispatch subscribes, mirroring how it already forwards these events onto the +microservice bus. The service also owns the permission and feature callbacks injected into the engine +(`getMediaServerSettings` `:430-456`, `userHasMediaCallPermission` `:470-478`, +`userHasFeaturePermission` `:458-468`) — an existing, function-shaped extension seam. + +### How apps-engine extension mechanisms work + +**Pattern A — events / listeners (host → app: notify, veto, enrich).** An `AppInterface` member names +a hookable event; an `AppMethod` names the method(s) the engine calls. Host code fires +`Apps.self?.triggerEvent(AppEvents.X, …)` → `AppServerOrchestrator.triggerEvent` → +`getListenerBridge().handleEvent()` → `AppListenerManager.executeListener()` → +`app.call(AppMethod.…)`. Handler kinds are distinguished by the accessors they receive: Pre-Prevent +returns `boolean` and any `true` short-circuits; Pre-Extend gets an additive extender; Pre-Modify +gets a full builder; Post gets the full accessor set, returns `void`, fire-and-forget. + +**Pattern B — provider registration (an app *backs* a capability; the host calls into it on demand).** +Used by video conf: an app registers a provider during `extendConfiguration`, the engine tracks it in +`AppVideoConfProviderManager`, and the host RPCs into it when needed. + +**Data accessors (app → host read/modify).** `IRead` is a facade of sub-readers; `IModify` splits into +creator/updater/extender/deleter. Each accessor is a thin per-app wrapper delegating to a `do*` +bridge method that performs the **permission check** then calls a `protected abstract` method the +host implements. The canonical minimal precedent is `IVideoConferenceRead` → `VideoConferenceRead` → +`VideoConferenceBridge` → host `AppVideoConferenceBridge` → converter → core-service. + +## `origin` — where a call comes from + +Nothing in `IPreMediaCallCreatedContext` or the app-facing `IMediaCall` used to say whether a call is +a pure WebRTC call between two workspace users, a call going out through the PBX, or a call arriving +from it. `service` is always `'webrtc'` (`CallDirector.ts:197-202`), so it does not answer the +question. + +### The information already exists at dispatch time + +Both contacts are final before either event is built, and their types *are* the origin: + +| `caller.type` | `callee.type` | origin | +| --- | --- | --- | +| `user` | `user` | never leaves the workspace | +| `user` | `sip` | placed out through the PBX | +| `sip` | `user` | arrived from the PBX | + +`sip`/`sip` cannot occur: `parseCallContacts` rejects a non-user caller for an external callee +(`MediaCallServer.ts:238-241`), and `getCalleeFromInvite` requires a user callee +(`IncomingSipCall.ts:435`). + +### Shape + +```ts +/** How this call reaches the outside world, and which side opened it. */ +export type MediaCallOrigin = 'internal' | 'sip-outbound' | 'sip-inbound'; +``` + +`'internal'`, not `'webrtc'`: WebRTC carries the media of a SIP leg as well, so the transport does +not tell an app where a call came from — which is the whole point of the field. `service` keeps +reporting `'webrtc'`, and it keeps meaning the transport. + +`origin` is added to `IPreMediaCallCreatedContext` and to the app-facing `IMediaCall`, so pre and +post events agree. It is **not patchable**: `MediaCallCreatePatch` stays `Pick<..., 'features'>`, and +`AppListenerManager.getMediaCallCreatePatch` (`:1371-1385`) drops anything that is not `features` — +along with a patch that is not an object at all, since `isEventResult` checks the marker and not the +payload under it. + +### Where it is computed + +One helper in `apps/meteor/server/services/media-call/appEvents.ts`, used by both sides — pre, where +`runPreMediaCallCreatedAppHook` already receives both contacts in `PreCallCreatedHookParams`, and +post, where `toAppMediaCall` already has `call.caller` / `call.callee`: + +```ts +function getCallOrigin(caller: MediaCallContact, callee: MediaCallContact): MediaCallOrigin { + if (caller.type === 'sip') return 'sip-inbound'; + if (callee.type === 'sip') return 'sip-outbound'; + return 'internal'; +} +``` + +Two consequences worth stating: **nothing changes in `ee/packages/media-calls`** for `origin` — no +new hook param, no new persisted field — and calls already in the database report the correct origin, +because it is derived from data they already carry. + +### `divertedBy` is a neighbouring signal, not a substitute + +`divertedBy` landed with #40560 (the RFC 5806 `Diversion` header) and reaches apps on both shapes: +`IncomingSipCall.getDiversionContactFromInvite` parses the header and resolves the extension to a +contact (`IncomingSipCall.ts:469-497`); `CallDirector.createCall` hands it to the pre-create hook and +persists it (`:218,254`); `toAppMediaCall` maps it into `context.call` (`appEvents.ts:90`). + +It answers a different question. `origin` says how a call reaches the outside world; `divertedBy` +says why it arrived at *this* callee instead of the one that was dialled. The two compose: a diverted +call is always `sip-inbound`, and `divertedBy` cannot appear on an `internal` or `sip-outbound` call, +because only an inbound INVITE carries the header. So `origin` needs no diverted variant. + +One asymmetry: `getNewCallTransferredBy` returns `divertedBy` ahead of the transfer check +(`server/signals/getNewCallTransferredBy.ts:5-9`), so clients label a diverted call as *transferred +by* the diverting party. Apps get the same fact under its own name and with no `parentCallId`, +because no earlier call was replaced. An app reconciling its own view with what the user sees must +read `divertedBy` as the client's `transferredBy`. + +### The gap `origin` leaves + +An internal call routed over SIP reports `sip-outbound` — true about the transport, silent about the +call being between two workspace users. The outbound leg cannot answer this at pre time: whether the +PBX routes the INVITE back into this workspace is known only once it does. An `internal: boolean` on +the pre-create context would therefore have to lie on exactly the case that motivates it. That is the +open problem below. + +## The open problem — one call, two events + +With SIP integration enabled *for internal calls*, a single user-to-user call is created twice: once +for the leg Rocket.Chat sends to the PBX, and once for the INVITE the PBX routes straight back in. +An app can tell that *a* SIP leg is involved; it cannot tell that the two legs are one conversation, +and has no reason to expect two. The two payloads are side by side under +[What an app receives today](#what-an-app-receives-today). + +### How one call becomes two + +`executePreMediaCallCreated` has exactly one trigger point — `MediaCallDirector.createCall` +(`CallDirector.ts:218`, via `runPreCallCreatedHook` → `runPreMediaCallCreatedAppHook`). So a double +execution means `createCall` ran twice. With `VoIP_TeamCollab_SIP_Integration_Enabled` **and** +`VoIP_TeamCollab_SIP_Integration_For_Internal_Calls` on (`service.ts:431-439` → +`routeExternally: 'always'`), it does: + +1. `user1` presses call → `request-call` → `notifications.module.ts:299` → + `GlobalSignalProcessor.processRequestCallSignal` (`internal/SignalProcessor.ts:194`) → + `MediaCallServer.requestCall` (`server/MediaCallServer.ts:92`). +2. `parseCallContacts` routes the callee through `getCalleeContactOptions` + (`MediaCallServer.ts:287-314`). With internal calls routed externally the option is + `{ requiredType: 'sip' }`, so `user2` resolves to the **sip contact for their extension** + (`server/CastDirector.ts:160-167`). +3. `MediaCallServer.createCall:128` sees `callee.type === 'sip'` → `OutgoingSipCall.createCall` + (`sip/providers/OutgoingSipCall.ts:46-77`) → `mediaCallDirector.createCall` → **event run #1**. +4. `OutgoingSipCall.createDialog:136` INVITEs `sip:@` + (`sip/Session.ts:102`). +5. The PBX dialplan resolves that extension back to Rocket.Chat, so drachtio hands the same + workspace an inbound INVITE: `srf.invite` (`sip/Session.ts:140`) → `processInvite:166` → + `IncomingSipCall.processInvite` (`sip/providers/IncomingSipCall.ts:48`), where + `getCalleeFromInvite:435` maps the called number to `user2` and `getCallerContactFromInvite:499` + rebuilds `user1`'s identity → `mediaCallDirector.createCall:103` → **event run #2**. + +Two `IMediaCall` documents result, each holding one real participant: the outbound leg has +`uids: [user1]` (a sip callee contributes no uid, `CallDirector.ts:245-249`), the inbound leg +`uids: [user2]`. + +The same doubling occurs with only `..._SIP_Integration_Enabled` on, whenever the PBX happens to +route an outbound leg back into the workspace (for example a DID mapped to a workspace extension); +user-to-user calls then stay internal (`routeExternally: 'never'`) and fire once. + +### Why the duplicate is unrecognisable to apps + +`IPreMediaCallCreatedContext` deliberately carries no call id (nothing is persisted yet), so the +only material an app has is the contacts and the features: + +| context field | outbound leg | inbound leg | same? | +| --- | --- | --- | --- | +| `caller.username` | `user1` | `user1` (from `X-RocketChat-Caller-Username`, or resolved from the extension) | yes | +| `callee.username` | `user2` (sip contact built from the user record) | `user2` | yes | +| `createdBy.username` | `user1` (the requester) | `user1` (`createdBy = requestedBy \|\| caller`, `CallDirector.ts:215`) | yes | +| `features` | client list filtered to `SIP_CALL_FEATURES` (`OutgoingSipCall.ts:70`) | `SIP_CALL_FEATURES` verbatim (`IncomingSipCall.ts:109`) | yes, unless the client asked for fewer | +| contact key set | `type,id,username,displayName,sipExtension` | same | yes | +| `caller.type` / `callee.type` | `user` / `sip` | `sip` / `user` | **no** — mirrored | +| `createdBy.type` / `.id` | `user` / uid of `user1` | `sip` / `user1`'s extension | **no** — `createdBy` *is* the caller contact on the inbound leg, since `IncomingSipCall` passes no `requestedBy` (`IncomingSipCall.ts:104-111`) | +| `caller.id` / `callee.id` | uid / extension | extension / uid | **no** — mirrored | + +The two contexts are distinguishable, just not *linkable*: every difference is the `user`/`sip` +mirroring, which an unrelated pair of real calls between the same two people would also show. An app +logging usernames and features — including the e2e fixture app `media-call-events-test` — sees two +entries that differ only by log timestamp. + +`divertedBy` does not narrow this. A loop-back leg carries no `Diversion` header — the PBX routes our +own leg back, it does not forward a line — so `divertedBy` is absent on exactly the calls a +correlation would have to recognise. + +## Alternatives considered + +### The app-facing shape — four prototypes + +Four prototypes were built end-to-end. All four emit the same typed events and all four return +`EventResult` from pre-events; they differ **only in how an app subscribes** and **how the engine +dispatches**. + +| Dimension | P1 — per-event interfaces (Pattern A) | P2 — `registerMediaCallManager` | P3 — one interface, two methods | **P4 — one interface, one method per event (chosen)** | +|---|---|---|---|---| +| **Author mental model** | "Implement the handler interface for each event." Same as every other RC app event. | "Fill in the hooks I want on one object and register it." Same as `provideVideoConfProvider`. | "Implement one interface, two methods; `switch` on `context.eventType`." | "Implement one interface; fill in the per-event methods I want." The `IUIKitActionHandler` model. | +| **Subscription** | Implicit, per event. | Explicit, one registration call. | Implicit, but per *group* — one method subscribes all pre or all post events. | Implicit, per event — every member is its own optional method. | +| **Discoverability** | Interface list is the menu. | Best single "here is everything you can hook" surface. | Weakest — the event menu hides one level down in the `eventType` union. | Strong — autocomplete lists every optional method on one interface. | +| **Return-type narrowing** | Per-interface restricted union. | Same, on the object method. | **Lost** — enforced against the whole pre-event union, not per `eventType`. | Per-method restricted union, like P1. | +| **Per-event opt-out** | Don't implement the interface. | Omit the method. | Coarse — a `switch` `default` or a missing case. | Cleanest — omit the method. | +| **Composition across apps** | Inherited from the listener loop. | **Reimplemented** in a manager-manager fan-out. | Inherited. | Inherited. | +| **Engine wiring cost** | Full recipe per event. | One-time scaffold, then just add methods. | One-time, per method group. | One-time scaffold (member, bridge case, envelope, router), then a method plus an envelope union member; a post event needs no manager change. | +| **Fit with existing architecture** | High — it *is* the events architecture. | Medium — provider registration repurposed for events. | Medium-high — a shape no other RC event surface uses. | High — the listener engine plus a shape RC already has. | + +**Why P4.** Media-call events are **broadcast**: every interested app should observe, and several +may veto. VideoConfProvider selects *one* provider by name and RPCs into it, so P2 had to +re-implement the prevent-wins and patch-chaining Pattern A gives for free — the core objection to it. +P3 keeps the listener engine but trades away per-event return-type narrowing, which `EventResult`'s +restricted unions depend on ([ADR 0002](./0002-unified-event-result-for-pre-events.md), decision 5); +if two media-call pre-events ever permit different variants, P3 cannot express it. P4 keeps every +type-level guarantee P1 has, collapses N interfaces into one app-facing surface, and adds no +conceptual novelty because `IUIKitActionHandler` already has that shape. As built it also grows more +cheaply than P1, because the envelope dispatch is scaffolded once for the family (decision 2); what it +gives up in exchange is a per-event `IListenerExecutor` `result` type, since the family shares one +entry. + +A hybrid remains available if both surfaces ever test well: keep P2's author-facing object but have +its registration fan into the listener manager internally, so composition is not duplicated. + +### Rejected — link the loop-back leg to the call it duplicates + +Recorded so the next person does not re-derive it. The proposal was: on the inbound INVITE, correlate +against the still-live outbound leg, persist the verdict on the inbound call as `loopbackOf`, and +surface it on that leg's events. Both legs keep firing; an app that wants one conversation drops the +leg with `loopbackOf` set, and one that wants both joins them on it. + +**The rule.** *This INVITE is a loop-back if `req.callingNumber` belongs to workspace user A and a +not-ended call exists whose caller is `{type: 'user', id: A}` and whose callee is the sip contact for +`req.calledNumber`.* It would need one finder, +`findOneNotEndedByCallerAndSipCallee(callerUid, sipExtension, options?)`, on `IMediaCallsModel`. +Leading the query with `{ ended: false, uids: callerUid, expiresAt: { $gt: now } }` reuses the +existing `{ ended: 1, uids: 1, expiresAt: 1 }` index (`MediaCalls.ts:33`), with the caller/callee +fields as the residual filter — no new index. + +**The ordering is safe.** The outbound document is inserted (`CallDirector.ts:261`) and flipped to +`ringing` (`OutgoingSipCall.ts:125`) *before* `createSipDialog` emits the INVITE (`:136`), so the +record is always present and not-ended when the loop-back arrives. Transfers routed externally +produce the same outbound-then-loop-back pair (`UserActorAgent.onCallTransferred:134` → +`requestCall`), so the match must **not** be conditioned on `parentCallId` being absent. + +**Why it is rejected.** The rule's only reliable half is the caller/extension match, and it has a +false-positive window: an external caller who presents a workspace extension as caller-ID during the +dial window gets their genuinely external call reported as a loop-back of an unrelated outbound one. +Adding `X-RocketChat-Origin-Call-Id` to the outbound INVITE (`OutgoingSipCall.createDialog` already +sets `Referred-By` for transfers, `:142-146`) does not fix this. The header survives only if the +dialplan copies custom headers across the bridge (FreeSWITCH needs `sip_copy_custom_headers`), and it +is spoofable, so it may only *select* among already correlated candidates — otherwise an external +caller presents an arbitrary call id and turns a spoofable header into a claim about who is calling. +Used correctly it reduces to the caller/extension rule plus a tie-break, so it cannot rescue it. +Reporting the wrong pair of calls as one conversation is worse than reporting neither. + +**What was also considered and is moot now.** Whether `loopbackOf` should also be written on the +outbound leg so the pair is navigable from either end (a second write to an already-ringing call, +arriving after that leg's pre event), and whether `duplicateOf` or `sameConversationAs` would be a +better app-facing name than a term describing a PBX routing artefact. + +## Adding an event — the wiring recipe + +Because of decision 2, a **fifth media-call event** is cheaper than a new event elsewhere in the +engine: `AppInterface`, the bridge and the `IListenerExecutor` map are already wired for the whole +family and are not touched again. + +1. `packages/apps-engine/src/definition/metadata/AppMethod.ts` — add the `EXECUTE…` method name. + Media-call events have **no `CHECK…` companion**: the executors call the `EXECUTE…` method + directly and read a `JSONRPC_METHOD_NOT_FOUND` rejection as "this app did not implement it", + which is what makes every member of `IMediaCallHandler` optional. +2. `packages/apps-engine/src/definition/mediaCalls/` — add the context type and the method on + `IMediaCallHandler`; add the member to the `MediaCallEvent` envelope union in `IMediaCallEvent.ts`. +3. `packages/apps-engine/src/definition/mediaCalls/index.ts` — export them. +4. `packages/apps/src/server/managers/AppListenerManager.ts` — **for a post event, nothing**: + `executePostMediaCallEvent` dispatches any envelope member it is handed. For a pre event, add a + branch to `executeMediaCallEvent` and its own serial executor loop, and widen the + `IListenerExecutor` entry's `result` union. +5. Host trigger site — `apps/meteor/server/services/media-call/appEvents.ts`, plus the emitter or + hook subscription in `service.ts`. +6. Rebuild `@rocket.chat/apps`. + +`apps/meteor/app/apps/server/bridges/listeners.ts` needs no edit — its single +`AppInterface.IMediaCallHandler` case already carries the envelope, and payloads arrive app-shaped +from `appEvents.ts` rather than through a converter. `AppImplements` detection is automatic via +`Object.keys(AppInterface)`. + +Adding an event under a **new** `AppInterface` member — the recipe every other event family uses — +additionally costs the enum member, an `IListenerExecutor` entry, a `case` in `executeListener`, the +`HandleEvent` union and `case` in `listeners.ts`, and a `packages/apps/src/converters/` entry plus +its host converter if the payload needs shape mapping. + +Nothing in the recipe hands the handler an accessor. `app.call(method, context)` passes the context +alone; the `IRead` / `IHttp` / `IPersistence` / `IModify` parameters on `IMediaCallHandler`'s methods +are supplied by the app runtime. A media-call event that needed a builder or an extender — as the +message Modify and Extend events get one — would be new work, not a step here. + +## Follow-ups — the remaining phases + +> **Nothing below this heading is decided.** This section is a survey, not a plan: the phases, the +> interface names, the proposed accessor surfaces and the "worked recipes" are sketches recorded so +> the next person starts from the reconnaissance rather than repeating it. Treat every shape here as +> a suggestion open to redesign, and every insertion point as a *candidate* that still has to be +> re-checked against the code when the work is actually picked up. No phase is scheduled and none is +> a commitment of this ADR. +> +> Two things here are firmer than the rest, and are flagged where they appear: the constraint that +> nothing may write `IMediaCall` fields around the guarded model layer, and +> [Adjacent surfaces](#adjacent-surfaces--already-generic-usable-today), which documents capabilities +> that already exist today rather than proposing new ones. + +### Phase 2 — Act + +`IMediaCallModify` action methods plus the remaining post events (created, ringing, accepted, +transferred, DTMF). This needs the EE hook bus scaffolding generalized beyond the single pre-create +hook. + +Remaining post-event insertion points: + +| Event | Insertion point | Payload available | +|---|---|---| +| `IPostMediaCallCreated` | `runOnCallCreatedForAgent` / `agent.onCallCreated` (`CallDirector.ts:376-395`); SIP `IncomingSipCall.ts:138`, `OutgoingSipCall.ts:84` | Full `IMediaCall`, role, contacts | +| `IPostMediaCallRinging` | after `MediaCalls.startRingingById` (`CallSignalProcessor.ts:283-286`) | callId, callee reachability | +| `IPostMediaCallTransferred` | `CallDirector.transferCall` success (`:288-295`) | transferredBy/To, parentCallId | +| `IPostMediaCallNegotiated` | `saveWebrtcSession` success (`CallDirector.ts:179-185`) | SDP state, media state, hold — **exposes SDP, see the SDP note below** | +| `IPostMediaCallDTMF` | `BroadcastAgent.onDTMF` (`ee/packages/media-calls/src/server/BroadcastAgent.ts:42-44`) | tone, duration | + +**`IMediaCallModify`, in rough order of safety:** + +- **Action methods (recommended):** `hangup(callId, reason)`, `transfer(callId, to)`, + `sendDTMF(callId, tone)` — wrappers over `MediaCallDirector.hangup` / `transferCall`. They mirror + real user actions and reuse every existing guard instead of mutating the record. Expose via bridge + `doHangup` / `doTransfer`, gated by `mediaCall.write`. +- **`IModifyCreator.startMediaCall()`:** let an app *place* a call, committing via the existing + `ModifyCreator.finish()` switch on `RocketChatAssociationModel` (`ModifyCreator.ts:122`). Requires + a `MEDIA_CALL` association member and routes to `callServer.requestCall`. +- **`IModifyExtender.extendMediaCall(id)`:** additive metadata only, analogous to + `extendVideoConference`. + +**Never expose raw `IMediaCall` field writes** — the persisted state machine is guarded at the model +layer for race safety (`MediaCalls.ts:80-185`), and bypassing it would corrupt live calls. + +### Phase 2b — `IMediaCallRead` + +Precedent is `IVideoConferenceRead` (a single `getById`); a richer surface is warranted given the +query helpers already on the model (`MediaCalls.ts:41-70,187-227`). Proposed surface: +`getById(callId)`, `getActiveCallsByUser(uid)` (backing `MediaCalls.findAllNotOverByUid` +`:199-210`), `getCallHistory(uid, opts)`, `getNegotiations(callId)`. + +The worked recipe, from the VideoConference precedent: + +- **Definitions:** create `packages/apps-engine/src/definition/accessors/IMediaCallRead.ts` (mirror + `IVideoConferenceRead.ts:7-15`); export it from `accessors/index.ts`; add + `getMediaCallReader(): IMediaCallRead` to `IRead.ts` (mirror `:49`). +- **Engine:** create `packages/apps/base-runtime/src/lib/accessors/read/MediaCallRead.ts` (mirror + `read/VideoConferenceRead.ts:7-13`); create abstract + `packages/apps/src/server/bridges/MediaCallBridge.ts` with a permission-gated `doGetById` (mirror + `VideoConferenceBridge.ts:10-95`); export from `bridges/index.ts`; add + `abstract getMediaCallBridge()` to `AppBridges.ts:100` and to the `Bridge` union (`:30-55`); pass a + `MediaCallRead` into the `Reader` constructed in `accessors/mod.ts:289-305`; add the getter to + `read/Reader.ts` (`:79-81`). +- **Host:** create `apps/meteor/app/apps/server/bridges/mediaCalls.ts` (mirror + `videoConferences.ts:10-76`) and `apps/meteor/app/apps/server/converters/mediaCalls.ts` (mirror + `converters/videoConferences.ts:8-32`); register the bridge in `bridges/bridges.js` and the + converter in `orchestrator.ts:106`. +- **Cross-cutting:** add `mediaCall: { read, write }` to `AppPermissions.ts:106-110`, plus + `defaultPermissions` (`:162-164`) if legacy apps should inherit it. + +Call chain: `IRead.getMediaCallReader()` → `MediaCallRead.getById` → `MediaCallBridge.doGetById` +(permission check) → `AppMediaCallBridge.getById` → converter → `MediaCalls` model. + +### Phase 3 — Intervene + +The remaining pre-hooks, wired into `MediaCallDirector` / `MediaCallServer` and reusing the +`CallRejectedError` rejection contract. + +| Event | Insertion point | What an app could do | Notes | +|---|---|---|---| +| `IPreMediaCallRequested` | `MediaCallServer.requestCall` / `parseCallContacts` (`MediaCallServer.ts:92-123`, impl `:186-262`) | Block a call, reroute (change callee), annotate before the call exists | Permission checks already run here (`:201,225,229,233,243`). This is also the high-leverage place to let apps participate in the injected `permissionCheck` / `isFeatureAvailableForUser` policy callbacks (`IMediaCallServer.ts:79-80`), rather than adding a full accessor. | +| `IPreMediaCallAccepted` | `MediaCallDirector.acceptCall` before `MediaCalls.acceptCallById` (`CallDirector.ts:76`), or `clientHasAccepted` (`CallSignalProcessor.ts:316-322`) | Enforce policy on who may accept | | +| `IPreMediaCallTransferred` | `MediaCallDirector.transferCall` before `MediaCalls.transferCallById` (`CallDirector.ts:288`) | Veto or redirect the transfer target | | +| `IPreMediaCallHangup` | `MediaCallDirector.hangup` before `MediaCalls.hangupCallById` (`CallDirector.ts:409`) | Rare | A veto must tolerate server, error and expiry-driven reasons (`:308-332`, `hangupByServer` `:45-47`). | +| `IPreMediaCallDTMF` | `processDTMF` (`CallSignalProcessor.ts:274-278`) | Intercept DTMF for IVR-style apps | | + +### Phase 4 — Provide (optional, large) + +Pattern B: let an app *back* media calls — an alternate SIP/telephony provider, or supplied +routing/URLs. Today the SIP-vs-internal fork is hard-coded in `MediaCallServer.createCall` +(`:125-134`) on `callee.type`. An `IMediaCallProvider` (mirroring `IVideoConfProvider.ts:11-70`) +with `onCallRequested` / `onCallEnded` / `generateRoute` would require a provider definition, an +`IMediaCallProvidersExtend` accessor, an `AppMediaCallProviderManager`, a bridge, a host registry, +and host call sites in `parseCallContacts` (`:186-262`). This is a significant refactor of the +routing layer and should be a separate initiative. + +### Persistence and associations + +Apps already get private storage via `IPersistence` / `IPersistenceRead`. To let them key records to +a call, add `MEDIA_CALL` to `RocketChatAssociationModel` +(`packages/apps-engine/src/definition/metadata/RocketChatAssociations.ts:1-10`). An app handling the +ended event could then persist per-call analytics with +`createWithAssociation(data, new RocketChatAssociationRecord(RocketChatAssociationModel.MEDIA_CALL, callId))`. +Low cost, high value for CDR, analytics and compliance apps; no engine routing changes. + +### Adjacent surfaces — already generic, usable today + +These need **no** media-call-specific work, and are listed so extension design does not duplicate +them: `INotifier` for ephemeral UI, `ISlashCommandsExtend` (a `/call ` command), +UIKit/contextual bar/action buttons (a call-ended handler could open a survey), +`ISchedulerExtend`/`ISchedulerModify` for reminders and callbacks, and the message hooks — call +outcomes are written as system messages via `saveCallToHistory` / `sendHistoryMessage` +(`service.ts:167-315`), which flow through `sendMessage` and therefore through the existing +`IPreMessageSent*` / `IPostMessageSent` hooks **today**. + +## Cross-cutting concerns for implementers + +- **SDP and sensitive data.** Signal payloads and negotiations carry SDP; the engine already strips + it for logs (`ee/packages/media-calls/src/server/stripSensitiveData.ts:3-24`, applied + `MediaCallServer.ts:66`). Any hook or accessor exposing signals or negotiations to apps must apply + the same stripping and sit behind a distinct permission. +- **SIP and internal calls are uniform at the director.** Both providers funnel every state change + through the *same* `MediaCallDirector` methods, so hooks placed there fire uniformly regardless of + provider (the fork is only at `MediaCallServer.createCall:125-134`). Place post-hooks in the + director, not in the agents, to avoid provider-specific gaps. +- **Non-user-driven transitions.** Expiry (`CallDirector.ts:308-332`), errors and `hangupByServer` + (`:45-47`) end calls with no user actor. Hook payloads must tolerate `ServerActor` + (`IMediaCall.ts:17-20`) and a non-user `endedBy`. +- **No multi-party participant model.** Calls are strictly `kind:'direct'` two-actor + (`IMediaCall.ts:37,44-45`). "Join/leave" maps to reachable/ringing → accept → active → hangup; + the client-side participant abstractions in `media-signaling` are not persisted. Multi-party would + be a much larger schema change. +- **Multi-instance and performance.** Events fan out across instances via the microservice bus and + the `BroadcastActorAgent` mechanism. Post-hooks stay fire-and-forget, as the existing listener + contract is, to avoid adding latency to real-time call signaling. +- **EE gating.** Media calls are enterprise plus module `teams-voip` + (`apps/meteor/ee/server/settings/voip.ts:7-8`). Host trigger sites must be safe when the feature + or module is disabled. +- **Build coupling.** The host imports the engine from `@rocket.chat/apps/dist` — rebuild + `packages/apps` after engine edits. + +## Implementation record + +- App-facing definitions: `packages/apps-engine/src/definition/mediaCalls/` — `IMediaCall` + (including `MediaCallOrigin`), `IMediaCallHandler`, `IMediaCallEvent`, the four context types, + `MediaCallCreateEventResult`, `MediaCallHangupReason` and the `isMissedCall` / `isRejectedCall` / + `isAnsweredCall` helpers. +- Enums: `packages/apps-engine/src/definition/metadata/{AppInterface,AppMethod}.ts`. +- Dispatch: `packages/apps/src/server/managers/AppListenerManager.ts`, covered by + `packages/apps/tests/server/managers/AppListenerManager.mediaCalls.test.ts`. The post events are + the one executor in that manager that does *not* await each app in turn: nothing reads their + result, so `executePostMediaCallEvent` starts every handler and then awaits the set. Awaiting + inside the loop would let one app that stalls until its runtime timeout delay the notification of + every app behind it. The pre event stays serial, because `prevent` has to short-circuit and + `patch` has to chain. +- Host bridge: `apps/meteor/app/apps/server/bridges/listeners.ts`. +- Host trigger, mappers and `getCallOrigin`: `apps/meteor/server/services/media-call/appEvents.ts`, + wired in `service.ts`; covered by + `apps/meteor/tests/unit/server/services/media-call/appEvents.spec.ts` — `origin` for each of the + three contact-type combinations, on the pre context and on `toAppMediaCall`. +- EE hook bus: `IMediaCallServer.setHooks` / `runPreCallCreatedHook`, consulted in + `MediaCallDirector.createCall` (`ee/packages/media-calls/src/`). +- Rejection feedback path: `packages/media-signaling/src/definition/call/common.ts`, + `.../signals/server/rejected-call-request.ts`, + `packages/ui-voip/src/providers/useCallRejectionToast.ts`. +- E2E: `apps/meteor/tests/e2e/apps/media-call-events.spec.ts`, against the `media-call-events-test` + fixture app (`apps/meteor/tests/data/apps/app-packages/`). It covers WebRTC calls reaching the app + with `origin === 'internal'`. The SIP paths need a PBX in CI, which the suite does not have — a + deliberate gap, recorded rather than hidden. +- `packages/apps-engine/definition/` is build output and gitignored; only `src/definition/` is + edited. + +## Reference index + +### Media call feature + +- Persisted model: `packages/core-typings/src/mediaCalls/IMediaCall.ts`, `IMediaCallNegotiation.ts` +- Model methods (the guarded state machine): `packages/models/src/models/MediaCalls.ts`, + `MediaCallNegotiations.ts`; typings `packages/model-typings/src/models/IMediaCallsModel.ts` +- EE engine: `ee/packages/media-calls/src/server/{MediaCallServer,CallDirector,CastDirector,BroadcastAgent,configuration,injection,stripSensitiveData}.ts`; + `internal/{InternalCallProvider,SignalProcessor}.ts`; + `internal/agents/{UserActorAgent,CallSignalProcessor}.ts`; `sip/providers/*`; `server/signals/*` +- EE engine definitions: `ee/packages/media-calls/src/definition/{IMediaCallServer,IMediaCallAgent,IMediaCallCastDirector,common}.ts` +- Signaling protocol and client model: `packages/media-signaling/src/definition/{call/*,signals/*,client.ts}`; + client runtime `packages/media-signaling/src/lib/{Session,Call,TransportWrapper}.ts` +- **Integration seam:** `apps/meteor/server/services/media-call/service.ts` (emitter wiring `:42-54`) +- Core-service contract: `packages/core-services/src/types/IMediaCallService.ts`; proxy + `packages/core-services/src/index.ts:194`; event `packages/core-services/src/events/Events.ts:307` +- Transport: `apps/meteor/server/modules/notifications/notifications.module.ts:294-301`; + `apps/meteor/server/modules/listeners/listeners.module.ts:148-150`; client + `packages/ui-voip/src/providers/useMediaSessionInstance.ts:287-308` +- REST: `apps/meteor/server/api/v1/media-calls.ts` +- Settings and gating: `apps/meteor/ee/server/settings/voip.ts`; permissions in `service.ts:470-478` +- UI: `packages/ui-voip/src/**` + +### apps-engine + +- Event enums: `packages/apps-engine/src/definition/metadata/{AppInterface,AppMethod}.ts` +- Handler interface templates: `packages/apps-engine/src/definition/messages/{IPostMessageSent,IPreMessageSentPrevent,IPreMessageSentExtend,IPreMessageSentModify}.ts` +- Listener manager: `packages/apps/src/server/managers/AppListenerManager.ts` +- Accessor interfaces: `packages/apps-engine/src/definition/accessors/{IRead,IModify,IVideoConferenceRead,IModifyCreator,IModifyUpdater,IModifyExtender,IModifyDeleter,IPersistence,IPersistenceRead,INotifier}.ts` +- Accessor impls: `packages/apps/base-runtime/src/lib/accessors/read/{Reader,VideoConferenceRead,RoomRead}.ts`, + `.../accessors/modify/{ModifyCreator,ModifyUpdater}.ts`, `.../accessors/{Persistence,notifier}.ts`; + assembly `packages/apps/base-runtime/src/lib/accessors/mod.ts` +- Bridges (abstract): `packages/apps/src/server/bridges/{AppBridges,BaseBridge,VideoConferenceBridge,RoomBridge,MessageBridge,ListenerBridge}.ts` +- Provider pattern (precedent): `packages/apps-engine/src/definition/videoConfProviders/IVideoConfProvider.ts`; + `packages/apps/src/server/managers/AppVideoConfProviderManager.ts`; + `packages/apps-engine/src/definition/accessors/IVideoConfProvidersExtend.ts`, implemented inline in + `packages/apps/base-runtime/src/lib/accessors/mod.ts` +- Associations and permissions: `packages/apps-engine/src/definition/metadata/{RocketChatAssociations,AppPermissions}.ts` +- Host bridges, converters, orchestrator: `apps/meteor/app/apps/server/bridges/{bridges.js,listeners.ts,videoConferences.ts,messages.ts,rooms.ts}`; + `apps/meteor/app/apps/server/converters/*`; `apps/meteor/ee/server/apps/orchestrator.ts` +- Trigger idiom reference: `apps/meteor/server/lib/messages/sendMessage.ts:241,247-257,287-291` diff --git a/ee/packages/media-calls/src/definition/IMediaCallServer.ts b/ee/packages/media-calls/src/definition/IMediaCallServer.ts index 37b119e4df5c3..0107c6718e720 100644 --- a/ee/packages/media-calls/src/definition/IMediaCallServer.ts +++ b/ee/packages/media-calls/src/definition/IMediaCallServer.ts @@ -1,6 +1,12 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IMediaCall, IUser, MediaCallContact } 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 { + CallFeature, + CallRejectionMessage, + ClientMediaSignal, + ClientMediaSignalBody, + ServerMediaSignal, +} from '@rocket.chat/media-signaling'; import type { InternalCallParams, SignalProcessingOptions } from './common'; @@ -9,13 +15,51 @@ export type VoipPushNotificationEventType = 'new' | 'answer' | 'end'; export type MediaCallServerEvents = { callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }; - callActivated: { callId: string; uids: IUser['_id'][] }; - callEnded: { callId: string; uids: IUser['_id'][] }; + /** + * The three lifecycle events carry the call as it was when the transition happened, not just + * its id: a listener that reads the call again may already see a later transition, and would + * then describe the wrong thing. + */ + callAccepted: { call: IMediaCall }; + callActivated: { call: IMediaCall }; + callEnded: { call: IMediaCall }; signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal }; historyUpdate: { callId: string }; pushNotificationRequest: { callId: string; event: VoipPushNotificationEventType }; }; +export type PreCallCreatedHookParams = { + caller: MediaCallContact; + callee: MediaCallContact; + createdBy: MediaCallContact; + features: CallFeature[]; + parentCallId?: string; + divertedBy?: MediaCallContact; +}; + +export type PreCallCreatedHookResult = + | { + prevented: true; + /** Recorded in the server logs, not shown to anyone. */ + reason?: string; + /** Shown to whoever requested the call, when the hook has something to tell them. */ + message?: CallRejectionMessage; + } + | { + prevented: false; + /** Replaces the requested features when present; still subject to the workspace's feature rules. */ + features?: CallFeature[]; + }; + +/** + * Hooks the server may run at points of the call lifecycle that need to be + * awaited. Injected by the host so that this package doesn't have to know what is + * on the other side of them - today it's the Apps-Engine. + */ +export type MediaCallHooks = { + onPreCallCreated?: (params: PreCallCreatedHookParams) => Promise; +}; + export interface IMediaCallServerSettings { internalCalls: { requireExtensions: boolean; @@ -58,6 +102,9 @@ export interface IMediaCallServer { hangupExpiredCalls(): Promise; scheduleExpirationCheck(): void; configure(settings: IMediaCallServerSettings): void; + setHooks(hooks: MediaCallHooks): void; + + runPreCallCreatedHook(params: PreCallCreatedHookParams): Promise; 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 0520fe390aa69..0482b6b491d5d 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 { CallFeature, CallRejectedReason, CallService } from '@rocket.chat/media-signaling'; +import type { CallFeature, CallRejectedReason, CallRejectionMessage, CallService } from '@rocket.chat/media-signaling'; export type MinimalUserData = Pick; @@ -25,6 +25,12 @@ export class CallRejectedError extends Error { constructor( public readonly callRejectedReason: CallRejectedReason, message?: string, + /** + * Passed on to whoever requested the call, when there is something to tell + * them beyond `callRejectedReason`. `message` is the error's own text and + * stays internal. + */ + public readonly rejectionMessage?: CallRejectionMessage, ) { super(message || 'call-rejected'); } diff --git a/ee/packages/media-calls/src/server/CallDirector.ts b/ee/packages/media-calls/src/server/CallDirector.ts index 73858a2598f7c..c0d68475fa1b9 100644 --- a/ee/packages/media-calls/src/server/CallDirector.ts +++ b/ee/packages/media-calls/src/server/CallDirector.ts @@ -13,8 +13,10 @@ import type { InsertionModel } from '@rocket.chat/model-typings'; import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models'; import { getCastDirector, getMediaCallServer } from './injection'; +import { SIP_CALL_FEATURES } from '../constants'; import type { IMediaCallAgent } from '../definition/IMediaCallAgent'; import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector'; +import { CallRejectedError } from '../definition/common'; import type { InternalCallParams, MediaCallHeader } from '../definition/common'; import { logger } from '../logger'; @@ -29,6 +31,20 @@ export type CreateCallParams = InternalCallParams & { // expiration checks by call id const scheduledExpirationChecks = new Map>(); +/** + * What the transport can carry is not up for negotiation: a call that goes through the PBX only + * ever has the features SIP supports, whoever asked for the others. The providers already clamp + * the request, but an app may change the feature list afterwards, so the clamp is applied again + * on the way out of the hook. + */ +function getFeaturesSupportedByTransport(caller: MediaCallContact, callee: MediaCallContact, features: CallFeature[]): CallFeature[] { + if (caller.type !== 'sip' && callee.type !== 'sip') { + return features; + } + + return features.filter((feature) => SIP_CALL_FEATURES.includes(feature)); +} + class MediaCallDirector { public async hangup(call: IMediaCall, actorAgent: IMediaCallAgent, reason: CallHangupReason): Promise { const { actor: endedBy } = actorAgent; @@ -48,14 +64,14 @@ class MediaCallDirector { public async activate(call: IMediaCall, actorAgent: IMediaCallAgent): Promise { logger.debug({ msg: 'MediaCallDirector.activateCall', role: actorAgent.role }); - const stateResult = await MediaCalls.activateCallById(call._id, this.getNewExpirationTime()); - if (!stateResult.modifiedCount) { + const activatedCall = await MediaCalls.activateCallById(call._id, this.getNewExpirationTime()); + if (!activatedCall) { return; } logger.info({ msg: 'Call was flagged as active', callId: call._id }); this.scheduleExpirationCheckByCallId(call._id); - getMediaCallServer().emitter.emit('callActivated', { callId: call._id, uids: call.uids }); + getMediaCallServer().emitter.emit('callActivated', { call: activatedCall }); return actorAgent.oppositeAgent?.onCallActive(call._id); } @@ -72,20 +88,16 @@ class MediaCallDirector { 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) { + const updatedCall = await MediaCalls.acceptCallById(call._id, acceptData, this.getNewExpirationTime()); + // Nothing came back: the call was no longer ringing + if (!updatedCall) { return false; } logger.info({ msg: 'Call was flagged as accepted', callId: call._id }); this.scheduleExpirationCheckByCallId(call._id); - const updatedCall = await MediaCalls.findOneById(call._id); - if (!updatedCall) { - logger.error({ msg: 'Unable to find up to date call data', callId: call._id }); - return false; - } + getMediaCallServer().emitter.emit('callAccepted', { call: updatedCall }); await calleeAgent.onCallAccepted(updatedCall); await calleeAgent.oppositeAgent?.onCallAccepted(updatedCall); @@ -209,7 +221,22 @@ class MediaCallDirector { callerAgent.oppositeAgent = calleeAgent; calleeAgent.oppositeAgent = callerAgent; - const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature)); + const createdBy = requestedBy || caller; + + // Last look before the call exists: the host may still block it or change the requested features + const hookResult = await getMediaCallServer().runPreCallCreatedHook({ caller, callee, createdBy, features, parentCallId, divertedBy }); + if (hookResult.prevented) { + logger.info({ + msg: 'Call creation was prevented', + reason: hookResult.reason, + callerType: caller.type, + calleeType: callee.type, + }); + throw new CallRejectedError('forbidden', hookResult.reason, hookResult.message); + } + + const requestedFeatures = getFeaturesSupportedByTransport(caller, callee, hookResult.features || features); + const allowedFeatures = requestedFeatures.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature)); const call: Omit = { // Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit) _id: randomUUID(), @@ -217,7 +244,7 @@ class MediaCallDirector { kind: 'direct', state: 'none', - createdBy: requestedBy || caller, + createdBy, createdAt: new Date(), caller, @@ -388,7 +415,7 @@ class MediaCallDirector { ...(endedBy && { endedBy }), }; - const result = await MediaCalls.hangupCallById(callId, cleanedParams).catch((err) => { + const endedCall = await MediaCalls.hangupCallById(callId, cleanedParams).catch((err) => { logger.error({ msg: 'Failed to hangup a call.', callId, @@ -399,17 +426,16 @@ class MediaCallDirector { throw err; }); - const ended = Boolean(result.modifiedCount); - if (ended) { - logger.info({ msg: 'Call was flagged as ended', callId, reason: params?.reason }); - getMediaCallServer().updateCallHistory({ callId }); - const call = await MediaCalls.findOneById>(callId, { projection: { uids: 1 } }); - if (call) { - getMediaCallServer().emitter.emit('callEnded', { callId, uids: call.uids }); - } + // Nothing came back: the call had already ended + if (!endedCall) { + return false; } - return ended; + logger.info({ msg: 'Call was flagged as ended', callId, reason: params?.reason }); + getMediaCallServer().updateCallHistory({ callId }); + getMediaCallServer().emitter.emit('callEnded', { call: endedCall }); + + return true; } public async hangupCallByIdAndNotifyAgents( diff --git a/ee/packages/media-calls/src/server/MediaCallServer.ts b/ee/packages/media-calls/src/server/MediaCallServer.ts index 86aaa932db858..1e551e98f0a3a 100644 --- a/ee/packages/media-calls/src/server/MediaCallServer.ts +++ b/ee/packages/media-calls/src/server/MediaCallServer.ts @@ -1,8 +1,9 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import type { CallFeature, CallRejectedReason, + CallRejectionMessage, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal, @@ -14,7 +15,10 @@ import { stripSensitiveDataFromSignal } from './stripSensitiveData'; import type { IMediaCallServer, IMediaCallServerSettings, + MediaCallHooks, MediaCallServerEvents, + PreCallCreatedHookParams, + PreCallCreatedHookResult, VoipPushNotificationEventType, } from '../definition/IMediaCallServer'; import { CallRejectedError } from '../definition/common'; @@ -35,6 +39,8 @@ export class MediaCallServer implements IMediaCallServer { private settings: IMediaCallServerSettings; + private hooks: MediaCallHooks = {}; + public emitter: Emitter; constructor() { @@ -90,8 +96,10 @@ export class MediaCallServer implements IMediaCallServer { await this.createCall(fullParams); } catch (error) { let rejectionReason: CallRejectedReason = 'unsupported'; + let rejectionMessage: CallRejectionMessage | undefined; if (error && typeof error === 'object' && error instanceof CallRejectedError) { rejectionReason = error.callRejectedReason; + rejectionMessage = error.rejectionMessage; } else { logger.error({ msg: 'Failed to create a requested call', params, err: error }); } @@ -106,6 +114,7 @@ export class MediaCallServer implements IMediaCallServer { callId: originalId, toContractId: params.requestedBy.contractId, reason: rejectionReason, + ...(rejectionMessage && { message: rejectionMessage }), }); } else { throw error; @@ -142,6 +151,22 @@ export class MediaCallServer implements IMediaCallServer { this.settings = settings; } + public setHooks(hooks: MediaCallHooks): void { + this.hooks = hooks; + } + + /** + * Runs the host's pre-call-created hook, if there is one. Errors are not caught: + * a hook that fails to decide must not let the call through. + */ + public async runPreCallCreatedHook(params: PreCallCreatedHookParams): Promise { + if (!this.hooks.onPreCallCreated) { + return { prevented: false }; + } + + return this.hooks.onPreCallCreated(params); + } + public async permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise { return this.settings.permissionCheck(uid, callType); } @@ -221,6 +246,10 @@ export class MediaCallServer implements IMediaCallServer { } } + // The call's `createdBy` is derived from the requester, so it needs the contact + // information too - see parseRequesterContact. + const requestedBy = params.requestedBy && (await this.parseRequesterContact(params.requestedBy, caller)); + return { ...params, caller: { @@ -228,9 +257,33 @@ export class MediaCallServer implements IMediaCallServer { contractId: params.caller.contractId, }, callee, + ...(requestedBy && { requestedBy }), }; } + /** + * Fills in the contact information of the user who requested the call, which reaches + * the server carrying nothing but an id and a contract. + * + * The requester becomes the call's `createdBy`, which is stored on the call, sent to + * clients as `transferredBy` and handed to the host's pre-call-created hook, so it has + * to carry the same details the caller and callee do. Calls created by a transfer + * already got theirs from the call being transferred; without this, every other call + * ends up with a `createdBy` that has no username on it. + */ + private async parseRequesterContact(requestedBy: MediaCallSignedContact, caller: MediaCallContact): Promise { + // On anything that isn't a transfer, the requester is the caller themselves, whose + // contact information was just loaded + if (requestedBy.type === caller.type && requestedBy.id === caller.id) { + return { ...caller, ...requestedBy }; + } + + const contact = await mediaCallDirector.cast.getContactForActor(requestedBy, { requiredType: requestedBy.type }); + + // The requester's own contract must survive: it is what the server signs rejections back to + return contact ? { ...contact, ...requestedBy } : requestedBy; + } + private getCalleeContactOptions(): GetActorContactOptions { if (!this.settings.sip.enabled) { return { diff --git a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts index 8bff9a4b688ee..956333a66171a 100644 --- a/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts +++ b/ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts @@ -6,6 +6,7 @@ import type Srf from 'drachtio-srf'; import { BaseSipCall } from './BaseSipCall'; import { SIP_CALL_FEATURES } from '../../constants'; +import { CallRejectedError } from '../../definition/common'; import { logger } from '../../logger'; import { BroadcastActorAgent } from '../../server/BroadcastAgent'; import { mediaCallDirector } from '../../server/CallDirector'; @@ -99,14 +100,24 @@ export class IncomingSipCall extends BaseSipCall { throw new SipError(SipErrorCodes.NOT_FOUND, 'Callee agent not found'); } - const call = await mediaCallDirector.createCall({ - caller, - callee, - callerAgent, - calleeAgent, - features: SIP_CALL_FEATURES, - ...(divertedBy && { divertedBy }), - }); + const call = await mediaCallDirector + .createCall({ + caller, + callee, + callerAgent, + calleeAgent, + features: SIP_CALL_FEATURES, + ...(divertedBy && { divertedBy }), + }) + .catch((err) => { + // An incoming invite needs an answer, and only SipErrors are forwarded to it + if (err instanceof CallRejectedError) { + logger.debug({ msg: 'incoming sip call was rejected', reason: err.callRejectedReason }); + throw new SipError(SipErrorCodes.FORBIDDEN, err.message); + } + + throw err; + }); const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer); diff --git a/packages/apps-engine/src/definition/eventResult/EventResult.ts b/packages/apps-engine/src/definition/eventResult/EventResult.ts new file mode 100644 index 0000000000000..ce99d36227f38 --- /dev/null +++ b/packages/apps-engine/src/definition/eventResult/EventResult.ts @@ -0,0 +1,60 @@ +/** + * Reserved discriminator stamped by the `EventResult.*` factories below. + */ +export const EVENT_RESULT_KIND = 'EventResult'; + +interface IMarker { + '@kind': typeof EVENT_RESULT_KIND; +} + +export type I18nMessage = { + key: string; + args?: { [key: string]: string | number }; +}; + +/** + * Author-facing, marker-free union — the type app authors annotate a handler's + * return type against (directly, or via a per-event restricted alias). + */ +export type EventResult = + | { type: 'pass' } + | { type: 'patch'; patch: Partial } + | ({ type: 'prevent' } & ({ reason: string } | { i18n: I18nMessage })); + +/** Branded variant returned by `EventResult.pass()`. */ +export type PassEventResult = IMarker & { type: 'pass' }; + +/** Branded variant returned by `EventResult.patch()`. */ +export type PatchEventResult = IMarker & { type: 'patch'; patch: Partial }; + +/** Branded variant returned by `EventResult.prevent()`. */ +export type PreventEventResult = IMarker & { type: 'prevent' } & ({ reason: string } | { i18n: I18nMessage }); + +/** + * The shape that actually crosses the JSON-RPC boundary and that + * `isEventResult()` recognizes — `EventResult` widened with the `@kind` marker. + */ +export type MarkedEventResult = PassEventResult | PatchEventResult | PreventEventResult; + +/** + * Companion-object factories. `EventResult` is simultaneously the marker-free + * union *type* above and this factory *value* namespace (same name, separate + * type/value namespaces — no declaration-merging trick needed). Each factory + * stamps `@kind` and returns a branded per-variant type so that a handler whose + * return type is a restricted union (e.g. `pass | patch`) fails to typecheck if + * an author returns a disallowed variant (e.g. `prevent`). + */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- the union type and the factory namespace share a name on purpose +export const EventResult = { + pass(): PassEventResult { + return { '@kind': EVENT_RESULT_KIND, 'type': 'pass' }; + }, + + patch(patch: Partial): PatchEventResult { + return { '@kind': EVENT_RESULT_KIND, 'type': 'patch', patch }; + }, + + prevent(input: { reason: string } | { i18n: I18nMessage }): PreventEventResult { + return { '@kind': EVENT_RESULT_KIND, 'type': 'prevent', ...input }; + }, +}; diff --git a/packages/apps-engine/src/definition/eventResult/index.ts b/packages/apps-engine/src/definition/eventResult/index.ts new file mode 100644 index 0000000000000..c3eecd3f3975e --- /dev/null +++ b/packages/apps-engine/src/definition/eventResult/index.ts @@ -0,0 +1,3 @@ +export { EventResult, EVENT_RESULT_KIND } from './EventResult'; +export type { PassEventResult, PatchEventResult, PreventEventResult, MarkedEventResult, I18nMessage } from './EventResult'; +export { isEventResult } from './isEventResult'; diff --git a/packages/apps-engine/src/definition/eventResult/isEventResult.ts b/packages/apps-engine/src/definition/eventResult/isEventResult.ts new file mode 100644 index 0000000000000..c1c21d7247d52 --- /dev/null +++ b/packages/apps-engine/src/definition/eventResult/isEventResult.ts @@ -0,0 +1,10 @@ +import { EVENT_RESULT_KIND } from './EventResult'; +import type { MarkedEventResult } from './EventResult'; + +/** + * Runtime guard for the `EventResult` marker. Must run *before* any legacy + * `typeof result === 'object'` / truthiness branch at a consumption site + */ +export function isEventResult(value: unknown): value is MarkedEventResult { + return typeof value === 'object' && value !== null && (value as Record)['@kind'] === EVENT_RESULT_KIND; +} diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts new file mode 100644 index 0000000000000..243d16cf0f10d --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCall.ts @@ -0,0 +1,107 @@ +import type { MediaCallHangupReason } from './MediaCallHangupReason'; + +/** A media-call capability, e.g. `'audio'`, `'video'`, `'screen-share'`. */ +export type MediaCallFeature = string; + +/** Media calls happen between workspace users and/or external SIP endpoints. */ +export type MediaCallActorType = 'user' | 'sip'; + +/** The states a call may be persisted in. */ +export type MediaCallState = 'none' | 'ringing' | 'accepted' | 'active' | 'hangup'; + +/** + * How a call reaches the outside world, and which side opened it. A call between + * two workspace users is `'internal'` unless the workspace routes internal calls + * through the PBX, in which case the leg Rocket.Chat sends out is + * `'sip-outbound'` like any other external call. + */ +export type MediaCallOrigin = 'internal' | 'sip-outbound' | 'sip-inbound'; + +/** + * Whoever acted on a call. `'server'` covers the transitions that have no human + * actor behind them — expiration, internal errors and forced hangups. + */ +export interface IMediaCallActor { + type: MediaCallActorType | 'server'; + id: string; +} + +/** + * One of the two sides of a call. Either side may be an external SIP endpoint + * instead of a workspace user, so always check `type` before treating `id` as a + * user id. + * + * The per-session signing token of the contact is deliberately absent: it is a + * credential, and it never crosses into an app. + */ +export interface IMediaCallContact { + type: MediaCallActorType; + id: string; + username?: string; + displayName?: string; + sipExtension?: string; +} + +/** + * A media call — the 1:1 direct audio/video calls between two contacts, as + * opposed to a video conference. Read-only snapshot of the call as it was when + * the event was emitted. + */ +export interface IMediaCall { + id: string; + service: 'webrtc'; + kind: 'direct'; + state: MediaCallState; + + /** Whether the call travels over the PBX, and which side opened it. */ + origin: MediaCallOrigin; + + /** Who requested the call — the caller, except on transfers. */ + createdBy: IMediaCallContact; + createdAt: Date; + + caller: IMediaCallContact; + callee: IMediaCallContact; + + /** The features this call may use. Values are final once the call is accepted. */ + features: MediaCallFeature[]; + + /** Ids of the workspace users on the call; external SIP endpoints are not listed here. */ + uids: string[]; + + ended: boolean; + endedAt?: Date; + endedBy?: IMediaCallActor; + /** Why the call ended. The known values are not exhaustive — see {@link MediaCallHangupReason}. */ + hangupReason?: MediaCallHangupReason; + + /** When the callee accepted the call. */ + acceptedAt?: Date; + /** When either side first reported media flowing. */ + activatedAt?: Date; + + /** Set when this call replaced another one through a transfer. */ + parentCallId?: string; + + /** + * The party whose line diverted the call, when the call reached its callee + * because the PBX forwarded it. A diversion is not a transfer: the call has no + * `parentCallId`, because there is no earlier call it replaced. Clients label a + * diverted call as transferred by this contact, so an app that reconciles what + * the user sees should read it the same way. + */ + divertedBy?: IMediaCallContact; +} + +/** + * A call snapshot taken once media was flowing, so `activatedAt` is set. It is + * the call as the update that emitted the event wrote it, not as the call stands + * now — a call that ended a moment later still reports the state it started in. + */ +export type IActiveMediaCall = Omit & { activatedAt: Date }; + +/** A call snapshot taken once the callee accepted, so `acceptedAt` is set. */ +export type IAcceptedMediaCall = Omit & { acceptedAt: Date }; + +/** A call snapshot taken after the call ended, so `ended` and `endedAt` are set. */ +export type IEndedMediaCall = Omit & { ended: true; endedAt: Date }; diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCallEndedContext.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCallEndedContext.ts new file mode 100644 index 0000000000000..c9c2ac065eaeb --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCallEndedContext.ts @@ -0,0 +1,23 @@ +import type { IEndedMediaCall } from './IMediaCall'; + +/** + * Context of `executePostMediaCallEnded`. Every call ends through this event, + * including the ones no user ended — expiration, transport errors and transfers + * report a `'server'` actor in `call.endedBy`. + * + * Why the call ended is `call.hangupReason`, who ended it is `call.endedBy`, and + * when is `call.endedAt`, which this event guarantees is set. `call.endedBy` is + * absent when the call was ended by something that isn't an identifiable actor. + * + * There is no separate event for a call nobody answered. Use `isMissedCall`, + * `isRejectedCall` and `isAnsweredCall` to tell the outcomes apart. + */ +export interface IMediaCallEndedContext { + call: IEndedMediaCall; + /** + * How long media was flowing, in milliseconds. `0` for calls that never became + * active. It is not part of the call: the call carries the two timestamps it is + * computed from. + */ + durationMs: number; +} diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCallEvent.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCallEvent.ts new file mode 100644 index 0000000000000..44ec628ea3680 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCallEvent.ts @@ -0,0 +1,40 @@ +import type { IMediaCallEndedContext } from './IMediaCallEndedContext'; +import type { IMediaCallParticipantJoinedContext } from './IMediaCallParticipantJoinedContext'; +import type { IMediaCallStartedContext } from './IMediaCallStartedContext'; +import type { IPreMediaCallCreatedContext } from './IPreMediaCallCreatedContext'; +import type { I18nMessage } from '../eventResult'; +import type { AppMethod } from '../metadata'; + +/** + * Envelope used to dispatch a media-call event to the apps that implement + * `IMediaCallHandler`. + * + * Every media-call event travels under a single `AppInterface` member, as UIKit + * interactions do, because `IMediaCallHandler` is one interface with one optional + * method per event: the interface is the subscription and `method` selects which + * of its methods to call. Apps never see this envelope — the listener manager + * hands the handler its `context` alone. + */ +export type MediaCallEvent = + | { method: AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED; context: IPreMediaCallCreatedContext } + | { method: AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED; context: IMediaCallStartedContext } + | { method: AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED; context: IMediaCallParticipantJoinedContext } + | { method: AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED; context: IMediaCallEndedContext }; + +/** + * What the pre-media-call-create event resolved to once every app had its say: + * either the first app to `prevent` the call, or the context as patched by all of + * them. + */ +export type PreMediaCallCreatedOutcome = + | { + prevented: true; + /** The app that prevented the call. */ + appId: string; + reason?: string; + i18n?: I18nMessage; + } + | { + prevented: false; + context: IPreMediaCallCreatedContext; + }; diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCallHandler.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCallHandler.ts new file mode 100644 index 0000000000000..4492dfa980aeb --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCallHandler.ts @@ -0,0 +1,70 @@ +import type { IMediaCallEndedContext } from './IMediaCallEndedContext'; +import type { IMediaCallParticipantJoinedContext } from './IMediaCallParticipantJoinedContext'; +import type { IMediaCallStartedContext } from './IMediaCallStartedContext'; +import type { IPreMediaCallCreatedContext } from './IPreMediaCallCreatedContext'; +import type { MediaCallCreateEventResult } from './MediaCallEventResult'; +import type { IHttp, IModify, IPersistence, IRead } from '../accessors'; +import { AppMethod } from '../metadata'; + +/** + * The media-call lifecycle events, as one interface with one optional method per + * event — the same shape as `IUIKitActionHandler`. Implementing the interface + * subscribes the app to media calls; implementing a given method subscribes it to + * that event, so an app that only cares about calls ending implements only + * `executePostMediaCallEnded`. + * + * Media calls are the 1:1 direct audio/video calls, not video conferences, and + * they are strictly two-party — see `IMediaCallParticipantJoinedContext` for how + * that shapes the join event. + */ +export interface IMediaCallHandler { + /** + * Called before a media call is created, and awaited: a slow handler delays + * the call from ringing. May `pass`, `patch` the call's requested features, or + * `prevent` the call from being created at all. + * + * The first app to `prevent` wins and the remaining apps are not consulted; + * the caller is told the call was rejected. Patches from every app that + * `patch`es are applied in turn, and the workspace's own feature rules are + * applied afterwards, so a patched-in feature the workspace disallows is still + * dropped. + * + * Throwing rejects the call, and the remaining apps are not consulted: a + * handler that was asked to decide and could not is not a `pass`. Throw only + * where blocking the call is the answer you want. + */ + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]?( + context: IPreMediaCallCreatedContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise; + + /** Called once media is flowing on a call. Fire-and-forget. */ + [AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED]?( + context: IMediaCallStartedContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise; + + /** Called when the callee accepts a call. Fire-and-forget. */ + [AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED]?( + context: IMediaCallParticipantJoinedContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise; + + /** Called once a call has ended, for any reason. Fire-and-forget. */ + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]?( + context: IMediaCallEndedContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise; +} diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCallParticipantJoinedContext.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCallParticipantJoinedContext.ts new file mode 100644 index 0000000000000..c92ddcdf85e39 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCallParticipantJoinedContext.ts @@ -0,0 +1,15 @@ +import type { IAcceptedMediaCall } from './IMediaCall'; + +/** + * Context of `executePostMediaCallParticipantJoined`, emitted when the callee + * accepts the call. + * + * Media calls are strictly two-party (`kind: 'direct'`), so this event fires at + * most once per call and the participant that joined is always `call.callee` — + * there is no server-side participant list to join or leave. The moment they + * joined is `call.acceptedAt`, which this event guarantees is set. The departure + * side of a call is `executePostMediaCallEnded`. + */ +export interface IMediaCallParticipantJoinedContext { + call: IAcceptedMediaCall; +} diff --git a/packages/apps-engine/src/definition/mediaCalls/IMediaCallStartedContext.ts b/packages/apps-engine/src/definition/mediaCalls/IMediaCallStartedContext.ts new file mode 100644 index 0000000000000..94aa1f9014d85 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IMediaCallStartedContext.ts @@ -0,0 +1,12 @@ +import type { IActiveMediaCall } from './IMediaCall'; + +/** + * Context of `executePostMediaCallStarted` — media has been confirmed flowing by + * at least one of the two sides of the call. + * + * The moment media started is `call.activatedAt`, which this event guarantees is + * set. + */ +export interface IMediaCallStartedContext { + call: IActiveMediaCall; +} diff --git a/packages/apps-engine/src/definition/mediaCalls/IPreMediaCallCreatedContext.ts b/packages/apps-engine/src/definition/mediaCalls/IPreMediaCallCreatedContext.ts new file mode 100644 index 0000000000000..40bde9bb70fb8 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/IPreMediaCallCreatedContext.ts @@ -0,0 +1,38 @@ +import type { IMediaCallContact, MediaCallFeature, MediaCallOrigin } from './IMediaCall'; + +/** + * Context of `executePreMediaCallCreated`. The call has been routed — both + * contacts are final and every permission check has already run — but nothing + * has been persisted yet, which is why there is no call id. + */ +export interface IPreMediaCallCreatedContext { + caller: IMediaCallContact; + callee: IMediaCallContact; + /** Who requested the call — the caller, except on transfers. */ + createdBy: IMediaCallContact; + /** + * The features requested for the call. Features the workspace does not allow + * are filtered out after this event runs, so patching a feature in here does + * not bypass workspace configuration. + */ + features: MediaCallFeature[]; + /** + * Whether the call travels over the PBX, and which side opened it. It follows + * from the two contacts, so it is not patchable. + */ + origin: MediaCallOrigin; + /** Set when this call is replacing another one through a transfer. */ + parentCallId?: string; + /** + * Set when the PBX forwarded the call: the party whose line diverted it. A call + * screening app sees a diversion here before the call exists. + */ + divertedBy?: IMediaCallContact; +} + +/** + * The part of the pre-create context an app may `patch`. Contacts are not + * patchable: they are the outcome of routing and of the permission checks that + * ran before this event. + */ +export type MediaCallCreatePatch = Pick; diff --git a/packages/apps-engine/src/definition/mediaCalls/MediaCallEventResult.ts b/packages/apps-engine/src/definition/mediaCalls/MediaCallEventResult.ts new file mode 100644 index 0000000000000..cc6837dc379cb --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/MediaCallEventResult.ts @@ -0,0 +1,9 @@ +import type { MediaCallCreatePatch } from './IPreMediaCallCreatedContext'; +import type { PassEventResult, PatchEventResult, PreventEventResult } from '../eventResult'; + +/** + * The `EventResult` variants the pre-media-call-create event permits — see the + * per-event capability matrix in + * docs/adr/0002-unified-event-result-for-pre-events.md. + */ +export type MediaCallCreateEventResult = PassEventResult | PatchEventResult | PreventEventResult; diff --git a/packages/apps-engine/src/definition/mediaCalls/MediaCallHangupReason.ts b/packages/apps-engine/src/definition/mediaCalls/MediaCallHangupReason.ts new file mode 100644 index 0000000000000..0712e2b713f62 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/MediaCallHangupReason.ts @@ -0,0 +1,66 @@ +/** + * The reasons Rocket.Chat records when a call ends. + * + * This list is a **copy**. The source of truth is `callHangupReasonList` in + * `@rocket.chat/media-signaling`, plus the codes the server passes to + * `hangupByServer`. The Apps-Engine publishes the app-facing SDK on its own and + * must not depend on an internal package, so the values are duplicated here. + * `appEvents.spec.ts` asserts that this copy still covers the original. + */ +export const mediaCallHangupReasonList = [ + /** A user explicitly hung up. */ + 'normal', + /** The client was told the call is over. */ + 'remote', + /** The callee declined the call. */ + 'rejected', + /** The actor was not available. */ + 'unavailable', + /** One side asked for the other to be transferred. */ + 'transfer', + /** The call rang for the maximum duration with no answer. */ + 'not-answered', + /** The server's expiration sweep ended a call that stopped progressing. */ + 'expired', + 'timeout-local-track', + 'timeout-remote-sdp', + 'timeout-local-sdp', + 'timeout-activation', + /** The call state did not progress for too long. */ + 'timeout', + 'signaling-error', + 'service-error', + 'media-error', + 'input-error', + /** An unidentified error. */ + 'error', + /** One of the call's signed users reported they do not know this call. */ + 'unknown', + /** A user asked for a hangup from a different session than the one holding the call. */ + 'another-client', + /** A SIP REFER failed while the call was transferred. */ + 'sip-refer-failed', +] as const; + +/** + * A reason Rocket.Chat is known to record. SIP failures carry the response code + * they came from, e.g. `'sip-error-486'`. + */ +export type KnownMediaCallHangupReason = (typeof mediaCallHangupReasonList)[number] | `sip-error-${string}`; + +/** + * What `hangupReason` may hold. The stored field is free-form text, so a value + * outside {@link KnownMediaCallHangupReason} is possible: never treat the known + * list as exhaustive. Use {@link isKnownMediaCallHangupReason} to narrow before + * an exhaustive `switch`. + */ +export type MediaCallHangupReason = KnownMediaCallHangupReason | (string & Record); + +/** Narrows a stored reason to the set this SDK version documents. */ +export function isKnownMediaCallHangupReason(reason: MediaCallHangupReason | undefined): reason is KnownMediaCallHangupReason { + if (typeof reason !== 'string') { + return false; + } + + return (mediaCallHangupReasonList as readonly string[]).includes(reason) || reason.startsWith('sip-error-'); +} diff --git a/packages/apps-engine/src/definition/mediaCalls/helpers.ts b/packages/apps-engine/src/definition/mediaCalls/helpers.ts new file mode 100644 index 0000000000000..c7c74936b98a9 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/helpers.ts @@ -0,0 +1,61 @@ +import type { IMediaCallEndedContext } from './IMediaCallEndedContext'; + +/** Replaces the call snapshot of an ended-call context with a narrower one. */ +type WithCall = Omit & { call: TCall }; + +type EndedCall = IMediaCallEndedContext['call']; + +/** + * An ended call the callee never joined. `acceptedAt` is absent, and because a + * call only activates out of the `accepted` state, `durationMs` is `0`. + */ +export type IUnansweredMediaCallEndedContext = WithCall & { acceptedAt?: undefined }>; + +/** An ended call the callee joined. `acceptedAt` is the moment they accepted. */ +export type IAnsweredMediaCallEndedContext = WithCall & { acceptedAt: Date }>; + +/** An ended call the callee actively declined. */ +export type IRejectedMediaCallEndedContext = WithCall< + IUnansweredMediaCallEndedContext, + Omit & { hangupReason: 'rejected' } +>; + +/** + * The callee joined this call. + * + * `acceptedAt` is the discriminator, not `hangupReason`: an answered call that + * later fails records an error reason like any other. + */ +export function isAnsweredCall(context: IMediaCallEndedContext): context is IAnsweredMediaCallEndedContext { + return Boolean(context.call.acceptedAt); +} + +/** + * The callee saw this call and declined it. A decline is a deliberate answer, so + * it is *not* a missed call — see {@link isMissedCall}. + */ +export function isRejectedCall(context: IMediaCallEndedContext): context is IRejectedMediaCallEndedContext { + return !isAnsweredCall(context) && context.call.hangupReason === 'rejected'; +} + +/** + * Nobody answered this call, and the callee did not decline it. Covers the ring + * timeout, an unreachable callee, expiry, and every transport failure that ended + * the call before it was accepted. + * + * Do not test `call.hangupReason === 'not-answered'` instead. That value is written + * by the *caller's* client when its ring timeout fires. A caller that closes the tab + * or loses the network first leaves the server's sweep to end the call as + * `'expired'` — the same missed call, a different reason. + * + * ```ts + * public async [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED](context: IMediaCallEndedContext): Promise { + * if (isMissedCall(context)) { + * await this.notifyOfMissedCall(context.call.callee, context.call.caller); + * } + * } + * ``` + */ +export function isMissedCall(context: IMediaCallEndedContext): context is IUnansweredMediaCallEndedContext { + return !isAnsweredCall(context) && !isRejectedCall(context); +} diff --git a/packages/apps-engine/src/definition/mediaCalls/index.ts b/packages/apps-engine/src/definition/mediaCalls/index.ts new file mode 100644 index 0000000000000..ff8f3952b0b64 --- /dev/null +++ b/packages/apps-engine/src/definition/mediaCalls/index.ts @@ -0,0 +1,23 @@ +export type { + IAcceptedMediaCall, + IActiveMediaCall, + IEndedMediaCall, + IMediaCall, + IMediaCallActor, + IMediaCallContact, + MediaCallActorType, + MediaCallFeature, + MediaCallOrigin, + MediaCallState, +} from './IMediaCall'; +export type { IPreMediaCallCreatedContext, MediaCallCreatePatch } from './IPreMediaCallCreatedContext'; +export type { IMediaCallStartedContext } from './IMediaCallStartedContext'; +export type { IMediaCallParticipantJoinedContext } from './IMediaCallParticipantJoinedContext'; +export type { IMediaCallEndedContext } from './IMediaCallEndedContext'; +export { mediaCallHangupReasonList, isKnownMediaCallHangupReason } from './MediaCallHangupReason'; +export type { MediaCallHangupReason, KnownMediaCallHangupReason } from './MediaCallHangupReason'; +export { isMissedCall, isRejectedCall, isAnsweredCall } from './helpers'; +export type { IUnansweredMediaCallEndedContext, IAnsweredMediaCallEndedContext, IRejectedMediaCallEndedContext } from './helpers'; +export type { MediaCallCreateEventResult } from './MediaCallEventResult'; +export type { IMediaCallHandler } from './IMediaCallHandler'; +export type { MediaCallEvent, PreMediaCallCreatedOutcome } from './IMediaCallEvent'; diff --git a/packages/apps-engine/src/definition/metadata/AppInterface.ts b/packages/apps-engine/src/definition/metadata/AppInterface.ts index ba970952095b4..73ab194cc7ec1 100644 --- a/packages/apps-engine/src/definition/metadata/AppInterface.ts +++ b/packages/apps-engine/src/definition/metadata/AppInterface.ts @@ -61,4 +61,6 @@ export enum AppInterface { IPostUserLoggedIn = 'IPostUserLoggedIn', IPostUserLoggedOut = 'IPostUserLoggedOut', IPostUserStatusChanged = 'IPostUserStatusChanged', + // Media calls + IMediaCallHandler = 'IMediaCallHandler', } diff --git a/packages/apps-engine/src/definition/metadata/AppMethod.ts b/packages/apps-engine/src/definition/metadata/AppMethod.ts index 4f69c4dafe680..86427f7187dbc 100644 --- a/packages/apps-engine/src/definition/metadata/AppMethod.ts +++ b/packages/apps-engine/src/definition/metadata/AppMethod.ts @@ -107,6 +107,11 @@ export enum AppMethod { EXECUTE_POST_USER_LOGGED_IN = 'executePostUserLoggedIn', EXECUTE_POST_USER_LOGGED_OUT = 'executePostUserLoggedOut', EXECUTE_POST_USER_STATUS_CHANGED = 'executePostUserStatusChanged', + // Media calls + EXECUTE_PRE_MEDIA_CALL_CREATED = 'executePreMediaCallCreated', + EXECUTE_POST_MEDIA_CALL_STARTED = 'executePostMediaCallStarted', + EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED = 'executePostMediaCallParticipantJoined', + EXECUTE_POST_MEDIA_CALL_ENDED = 'executePostMediaCallEnded', // Runtime specific methods RUNTIME_RESTART = 'runtime:restart', RUNTIME_UNCAUGHT_EXCEPTION = 'runtime:uncaughtException', diff --git a/packages/apps/src/server/managers/AppListenerManager.ts b/packages/apps/src/server/managers/AppListenerManager.ts index a44db79910368..547337ef7e0c3 100644 --- a/packages/apps/src/server/managers/AppListenerManager.ts +++ b/packages/apps/src/server/managers/AppListenerManager.ts @@ -1,4 +1,6 @@ import type { IEmailDescriptor, IPreEmailSentContext } from '@rocket.chat/apps-engine/definition/email'; +import type { MarkedEventResult } from '@rocket.chat/apps-engine/definition/eventResult'; +import { isEventResult } from '@rocket.chat/apps-engine/definition/eventResult'; import { EssentialAppDisabledException } from '@rocket.chat/apps-engine/definition/exceptions'; import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/externalComponent'; import type { @@ -8,6 +10,12 @@ import type { IVisitor, } from '@rocket.chat/apps-engine/definition/livechat'; import type { ILivechatDepartmentEventContext } from '@rocket.chat/apps-engine/definition/livechat/ILivechatEventContext'; +import type { + IPreMediaCallCreatedContext, + MediaCallCreatePatch, + MediaCallEvent, + PreMediaCallCreatedOutcome, +} from '@rocket.chat/apps-engine/definition/mediaCalls'; import type { IMessage, IMessageDeleteContext, @@ -242,6 +250,13 @@ export interface IListenerExecutor { args: [IUserStatusContext]; result: void; }; + // Media calls + // Every media-call event shares this entry: the envelope's `method` selects + // which of `IMediaCallHandler`'s optional methods to dispatch to. + [AppInterface.IMediaCallHandler]: { + args: [MediaCallEvent]; + result: PreMediaCallCreatedOutcome | void; + }; } // type EventReturn = void | boolean | IMessage | IRoom | IUser | IUIKitResponse | ILivechatRoom | IEmailDescriptor; @@ -466,6 +481,9 @@ export class AppListenerManager { return this.executePostUserLoggedOut(data as IUser); case AppInterface.IPostUserStatusChanged: return this.executePostUserStatusChanged(data as IUserStatusContext); + // Media calls + case AppInterface.IMediaCallHandler: + return this.executeMediaCallEvent(data as MediaCallEvent); default: console.warn('An invalid listener was called'); } @@ -1280,4 +1298,114 @@ export class AppListenerManager { await app.call(AppMethod.EXECUTE_POST_USER_STATUS_CHANGED, data); } } + + // Media calls + private async executeMediaCallEvent(event: MediaCallEvent): Promise { + if (event.method === AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED) { + return this.executePreMediaCallCreated(event.context); + } + + // Post events must not add latency to call signaling, so they are not awaited + void this.executePostMediaCallEvent(event); + } + + private async executePreMediaCallCreated(data: IPreMediaCallCreatedContext): Promise { + let context = data; + + for (const appId of this.listeners.get(AppInterface.IMediaCallHandler)) { + const app = this.manager.getOneById(appId); + + const result = await app.call(AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED, context).catch((error) => { + // Every method of IMediaCallHandler is optional: an app may implement the + // interface for the post events alone + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return undefined; + } + + // Anything else fails the call rather than allowing it: an app asked to decide and + // could not, so there is no decision to honour. Note that this only covers what + // `ProxiedApp.call` lets through - a request that times out is swallowed there and + // arrives here as `undefined`, which allows the call. See ADR 0003. + throw error; + }); + + if (!isEventResult(result)) { + continue; + } + + switch (result.type) { + case 'prevent': + return { + prevented: true, + appId, + ...('reason' in result && { reason: result.reason }), + ...('i18n' in result && { i18n: result.i18n }), + }; + case 'patch': + context = { ...context, ...this.getMediaCallCreatePatch(appId, result.patch) }; + break; + case 'pass': + break; + default: + // Unreachable for a well-formed app: the cases above cover every variant + // the type declares. It still has to fail open, because what arrives here + // is a JSON-RPC payload the types never got to check. + console.warn( + `App ${appId} returned an unsupported EventResult from ${AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED}: ${ + (result as MarkedEventResult).type + }`, + ); + } + } + + return { prevented: false, context }; + } + + /** + * Contacts are the outcome of routing and of permission checks, so only features may be patched. + * + * `isEventResult` only recognizes the marker, so the payload under it is still whatever the app + * sent over JSON-RPC: it has a type here, but nothing ever checked it. A patch that carries + * nothing usable changes nothing, exactly like `pass`. + */ + private getMediaCallCreatePatch(appId: string, patch: unknown): Partial { + if (typeof patch !== 'object' || patch === null) { + console.warn(`App ${appId} returned a media call patch that is not an object: ${patch === null ? 'null' : typeof patch}`); + return {}; + } + + const { features, ...rest } = patch as Partial; + const unsupported = Object.keys(rest); + + if (unsupported.length) { + console.warn(`App ${appId} tried to patch unsupported media call properties: ${unsupported.join(', ')}`); + } + + return Array.isArray(features) ? { features } : {}; + } + + private async executePostMediaCallEvent( + event: Exclude, + ): Promise { + const dispatched: Promise[] = []; + + for (const appId of this.listeners.get(AppInterface.IMediaCallHandler)) { + const app = this.manager.getOneById(appId); + + // Nothing is waiting on these events, so one app must not keep the others from being + // notified - not even by not implementing the method at all, and not by stalling until + // its own call times out. Every handler is started before any of them is awaited. + dispatched.push( + app.call(event.method, event.context).catch((error) => { + if (error?.code === JSONRPC_METHOD_NOT_FOUND) { + return; + } + + console.error(`App ${appId} failed to handle ${event.method}`, error); + }), + ); + } + + await Promise.all(dispatched); + } } diff --git a/packages/apps/tests/server/managers/AppListenerManager.mediaCalls.test.ts b/packages/apps/tests/server/managers/AppListenerManager.mediaCalls.test.ts new file mode 100644 index 0000000000000..1495102ae3ebb --- /dev/null +++ b/packages/apps/tests/server/managers/AppListenerManager.mediaCalls.test.ts @@ -0,0 +1,386 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'node:test'; + +import { EVENT_RESULT_KIND, EventResult } from '@rocket.chat/apps-engine/definition/eventResult'; +import type { + IMediaCall, + IMediaCallEndedContext, + IMediaCallParticipantJoinedContext, + IMediaCallStartedContext, + IPreMediaCallCreatedContext, + MediaCallEvent, + PreMediaCallCreatedOutcome, +} from '@rocket.chat/apps-engine/definition/mediaCalls'; +import { AppInterface, AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; + +import type { AppManager } from '../../../src/server/AppManager'; +import type { ProxiedApp } from '../../../src/server/ProxiedApp'; +import { AppListenerManager } from '../../../src/server/managers'; +import { JSONRPC_METHOD_NOT_FOUND } from '../../../src/server/runtime/base/BaseRuntimeSubprocessController'; + +type AppMethodHandlers = Record unknown>; + +/** + * Every method of `IMediaCallHandler` is optional, and an app that doesn't + * implement one answers the way the runtime does: a method-not-found error. + */ +function mockApp(id: string, handlers: AppMethodHandlers): ProxiedApp { + return { + getID() { + return id; + }, + getImplementationList() { + return { [AppInterface.IMediaCallHandler]: true } as { [inte: string]: boolean }; + }, + async call(method: string, ...args: unknown[]) { + if (!(method in handlers)) { + throw Object.assign(new Error(`Method not found: ${method}`), { code: JSONRPC_METHOD_NOT_FOUND }); + } + + return handlers[method](...args); + }, + } as unknown as ProxiedApp; +} + +function managerFor(apps: ProxiedApp[]): AppManager { + return { + getOneById(appId: string) { + return apps.find((app) => app.getID() === appId); + }, + } as AppManager; +} + +function listenerManagerFor(apps: ProxiedApp[]): AppListenerManager { + const listenerManager = new AppListenerManager(managerFor(apps)); + + apps.forEach((app) => listenerManager.registerListeners(app)); + + return listenerManager; +} + +const context: IPreMediaCallCreatedContext = { + caller: { type: 'user', id: 'caller-id', username: 'caller' }, + callee: { type: 'user', id: 'callee-id', username: 'callee' }, + createdBy: { type: 'user', id: 'caller-id', username: 'caller' }, + features: ['audio'], + origin: 'internal', +}; + +async function runPreCallCreated(apps: ProxiedApp[]): Promise { + const outcome = await listenerManagerFor(apps).executeListener(AppInterface.IMediaCallHandler, { + method: AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED, + context, + }); + + return outcome as PreMediaCallCreatedOutcome; +} + +/** The `default:` and unsupported-patch branches only report themselves through `console.warn`. */ +async function capturingWarnings(fn: () => Promise): Promise<{ result: T; warnings: string[] }> { + const warnings: string[] = []; + const original = console.warn; + console.warn = (message: string) => void warnings.push(message); + + try { + const result = await fn(); + + return { result, warnings }; + } finally { + console.warn = original; + } +} + +describe('AppListenerManager media call events', () => { + describe('pre media call created', () => { + it('passes the context through untouched when every app passes', async () => { + const outcome = await runPreCallCreated([ + mockApp('passing', { [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => EventResult.pass() }), + ]); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + }); + + it('skips apps that only implement the post events', async () => { + const outcome = await runPreCallCreated([mockApp('post-only', { [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: () => undefined })]); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + }); + + it('reports the app that prevented the call and stops consulting the others', async () => { + const consulted: string[] = []; + const outcome = await runPreCallCreated([ + mockApp('preventing', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => { + consulted.push('preventing'); + return EventResult.prevent({ reason: 'callee is on a do-not-disturb list' }); + }, + }), + mockApp('later', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => { + consulted.push('later'); + return EventResult.pass(); + }, + }), + ]); + + assert.deepStrictEqual(outcome, { + prevented: true, + appId: 'preventing', + reason: 'callee is on a do-not-disturb list', + }); + assert.deepStrictEqual(consulted, ['preventing']); + }); + + it('carries an i18n prevention reason', async () => { + const outcome = await runPreCallCreated([ + mockApp('preventing', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => EventResult.prevent({ i18n: { key: 'callee_is_dnd' } }), + }), + ]); + + assert.deepStrictEqual(outcome, { prevented: true, appId: 'preventing', i18n: { key: 'callee_is_dnd' } }); + }); + + it('chains patches, handing each app what the previous one patched', async () => { + const seen: string[][] = []; + const outcome = await runPreCallCreated([ + mockApp('first', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: (ctx: IPreMediaCallCreatedContext) => { + seen.push(ctx.features); + return EventResult.patch({ features: [...ctx.features, 'hold'] }); + }, + }), + mockApp('second', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: (ctx: IPreMediaCallCreatedContext) => { + seen.push(ctx.features); + return EventResult.patch({ features: [...ctx.features, 'transfer'] }); + }, + }), + ]); + + assert.deepStrictEqual(seen, [['audio'], ['audio', 'hold']]); + assert.deepStrictEqual(outcome, { + prevented: false, + context: { ...context, features: ['audio', 'hold', 'transfer'] }, + }); + }); + + it('drops patches to anything other than the requested features', async () => { + const outcome = await runPreCallCreated([ + mockApp('rerouting', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => + EventResult.patch({ + callee: { type: 'user', id: 'someone-else' }, + // Follows from the contacts, so an app cannot claim the call came from elsewhere + origin: 'sip-inbound', + features: ['audio', 'hold'], + } as never), + }), + ]); + + assert.deepStrictEqual(outcome, { prevented: false, context: { ...context, features: ['audio', 'hold'] } }); + }); + + it('drops a patch whose features are not a list', async () => { + const { result: outcome, warnings } = await capturingWarnings(() => + runPreCallCreated([ + mockApp('confused', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => EventResult.patch({ features: 'audio' } as never), + }), + ]), + ); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + assert.deepStrictEqual(warnings, []); + }); + + it('drops a patch that carries no payload, rather than failing the call over it', async () => { + const { result: outcome, warnings } = await capturingWarnings(() => + runPreCallCreated([ + // Hand-rolled rather than built by `EventResult.patch`: the marker is all + // `isEventResult` looks at, so nothing guarantees a patch underneath it + mockApp('marker-only', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => ({ '@kind': EVENT_RESULT_KIND, 'type': 'patch' }), + }), + ]), + ); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + assert.deepStrictEqual(warnings, ['App marker-only returned a media call patch that is not an object: undefined']); + }); + + it('ignores a return value that is not an EventResult', async () => { + const outcome = await runPreCallCreated([ + // Predates the EventResult protocol, or is simply not speaking it + mockApp('legacy', { [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => ({ prevented: true, reason: 'not an EventResult' }) }), + ]); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + }); + + /** + * The static types forbid this, so it only arrives from a bug or a tampered + * JSON-RPC payload — hence the hand-built marker instead of a factory call. + */ + it('warns about and passes over an EventResult variant this event does not support', async () => { + const { result: outcome, warnings } = await capturingWarnings(() => + runPreCallCreated([ + mockApp('prompting', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => ({ + '@kind': EVENT_RESULT_KIND, + 'type': 'prompt', + 'message': 'Are you sure?', + }), + }), + ]), + ); + + assert.deepStrictEqual(outcome, { prevented: false, context }); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /App prompting returned an unsupported EventResult from executePreMediaCallCreated: prompt/); + }); + + /** + * The pre event is the one thing standing between an app's policy and a call + * being created, so an app that fails is not passed over the way it is on the + * post events: the rejection travels up to `MediaCallServer.requestCall`, which + * turns it into a refused call. + */ + it('fails closed when an app handler throws', async () => { + await assert.rejects( + runPreCallCreated([ + mockApp('failing', { + [AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED]: () => { + throw new Error('app blew up'); + }, + }), + ]), + /app blew up/, + ); + }); + }); + + describe('post media call events', () => { + const call: IMediaCall = { + id: 'call-id', + service: 'webrtc', + kind: 'direct', + state: 'hangup', + createdBy: context.caller, + createdAt: new Date(0), + caller: context.caller, + callee: context.callee, + features: ['audio'], + uids: ['caller-id', 'callee-id'], + ended: true, + }; + + // Each post event narrows the call to the timestamp it guarantees, and carries + // nothing beside it that the call already holds + const endedContext: IMediaCallEndedContext = { + call: { ...call, ended: true, endedAt: new Date(0) }, + durationMs: 0, + }; + + const startedContext: IMediaCallStartedContext = { + call: { ...call, state: 'active', ended: false, activatedAt: new Date(0) }, + }; + + const participantJoinedContext: IMediaCallParticipantJoinedContext = { + call: { ...call, state: 'accepted', ended: false, acceptedAt: new Date(0) }, + }; + + async function triggerPostEvent( + apps: ProxiedApp[], + event: Exclude, + ): Promise { + await listenerManagerFor(apps).executeListener(AppInterface.IMediaCallHandler, event); + + // Post events are dispatched without being awaited + await new Promise((resolve) => setImmediate(resolve)); + } + + async function triggerCallEnded(apps: ProxiedApp[]): Promise { + return triggerPostEvent(apps, { method: AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED, context: endedContext }); + } + + it('hands the context to every app that implements the event', async () => { + const notified: string[] = []; + + await triggerCallEnded([ + mockApp('logging', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: (ctx: typeof endedContext) => { + notified.push(`logging:${ctx.call.id}`); + }, + }), + mockApp('billing', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: (ctx: typeof endedContext) => { + notified.push(`billing:${ctx.call.id}`); + }, + }), + ]); + + assert.deepStrictEqual(notified, ['logging:call-id', 'billing:call-id']); + }); + + it('keeps notifying the other apps when one fails or does not implement the event', async () => { + const notified: string[] = []; + + await triggerCallEnded([ + mockApp('failing', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: () => { + throw new Error('app blew up'); + }, + }), + mockApp('not-subscribed', { [AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED]: () => undefined }), + mockApp('logging', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: () => { + notified.push('logging'); + }, + }), + ]); + + assert.deepStrictEqual(notified, ['logging']); + }); + + it('routes the started event to the method that handles it', async () => { + const notified: string[] = []; + + await triggerPostEvent( + [ + mockApp('logging', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED]: (ctx: IMediaCallStartedContext) => { + notified.push(`started:${ctx.call.id}:${ctx.call.state}`); + }, + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: () => { + notified.push('ended'); + }, + }), + ], + { method: AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED, context: startedContext }, + ); + + assert.deepStrictEqual(notified, ['started:call-id:active']); + }); + + it('routes the participant joined event to the method that handles it', async () => { + const notified: string[] = []; + + await triggerPostEvent( + [ + mockApp('logging', { + [AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED]: (ctx: IMediaCallParticipantJoinedContext) => { + notified.push(`joined:${ctx.call.callee.id}`); + }, + [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED]: () => { + notified.push('ended'); + }, + }), + ], + { method: AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED, context: participantJoinedContext }, + ); + + assert.deepStrictEqual(notified, ['joined:callee-id']); + }); + }); +}); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ae0bc80a9be37..d8cbc71b240a6 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1131,6 +1131,12 @@ "Call_ongoing": "Call ongoing", "Call_open_separate_window": "Call open in a separate window", "Call_provider": "Call Provider", + "Call_rejected": "Your call could not be completed", + "Call_rejected_busy": "You are already on another call", + "Call_rejected_forbidden": "You are not allowed to make this call", + "Call_rejected_invalid_call_params": "This call could not be routed", + "Call_rejected_unavailable": "The person you are calling is unavailable", + "Call_rejected_unsupported": "This call is not supported", "Call_ringer_volume": "Call ringer volume", "Call_ringer_volume_hint": "For all incoming voice and video call notifications", "Call_started": "Call started", diff --git a/packages/media-signaling/src/definition/call/CallEvents.ts b/packages/media-signaling/src/definition/call/CallEvents.ts index 26b629b0294cd..0e4cb2b03504d 100644 --- a/packages/media-signaling/src/definition/call/CallEvents.ts +++ b/packages/media-signaling/src/definition/call/CallEvents.ts @@ -1,5 +1,6 @@ import type { ClientState } from '../client'; -import type { CallState } from './IClientMediaCall'; +import type { CallRejectedReason, CallState } from './IClientMediaCall'; +import type { CallRejectionMessage } from './common'; export type CallEvents = { /* Triggered when the call's server state is changed on this client, with the old state as param */ @@ -29,6 +30,9 @@ export type CallEvents = { /* Triggered when the call's state on the server changes to 'hangup' */ ended: void; + /* Triggered when the server refuses a call this session requested. Always followed by 'ended' */ + rejected: { reason: CallRejectedReason; message?: CallRejectionMessage }; + /* Triggered when screen share is toggled */ screenShareRequestChange: boolean; diff --git a/packages/media-signaling/src/definition/call/common.ts b/packages/media-signaling/src/definition/call/common.ts index 542f5cf527a04..afd1b2210bc71 100644 --- a/packages/media-signaling/src/definition/call/common.ts +++ b/packages/media-signaling/src/definition/call/common.ts @@ -11,3 +11,18 @@ export type CallContact = { }; export type CallRole = 'caller' | 'callee'; + +/** + * A human-readable explanation of why a call was rejected, meant to be shown to + * the user who requested it. It travels alongside the machine-readable + * `CallRejectedReason`, which is what the client acts on - this is only ever + * displayed. + * + * `i18n` messages name a key the client is expected to resolve; `ns` selects the + * i18n namespace it lives in, so that whoever produced the message can point at + * their own translations instead of the workspace's. A client that can't resolve + * the key must fall back to a message of its own rather than render the key. + */ +export type CallRejectionMessage = + | { type: 'text'; text: string } + | { type: 'i18n'; key: string; args?: Record; ns?: 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 5bbd338da3723..f8ef63f1a6e61 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 @@ -1,8 +1,14 @@ -import type { CallRejectedReason } from '../../call'; +import type { CallRejectedReason, CallRejectionMessage } from '../../call'; export type ServerMediaSignalRejectedCallRequest = { callId: string; type: 'rejected-call-request'; toContractId: string; reason: CallRejectedReason; + /** + * Present when the rejection came with an explanation meant for the user - + * today, from an app that blocked the call. Clients that can't display it + * still have `reason` to act on. + */ + message?: CallRejectionMessage; }; diff --git a/packages/media-signaling/src/lib/Call.spec.ts b/packages/media-signaling/src/lib/Call.spec.ts new file mode 100644 index 0000000000000..cd9eda9d0e175 --- /dev/null +++ b/packages/media-signaling/src/lib/Call.spec.ts @@ -0,0 +1,116 @@ +import { ClientMediaCall } from './Call'; +import type { IClientMediaCallConfig } from './Call'; +import type { MediaSignalTransportWrapper } from './TransportWrapper'; +import type { ServerMediaSignalRejectedCallRequest } from '../definition/signals/server'; + +const SESSION_ID = 'session-id'; + +const makeTransporter = () => + ({ + sendToServer: jest.fn(), + hangup: jest.fn(), + answer: jest.fn(), + sendError: jest.fn(), + requestRenegotiation: jest.fn(), + }) as unknown as MediaSignalTransportWrapper; + +const makeCall = (callId: string) => { + const config: IClientMediaCallConfig = { + userId: 'caller-id', + sessionId: SESSION_ID, + transporter: makeTransporter(), + processorFactories: {}, + iceGatheringTimeout: 5000, + iceServers: [], + supportedFeatures: ['audio'], + }; + + return new ClientMediaCall(config, callId); +}; + +const rejection = (overrides: Partial = {}): ServerMediaSignalRejectedCallRequest => ({ + type: 'rejected-call-request', + callId: 'call-id', + toContractId: SESSION_ID, + reason: 'forbidden', + ...overrides, +}); + +describe('ClientMediaCall', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('rejected-call-request', () => { + it('reports the rejection to the session that asked for the call', async () => { + const call = makeCall('call-id'); + await call.initializeOutboundCall({ type: 'user', id: 'callee-id' }); + + const onRejected = jest.fn(); + call.emitter.on('rejected', onRejected); + + await call.processSignal(rejection({ reason: 'busy' })); + + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith({ reason: 'busy' }); + }); + + it('passes along the message the rejection came with', async () => { + const call = makeCall('call-id'); + await call.initializeOutboundCall({ type: 'user', id: 'callee-id' }); + + const onRejected = jest.fn(); + call.emitter.on('rejected', onRejected); + + const message = { type: 'i18n' as const, key: 'callee_is_dnd', ns: 'app-blocking-app', args: { username: 'callee' } }; + await call.processSignal(rejection({ message })); + + expect(onRejected).toHaveBeenCalledWith({ reason: 'forbidden', message }); + }); + + it('ends the call', async () => { + const call = makeCall('call-id'); + await call.initializeOutboundCall({ type: 'user', id: 'callee-id' }); + + await call.processSignal(rejection()); + + expect(call.state).toBe('hangup'); + expect(call.isOver()).toBe(true); + }); + + it('stays quiet on a session that did not ask for the call', async () => { + // A call this session knows nothing about: the same signal reaches every + // session the user has open, and only the one that placed the call is + // supposed to hear about it + const call = makeCall('call-id'); + + const onRejected = jest.fn(); + call.emitter.on('rejected', onRejected); + + await call.processSignal(rejection()); + + expect(call.hidden).toBe(true); + expect(onRejected).not.toHaveBeenCalled(); + expect(call.state).toBe('hangup'); + }); + + it('stays quiet on a session whose contract was not the one addressed', async () => { + const call = makeCall('call-id'); + await call.initializeOutboundCall({ type: 'user', id: 'callee-id' }); + call.setContractState('ignored'); + + const onRejected = jest.fn(); + call.emitter.on('rejected', onRejected); + + await call.processSignal(rejection({ toContractId: 'some-other-session' })); + + expect(onRejected).not.toHaveBeenCalled(); + expect(call.state).toBe('hangup'); + }); + }); +}); diff --git a/packages/media-signaling/src/lib/Call.ts b/packages/media-signaling/src/lib/Call.ts index 5917588868ef7..7c0763b526a9c 100644 --- a/packages/media-signaling/src/lib/Call.ts +++ b/packages/media-signaling/src/lib/Call.ts @@ -29,6 +29,7 @@ import type { ServerMediaSignal, ServerMediaSignalNewCall, ServerMediaSignalNotification, + ServerMediaSignalRejectedCallRequest, ServerMediaSignalRemoteSDP, ServerMediaSignalRequestOffer, } from '../definition/signals/server'; @@ -635,7 +636,7 @@ export class ClientMediaCall implements IClientMediaCall { } if (signalType === 'rejected-call-request') { - return this.flagAsEnded('remote'); + return this.processRejection(signal); } if (!this.hasRemoteData) { @@ -1197,6 +1198,25 @@ export class ClientMediaCall implements IClientMediaCall { this.changeState('accepted'); } + /** + * The server refused a call request. The call ends either way, but the session + * that asked for it is the only one that gets told why: the same signal reaches + * every session of the user, and the others are hidden (their contract was not + * the one addressed, or they never knew about this call to begin with). + */ + private processRejection(signal: ServerMediaSignalRejectedCallRequest): void { + this.config.logger?.debug('ClientMediaCall.processRejection', signal.reason); + + if (!this.hidden) { + this.emitter.emit('rejected', { + reason: signal.reason, + ...(signal.message && { message: signal.message }), + }); + } + + this.flagAsEnded('remote'); + } + private flagAsEnded(reason: CallHangupReason): void { this.config.logger?.debug('ClientMediaCall.flagAsEnded', reason); if (this._state === 'hangup') { diff --git a/packages/media-signaling/src/lib/Session.ts b/packages/media-signaling/src/lib/Session.ts index 7f6e429153604..60904c0657def 100644 --- a/packages/media-signaling/src/lib/Session.ts +++ b/packages/media-signaling/src/lib/Session.ts @@ -13,7 +13,7 @@ import type { ServerMediaSignal, ServerMediaSignalRegistered, } from '../definition'; -import type { IClientMediaCall, CallActorType, CallContact, CallFeature, AnyMediaCallData } from '../definition/call'; +import type { IClientMediaCall, CallActorType, CallContact, CallEvents, CallFeature, AnyMediaCallData } from '../definition/call'; import type { IMediaSignalLogger } from '../definition/logger'; import { SessionRegistration } from './components/SessionRegistration'; import { isSameDeviceId } from './utils/isSameDeviceId'; @@ -26,6 +26,8 @@ export type MediaSignalingEvents = { hiddenCall: void; registered: { activeCalls: IClientMediaCall['callId'][] }; outOfSync: { missingCalls: IClientMediaCall['callId'][] }; + /** The server refused a call this session requested, and said why. Followed by 'endedCall' */ + rejectedCall: { callId: IClientMediaCall['callId'] } & CallEvents['rejected']; }; export type MediaSignalingSessionConfig = { @@ -680,6 +682,7 @@ export class MediaSignalingSession extends Emitter { call.emitter.on('hidden', () => this.onHiddenCall(call)); call.emitter.on('active', () => this.onActiveCall(call)); call.emitter.on('ended', () => this.onEndedCall(call)); + call.emitter.on('rejected', (rejection) => this.onRejectedCall(call, rejection)); call.emitter.on('screenShareRequestChange', (requested: boolean) => this.onScreenShareRequestChange(call, requested)); call.emitter.on('streamChange', () => this.onSessionStateChange()); @@ -732,6 +735,11 @@ export class MediaSignalingSession extends Emitter { this.onSessionStateChange(); } + private onRejectedCall(call: ClientMediaCall, rejection: CallEvents['rejected']): void { + this.config.logger?.debug('MediaSignalingSession.onRejectedCall', rejection.reason); + this.emit('rejectedCall', { callId: call.callId, ...rejection }); + } + private onHiddenCall(_call: ClientMediaCall): void { this.config.logger?.debug('MediaSignalingSession.onHiddenCall'); this.onSessionStateChange(); diff --git a/packages/model-typings/src/models/IMediaCallsModel.ts b/packages/model-typings/src/models/IMediaCallsModel.ts index d6e08613a4e38..c9a334e4913c4 100644 --- a/packages/model-typings/src/models/IMediaCallsModel.ts +++ b/packages/model-typings/src/models/IMediaCallsModel.ts @@ -23,10 +23,20 @@ export interface IMediaCallsModel extends IBaseModel { options?: O, ): Promise | null>; startRingingById(callId: string, expiresAt: Date): Promise; - acceptCallById(callId: string, data: { calleeContractId: string; supportedFeatures: string[] }, expiresAt: Date): Promise; - activateCallById(callId: string, expiresAt: Date): Promise; + /** + * The three state transitions below return the call as it is once the transition has been + * applied, or `null` when the call was no longer in a state the transition applies to. Whoever + * reports the transition must describe the call as it was when it happened, and a separate read + * would already see the next transition. + */ + acceptCallById( + callId: string, + data: { calleeContractId: string; supportedFeatures: 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; + hangupCallById(callId: string, params: { endedBy?: IMediaCall['endedBy']; reason?: string } | undefined): Promise; transferCallById(callId: string, params: { by: MediaCallSignedContact; to: MediaCallContact }): Promise; findAllExpiredCalls = FindOptionsWithProjection>( options?: O, diff --git a/packages/models/src/models/MediaCalls.ts b/packages/models/src/models/MediaCalls.ts index 5ec6e414af0c4..6110ac9654b22 100644 --- a/packages/models/src/models/MediaCalls.ts +++ b/packages/models/src/models/MediaCalls.ts @@ -84,10 +84,10 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod callId: string, data: { calleeContractId: string; supportedFeatures: string[] }, expiresAt: Date, - ): Promise { + ): Promise { const { calleeContractId } = data; - return this.updateOne( + return this.findOneAndUpdate( { _id: callId, state: { $in: ['none', 'ringing'] }, @@ -105,11 +105,12 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod }, }, }, + { returnDocument: 'after' }, ); } - public async activateCallById(callId: string, expiresAt: Date): Promise { - return this.updateOne( + public async activateCallById(callId: string, expiresAt: Date): Promise { + return this.findOneAndUpdate( { _id: callId, state: 'accepted', @@ -121,13 +122,14 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod expiresAt, }, }, + { returnDocument: 'after' }, ); } - public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { + public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise { const { endedBy, reason } = params || {}; - return this.updateOne( + return this.findOneAndUpdate( { _id: callId, ended: false, @@ -141,6 +143,7 @@ export class MediaCallsRaw extends BaseRaw implements IMediaCallsMod ...(reason && { hangupReason: reason }), }, }, + { returnDocument: 'after' }, ); } diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index 792cddf5b43b8..d76ec424c5cf8 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useCallRejectionToast } from './useCallRejectionToast'; import { useCallSounds } from './useCallSounds'; import { useDesktopNotifications } from './useDesktopNotifications'; import { useMediaSession } from './useMediaSession'; @@ -40,6 +41,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const controls = useMediaSessionControls(instance); useDesktopNotifications(sessionState); + useCallRejectionToast(instance); const setOutputMediaDevice = useSetOutputMediaDevice(); const setInputMediaDevice = useSetInputMediaDevice(); diff --git a/packages/ui-voip/src/providers/useCallRejectionToast.spec.tsx b/packages/ui-voip/src/providers/useCallRejectionToast.spec.tsx new file mode 100644 index 0000000000000..d56dad6561761 --- /dev/null +++ b/packages/ui-voip/src/providers/useCallRejectionToast.spec.tsx @@ -0,0 +1,90 @@ +import { Emitter } from '@rocket.chat/emitter'; +import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook } from '@testing-library/react'; + +import { useCallRejectionToast } from './useCallRejectionToast'; + +type RejectedCall = { callId: string; reason: string; message?: unknown }; + +const dispatchToastMessage = jest.fn(); + +const createWrapper = () => + mockAppRoot() + .withTranslations('en', 'core', { + Call_rejected: 'Your call could not be completed', + Call_rejected_forbidden: 'You are not allowed to make this call', + Call_rejected_busy: 'You are already on another call', + }) + .withTranslations('en', 'app-blocking-app', { + callee_is_dnd: '{{username}} is not taking calls right now', + }) + .withToastMessageDispatch(dispatchToastMessage) + .build(); + +const setupRejectionToast = () => { + const emitter = new Emitter<{ rejectedCall: RejectedCall }>(); + const instance = emitter as unknown as MediaSignalingSession; + + renderHook(() => useCallRejectionToast(instance), { wrapper: createWrapper() }); + + return (rejection: Omit) => act(() => emitter.emit('rejectedCall', { callId: 'call-id', ...rejection })); +}; + +describe('useCallRejectionToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('explains the reason the call was rejected', () => { + const reject = setupRejectionToast(); + + reject({ reason: 'forbidden' }); + + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'You are not allowed to make this call' }); + }); + + it('prefers the message the rejection came with over the reason', () => { + const reject = setupRejectionToast(); + + reject({ reason: 'forbidden', message: { type: 'text', text: 'blocked by the on-call policy' } }); + + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'blocked by the on-call policy' }); + }); + + it('resolves an i18n message against the namespace of the app that produced it', () => { + const reject = setupRejectionToast(); + + reject({ + reason: 'forbidden', + message: { type: 'i18n', key: 'callee_is_dnd', ns: 'app-blocking-app', args: { username: 'callee' } }, + }); + + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'callee is not taking calls right now' }); + }); + + it('falls back to the reason when the app never shipped the translation it named', () => { + const reject = setupRejectionToast(); + + reject({ reason: 'busy', message: { type: 'i18n', key: 'no_such_key', ns: 'app-blocking-app' } }); + + // Never the raw key + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'You are already on another call' }); + }); + + it('falls back to the generic message when there is no text for the reason either', () => { + const reject = setupRejectionToast(); + + reject({ reason: 'already-requested', message: { type: 'i18n', key: 'no_such_key', ns: 'app-blocking-app' } }); + + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: 'Your call could not be completed' }); + }); + + it('stays silent about a rejection the user can do nothing with', () => { + const reject = setupRejectionToast(); + + reject({ reason: 'already-requested' }); + + expect(dispatchToastMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui-voip/src/providers/useCallRejectionToast.ts b/packages/ui-voip/src/providers/useCallRejectionToast.ts new file mode 100644 index 0000000000000..d102eedb697c8 --- /dev/null +++ b/packages/ui-voip/src/providers/useCallRejectionToast.ts @@ -0,0 +1,75 @@ +import type { CallRejectedReason, CallRejectionMessage, MediaSignalingSession } from '@rocket.chat/media-signaling'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * The reasons worth interrupting the caller about, and what to tell them. + * + * The ones left out - `invalid-call-id`, `invalid-contract-id`, + * `existing-call-id` and `already-requested` - are the server turning down a + * request a client should not have made in the first place. Nothing the user did + * caused them and nothing they can do fixes them, so they stay silent unless the + * rejection came with a message of its own. + */ +const rejectionMessageKeys: Partial> = { + 'forbidden': 'Call_rejected_forbidden', + 'busy': 'Call_rejected_busy', + 'unavailable': 'Call_rejected_unavailable', + 'unsupported': 'Call_rejected_unsupported', + 'invalid-call-params': 'Call_rejected_invalid_call_params', +}; + +/** + * Tells the caller why the call they just placed is not happening. + * + * Without this the only feedback is the widget appearing and vanishing again, + * which reads as a glitch rather than as an answer - especially when an app + * blocked the call on purpose and has something to say about it. + */ +export const useCallRejectionToast = (instance?: MediaSignalingSession) => { + const { t, i18n } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + useEffect(() => { + if (!instance) { + return; + } + + /** + * An explicit message wins over the reason code, but only once we know we can + * actually render it: an app naming a key it never shipped a translation for + * would otherwise put the raw key in front of the user. + */ + const resolveMessage = (reason: CallRejectedReason, message?: CallRejectionMessage): string | undefined => { + if (message?.type === 'text' && message.text) { + return message.text; + } + + if (message?.type === 'i18n' && i18n.exists(message.key, { ns: message.ns })) { + return i18n.t(message.key, { ns: message.ns, ...message.args }); + } + + const reasonKey = rejectionMessageKeys[reason]; + + // A rejection that carries a message is always worth reporting, even if we + // ended up with nothing better than the generic text to report it with + if (!reasonKey) { + return message ? t('Call_rejected') : undefined; + } + + return t(reasonKey); + }; + + return instance.on('rejectedCall', ({ reason, message }) => { + const text = resolveMessage(reason, message); + + if (!text) { + return; + } + + dispatchToastMessage({ type: 'error', message: text }); + }); + }, [instance, dispatchToastMessage, t, i18n]); +};