-
Notifications
You must be signed in to change notification settings - Fork 13.8k
feat(apps-engine): media call hooks #41681
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
d-gubert
wants to merge
44
commits into
develop
Choose a base branch
from
feat/apps-media-call-hooks
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,408
−76
Open
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
94cf883
docs: add apps-engine-event-result-return-type.md proposal
d-gubert 4c8917d
docs: add apps-media-call-analysis.md to proposals
d-gubert 1529162
docs: media call events prototypes
d-gubert 14abc5a
feat(apps-engine): new EventResult type
d-gubert 35a076f
feat(apps-engine): type definition for media call events
d-gubert e1407bd
fix: lint
d-gubert ad5cd6b
feat(apps-engine): media call types improvementss
d-gubert d87ddf6
feat(apps): hook up media call events from apps-engine
d-gubert 8065fc0
update proposal
d-gubert 019e647
fix(media-calls): missing contact information on a call's createdBy
d-gubert f3ef26a
test: e2e suite
d-gubert 98b8068
test: unit coverage for media call app events
d-gubert adab109
chore: add changeset for media call app events
d-gubert c9ac5d2
docs: update event result proposal
d-gubert bbe106f
chore: remove checkPreMediaCallCreated from the handler interface
d-gubert 37ee398
docs: SIP loopback calls analysis
d-gubert 7ad3ae1
feat(media-signaling): CallRejectionMessage type
d-gubert 515daaf
feat(media-calls): let the pre-call-created hook explain a rejection
d-gubert d94680c
feat(apps): keep what an app said about the call it blocked
d-gubert 9a73510
feat(media-calls): carry a rejection message on CallRejectedError
d-gubert 248c9ea
feat(media-calls): send the rejection message to the caller
d-gubert 3d76709
feat(media-signaling): surface call rejections instead of swallowing …
d-gubert e0500c5
feat(i18n): messages for the reasons a call gets rejected
d-gubert 7c11b5c
feat(ui-voip): tell the caller why their call was rejected
d-gubert bbc57a4
test: cover the path from a blocked call to the caller's toast
d-gubert 19da711
docs: changeset and analysis update for call rejection feedback
d-gubert bef99a3
feat(apps-engine): helpers to identify end call reasons
d-gubert ee99982
chore: adapt to new divertedBy field
d-gubert ae689c2
feat(apps-engine): add origin field to pre event context
d-gubert 042e973
refactor: enumerate media call invariants declaratively
d-gubert 782fa6a
refactor: simplify dispatching of app events on media-call
d-gubert a85ed7a
refactor: remove test app source, keep only package and docs
d-gubert 2696b4f
tests: fix e2e tests
d-gubert 6f49be1
docs: moved proposals to adrs and improved conciseness
d-gubert e37852a
refactor: remove the unused "prompt" event result
d-gubert d2641b0
fix(e2e): use the existing Widget.hangup() helper
d-gubert 72c6518
fix(apps): notify every media-call listener without serializing them
d-gubert 756f0f9
docs: record that the media-call pre event fails open on a timeout
d-gubert 578276e
tests: fix e2e tests
d-gubert 8067a91
docs: ADR sync pass
d-gubert b144d52
docs: ADR refinement
d-gubert cf7bf91
fix: pass the full call object to events instead of just ids to avoid…
d-gubert 24d6675
tests: restore setting value on test teardown
d-gubert dbd6c52
fix: malformed patch response crashed the event handler
d-gubert File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@rocket.chat/apps-engine': minor | ||
| '@rocket.chat/media-calls': minor | ||
| '@rocket.chat/apps': minor | ||
| '@rocket.chat/meteor': minor | ||
| --- | ||
|
|
||
| Adds media call lifecycle events to the Apps-Engine: an app implementing the new `IMediaCallHandler` interface can now observe calls starting, being answered and ending, and can block a call or change the features it was requested with before it is created |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@rocket.chat/media-calls': patch | ||
| '@rocket.chat/meteor': patch | ||
| --- | ||
|
|
||
| Fixes the `createdBy` of a voice call being stored with no contact information on it: every call that was not created by a transfer ended up with a `createdBy` carrying only the requester's id, while the caller and callee carried their username and display name. This also affected the `transferredBy` reported to clients. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@rocket.chat/media-signaling': minor | ||
| '@rocket.chat/media-calls': minor | ||
| '@rocket.chat/ui-voip': minor | ||
| '@rocket.chat/i18n': minor | ||
| '@rocket.chat/meteor': minor | ||
| --- | ||
|
|
||
| Tells the caller why a voice call they placed was rejected, instead of showing the call widget for an instant and nothing else. An app that blocks a call through `IMediaCallHandler` can now have its own message shown to the caller, and rejections the server was already sending — the callee being unavailable, the caller not being allowed to place the call — are explained rather than silent |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| import { AppEvents, Apps } from '@rocket.chat/apps'; | ||
| import type { | ||
| IAcceptedMediaCall as IAppsAcceptedMediaCall, | ||
| IActiveMediaCall as IAppsActiveMediaCall, | ||
| IEndedMediaCall as IAppsEndedMediaCall, | ||
| IMediaCall as IAppsMediaCall, | ||
| IMediaCallActor as IAppsMediaCallActor, | ||
| IMediaCallContact as IAppsMediaCallContact, | ||
| IPreMediaCallCreatedContext, | ||
| MediaCallEvent, | ||
| MediaCallOrigin, | ||
| PreMediaCallCreatedOutcome, | ||
| } from '@rocket.chat/apps-engine/definition/mediaCalls'; | ||
| import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata'; | ||
| import type { IMediaCall, MediaCallActor, MediaCallContact, ServerActor } from '@rocket.chat/core-typings'; | ||
| import type { PreCallCreatedHookParams, PreCallCreatedHookResult } from '@rocket.chat/media-calls'; | ||
| import { callFeatureList, type CallFeature, type CallRejectionMessage } from '@rocket.chat/media-signaling'; | ||
|
|
||
| import { logger } from './logger'; | ||
|
|
||
| /** | ||
| * Maps media calls onto the shapes apps see and dispatches the media-call | ||
| * lifecycle events to the Apps-Engine. | ||
| * | ||
| * Every event travels under the single `IMediaCallHandler` interface; the | ||
| * `method` on the envelope is what tells the listener manager which of the | ||
| * handler's optional methods to call. | ||
| */ | ||
|
|
||
| /** Contacts carry a per-session signing token, which is a credential: only these fields may reach an app. */ | ||
| function toAppContact(contact: MediaCallContact): IAppsMediaCallContact { | ||
| return { | ||
| type: contact.type, | ||
| id: contact.id, | ||
| ...(contact.username && { username: contact.username }), | ||
| ...(contact.displayName && { displayName: contact.displayName }), | ||
| ...(contact.sipExtension && { sipExtension: contact.sipExtension }), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * The two contacts are the origin: a sip caller means the call arrived from the | ||
| * PBX, a sip callee means it was placed out through it, and neither means it never | ||
| * leaves the workspace. Both contacts are final before any event is built, so | ||
| * apps do not have to reimplement the routing rules to tell the cases apart. | ||
| * | ||
| * A sip/sip pair cannot occur: an external callee requires a user caller, and an | ||
| * inbound INVITE requires a user callee. | ||
| */ | ||
| function getCallOrigin(caller: MediaCallContact, callee: MediaCallContact): MediaCallOrigin { | ||
| if (caller.type === 'sip') { | ||
| return 'sip-inbound'; | ||
| } | ||
|
|
||
| if (callee.type === 'sip') { | ||
| return 'sip-outbound'; | ||
| } | ||
|
|
||
| return 'internal'; | ||
| } | ||
|
|
||
| function toAppActor(actor: MediaCallActor | ServerActor): IAppsMediaCallActor { | ||
| return { | ||
| type: actor.type, | ||
| id: actor.id, | ||
| }; | ||
| } | ||
|
|
||
| function toAppMediaCall(call: IMediaCall): IAppsMediaCall { | ||
| return { | ||
| id: call._id, | ||
| service: call.service, | ||
| kind: call.kind, | ||
| state: call.state, | ||
| origin: getCallOrigin(call.caller, call.callee), | ||
| createdBy: toAppContact(call.createdBy), | ||
| createdAt: call.createdAt, | ||
| caller: toAppContact(call.caller), | ||
| callee: toAppContact(call.callee), | ||
| features: call.features, | ||
| uids: call.uids, | ||
| ended: call.ended, | ||
| ...(call.endedAt && { endedAt: call.endedAt }), | ||
| ...(call.endedBy && { endedBy: toAppActor(call.endedBy) }), | ||
| ...(call.hangupReason && { hangupReason: call.hangupReason }), | ||
| ...(call.acceptedAt && { acceptedAt: call.acceptedAt }), | ||
| ...(call.activatedAt && { activatedAt: call.activatedAt }), | ||
| ...(call.parentCallId && { parentCallId: call.parentCallId }), | ||
| ...(call.divertedBy && { divertedBy: toAppContact(call.divertedBy) }), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Each post event promises the apps one timestamp on the call it carries. The | ||
| * event is dispatched after the write that sets it, so the timestamp is there. | ||
| * A call that arrives without it cannot keep the promise, and an app that acts on | ||
| * a made-up time is worse off than an app that never hears about the call, so the | ||
| * event is dropped instead. | ||
| */ | ||
| function getEventTimestamp(call: IMediaCall, field: 'activatedAt' | 'acceptedAt' | 'endedAt'): Date | undefined { | ||
| if (!call[field]) { | ||
| logger.warn({ msg: 'Skipped a media call event for a call that carries no timestamp for it', callId: call._id, field }); | ||
| } | ||
|
|
||
| return call[field]; | ||
| } | ||
|
|
||
| function toAppActiveMediaCall(call: IMediaCall): IAppsActiveMediaCall | undefined { | ||
| const activatedAt = getEventTimestamp(call, 'activatedAt'); | ||
|
|
||
| return activatedAt && { ...toAppMediaCall(call), activatedAt }; | ||
| } | ||
|
|
||
| function toAppAcceptedMediaCall(call: IMediaCall): IAppsAcceptedMediaCall | undefined { | ||
| const acceptedAt = getEventTimestamp(call, 'acceptedAt'); | ||
|
|
||
| return acceptedAt && { ...toAppMediaCall(call), acceptedAt }; | ||
| } | ||
|
|
||
| function toAppEndedMediaCall(call: IMediaCall): IAppsEndedMediaCall | undefined { | ||
| const endedAt = getEventTimestamp(call, 'endedAt'); | ||
|
|
||
| return endedAt && { ...toAppMediaCall(call), ended: true, endedAt }; | ||
| } | ||
|
|
||
| /** `0` for a call that never became active, and never negative. */ | ||
| function getCallDurationInMs(activatedAt: Date | undefined, endedAt: Date): number { | ||
| if (!activatedAt) { | ||
| return 0; | ||
| } | ||
|
|
||
| return Math.max(0, endedAt.valueOf() - activatedAt.valueOf()); | ||
| } | ||
|
|
||
| function isCallFeature(feature: string): feature is CallFeature { | ||
| return (callFeatureList as readonly string[]).includes(feature); | ||
| } | ||
|
|
||
| async function triggerMediaCallEvent(event: MediaCallEvent): Promise<unknown> { | ||
| return Apps.self?.triggerEvent(AppEvents.IMediaCallHandler, event); | ||
| } | ||
|
|
||
| /** | ||
| * Every post event is reported from the call as it was when the event happened. The call is never | ||
| * read again on the way here: by then it may already have moved on, and an app that is told about | ||
| * an accepted call has to be told about the call that was accepted. A workspace with no apps | ||
| * skips the work. | ||
| */ | ||
| export async function notifyAppsOfMediaCallStarted(call: IMediaCall): Promise<void> { | ||
| if (!Apps.self) { | ||
| return; | ||
| } | ||
|
|
||
| const activeCall = toAppActiveMediaCall(call); | ||
| if (!activeCall) { | ||
| // `getEventTimestamp` already logged what the call is missing | ||
| return; | ||
| } | ||
|
|
||
| await triggerMediaCallEvent({ method: AppMethod.EXECUTE_POST_MEDIA_CALL_STARTED, context: { call: activeCall } }); | ||
| } | ||
|
|
||
| export async function notifyAppsOfMediaCallParticipantJoined(call: IMediaCall): Promise<void> { | ||
| if (!Apps.self) { | ||
| return; | ||
| } | ||
|
|
||
| // Calls are strictly two-party, so the side that joins is always `call.callee` | ||
| const acceptedCall = toAppAcceptedMediaCall(call); | ||
| if (!acceptedCall) { | ||
| return; | ||
| } | ||
|
|
||
| await triggerMediaCallEvent({ method: AppMethod.EXECUTE_POST_MEDIA_CALL_PARTICIPANT_JOINED, context: { call: acceptedCall } }); | ||
| } | ||
|
|
||
| export async function notifyAppsOfMediaCallEnded(call: IMediaCall): Promise<void> { | ||
| if (!Apps.self) { | ||
| return; | ||
| } | ||
|
|
||
| const endedCall = toAppEndedMediaCall(call); | ||
| if (!endedCall) { | ||
| return; | ||
| } | ||
|
|
||
| await triggerMediaCallEvent({ | ||
| method: AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED, | ||
| context: { | ||
| call: endedCall, | ||
| durationMs: getCallDurationInMs(call.activatedAt, endedCall.endedAt), | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| /** An app's explanation is shown in a toast, so it can't be allowed to be arbitrarily long. */ | ||
| const MAX_REJECTION_TEXT_LENGTH = 200; | ||
|
|
||
| /** | ||
| * Turns what an app said about a call it blocked into something the caller can | ||
| * be shown. An app's translations are registered on the client under a namespace | ||
| * of its own, so an `i18n` key is only resolvable together with the id of the app | ||
| * that produced it. | ||
| */ | ||
| function toRejectionMessage(outcome: PreMediaCallCreatedOutcome & { prevented: true }): CallRejectionMessage | undefined { | ||
| if (outcome.i18n) { | ||
| return { | ||
| type: 'i18n', | ||
| key: outcome.i18n.key, | ||
| ns: `app-${outcome.appId}`, | ||
| ...(outcome.i18n.args && { args: outcome.i18n.args }), | ||
| }; | ||
| } | ||
|
|
||
| if (outcome.reason) { | ||
| return { type: 'text', text: outcome.reason.slice(0, MAX_REJECTION_TEXT_LENGTH) }; | ||
| } | ||
|
|
||
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Runs the pre-media-call-created event and translates its outcome back into | ||
| * something the media call server understands. Apps may block the call or change | ||
| * the features it was requested with; anything else they try to patch is dropped | ||
| * by the listener manager. | ||
| */ | ||
| export async function runPreMediaCallCreatedAppHook(params: PreCallCreatedHookParams): Promise<PreCallCreatedHookResult> { | ||
| if (!Apps.self) { | ||
| return { prevented: false }; | ||
| } | ||
|
|
||
| const context: IPreMediaCallCreatedContext = { | ||
| caller: toAppContact(params.caller), | ||
| callee: toAppContact(params.callee), | ||
| createdBy: toAppContact(params.createdBy), | ||
| features: [...params.features], | ||
| origin: getCallOrigin(params.caller, params.callee), | ||
| ...(params.parentCallId && { parentCallId: params.parentCallId }), | ||
| ...(params.divertedBy && { divertedBy: toAppContact(params.divertedBy) }), | ||
| }; | ||
|
|
||
| const outcome = (await triggerMediaCallEvent({ | ||
| method: AppMethod.EXECUTE_PRE_MEDIA_CALL_CREATED, | ||
| context, | ||
| })) as PreMediaCallCreatedOutcome | undefined; | ||
|
|
||
| if (!outcome) { | ||
| return { prevented: false }; | ||
| } | ||
|
|
||
| if (outcome.prevented) { | ||
| logger.info({ | ||
| msg: 'An app prevented a media call from being created', | ||
| appId: outcome.appId, | ||
| reason: outcome.reason || outcome.i18n?.key, | ||
| }); | ||
|
|
||
| return { | ||
| prevented: true, | ||
| reason: outcome.reason || outcome.i18n?.key, | ||
| message: toRejectionMessage(outcome), | ||
| }; | ||
| } | ||
|
|
||
| // Apps are free to ask for features that don't exist; only the known ones move on | ||
| const features = outcome.context.features.filter(isCallFeature); | ||
|
|
||
| return { prevented: false, features }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.