Media call events let your app react to voice calls in the workspace. Media calls are the one-to-one direct calls between two workspace users, or between a user and an external phone number through your PBX (SIP). They are not video conferences.
With these events, your app can observe calls starting, being answered, and ending. It can also block a call or change the features a call was requested with before the call is created. Apps with the media-call.history permission can also read a user's call history.
Media call events and the call history reader are available from Apps-Engine 1.67.0 (Rocket.Chat 8.9.0).
Implement the interface
Unlike other event interfaces, all media call events are grouped in a single interface, IMediaCallHandler. Implement it on your app's main class and add only the methods for the events you need. Every method is optional, and implementing a method subscribes your app to that event. For example, an app that only needs to know when calls end implements only executePostMediaCallEnded.
Method | When it runs | Context |
|---|---|---|
| Before the call is created. Routing and permission checks have already run, but the call is not saved yet and does not ring until your handler returns. The app can allow the call, change its features, or block it. | IPreMediaCallCreatedContext |
| When the callee accepts the call. Runs at most once per call. The participant who joined is always |
|
| When media starts flowing on the call. |
|
| When a call ends for any reason, including calls that were missed, declined, transferred, expired, or failed. |
|
Each method receives the event context followed by the read, http, persistence, and modify accessors.
The
callobject in each post-event is a snapshot taken when the event was emitted, not the call's current state.
Before a call is created
executePreMediaCallCreated receives the following context. There is no call ID yet because the call has not been saved.
Field | Description |
|---|---|
| The contact making the call. |
| The contact receiving the call. |
| Who requested the call. This is the caller, except on transfers. |
| The features requested for the call. Supported values are |
|
|
| Set when the call replaces another call through a transfer. |
| Set when the PBX forwarded the call. The party whose line diverted it. |
Each contact has a type (user or sip), an id, and, when available, username, displayName, and sipExtension.
The handler must return one of the following results, built with EventResult from @rocket.chat/apps-engine/definition/eventResult:
Result | Effect |
|---|---|
EventResult.pass() | Lets the call continue unchanged. |
EventResult.patch({ features }) | Changes the call's requested features. Only |
| Blocks the call. It never rings. |
Keep the following in mind:
A slow handler delays the call from ringing, so keep this handler fast.
Features the workspace doesn't allow are removed after your handler runs, and unknown feature values are dropped. Patching can't enable a feature that the workspace has disabled.
When an app blocks a call, the reason is stored with the call, and the call appears in call history with the state
prevented. If you pass ani18nkey, the workspace resolves it using your app's translations in the workspace language and stores that text, so the reason stays readable even after your app is uninstalled. Reasons are limited to 1,000 characters. If you don't provide a reason, a generic "prevented by app" message is shown.
The following example blocks calls to external numbers and removes screen sharing from all other calls:
import { App } from '@rocket.chat/apps-engine/definition/App';
import type { IAppAccessors, IHttp, ILogger, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { EventResult } from '@rocket.chat/apps-engine/definition/eventResult';
import type {
IMediaCallHandler,
IPreMediaCallCreatedContext,
MediaCallCreateEventResult,
} from '@rocket.chat/apps-engine/definition/mediaCalls';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata';
export class CallPolicyApp 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,
http: IHttp,
persistence: IPersistence,
modify: IModify,
): Promise<MediaCallCreateEventResult> {
if (context.origin === 'sip-outbound') {
return EventResult.prevent({ reason: 'External calls are not allowed in this workspace.' });
}
if (context.features.includes('screen-share')) {
return EventResult.patch({
features: context.features.filter((feature) => feature !== 'screen-share'),
});
}
return EventResult.pass();
}
}After a call ends
executePostMediaCallEnded runs for every call that ends, whatever the outcome. The context contains the call and durationMs, which is how long media was flowing, in milliseconds. It is 0 for calls that never started.
To tell outcomes apart, use the helper functions from @rocket.chat/apps-engine/definition/mediaCalls:
Helper | Returns |
|---|---|
| The callee accepted the call. |
| The callee saw the call and declined it. A declined call is not a missed call. |
| Nobody answered and the callee did not decline. This covers ring timeouts, unreachable callees, expired calls, and connection failures before the call was accepted. |
Don't detect missed calls by checking
call.hangupReason === 'not-answered'. That value is written only when the caller's client times out. A caller who closes the tab or loses connection produces a different reason (expired) for the same missed call. UseisMissedCallinstead.
Why the call ended is in call.hangupReason, who ended it is in call.endedBy, and when is in call.endedAt. Calls that no user ended, such as expired, failed, or transferred calls, report a server actor in call.endedBy. hangupReason is free-form text and new values can appear, so use isKnownMediaCallHangupReason() before handling reasons in an exhaustive switch.
import type { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors';
import { isAnsweredCall, isMissedCall, isRejectedCall } from '@rocket.chat/apps-engine/definition/mediaCalls';
import type { IMediaCallEndedContext } from '@rocket.chat/apps-engine/definition/mediaCalls';
import { AppMethod } from '@rocket.chat/apps-engine/definition/metadata';
// Inside your app class that implements IMediaCallHandler
public async [AppMethod.EXECUTE_POST_MEDIA_CALL_ENDED](
context: IMediaCallEndedContext,
read: IRead,
http: IHttp,
persistence: IPersistence,
modify: IModify,
): Promise<void> {
const caller = context.call.caller.displayName ?? context.call.caller.id;
if (isMissedCall(context)) {
this.getLogger().info(`Missed call from ${caller}`);
} else if (isRejectedCall(context)) {
this.getLogger().info(`Call from ${caller} was declined`);
} else if (isAnsweredCall(context)) {
this.getLogger().info(`Call from ${caller} lasted ${Math.round(context.durationMs / 1000)} seconds`);
}
}The call object
The post-events provide an IMediaCall object with the following main fields:
Field | Description |
|---|---|
id | The call ID. |
state |
|
origin |
|
caller, callee, createdBy | The contacts involved in the call. |
features | The features the call may use. These values are final once the call is accepted. |
uids | IDs of the workspace users on the call. External SIP endpoints are not listed. |
| When the call was created, accepted, started flowing media, and ended. |
| Whether the call ended, who ended it, and why. |
parentCallId | Set when the call replaced another call through a transfer. |
divertedBy | Set when the PBX forwarded the call. |
Read call history
Apps can read a user's call history through read.getCallHistoryReader(). This requires the media-call.history permission. This permission is not granted by default. Without it, every method returns undefined.
"permissions": [
{ "name": "media-call.history" }
]Declaring permissions in
app.jsonreplaces the default permissions, so list every permission your app uses.
Every method reads the history of one user, identified by uid.
Method | Description |
|---|---|
| Returns one history entry by its ID, or |
| Returns the user's history entry for a call, or |
| Searches the user's history, newest first. Returns |
The search method accepts the following options:
Option | Description |
|---|---|
| Matches the contact's name, username, or extension. |
|
|
| An array of states to include: |
| How many entries to return. Defaults to 50, maximum 100. |
| How many entries to skip. |
| Sort order, for example |
Each history entry includes id, uid, callId, ts, direction, state, duration (in seconds), endedAt, and external:
For calls with another workspace user (
external: false), the entry includescontactIdand, when available,contactName,contactUsername,rid(the direct message room), andmessageId(the message sent after the call ended).For calls with an external number (
external: true), the entry includescontactExtension.
The history entry is saved after
executePostMediaCallEndedruns. If you read the history inside that handler, the entry for the call that just ended may not exist yet. Read history later instead, for example from a slash command, an API endpoint, or a scheduled job.
const history = await read.getCallHistoryReader().search(
userId,
{ direction: 'inbound', inStates: ['not-answered'] },
{ count: 20 },
);
if (!history) {
// The app doesn't have the media-call.history permission
return;
}
for (const item of history.items) {
const contact = item.external ? item.contactExtension : item.contactName ?? item.contactUsername;
this.getLogger().info(`Missed call from ${contact} at ${item.ts.toISOString()}`);
}For the complete type definitions, refer to the mediaCalls and accessors folders in the Apps-Engine definitions.