Media Calls – Event Interfaces

Prev Next

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

executePreMediaCallCreated

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

executePostMediaCallParticipantJoined

When the callee accepts the call. Runs at most once per call. The participant who joined is always call.callee.

IMediaCallParticipantJoinedContext (call, with acceptedAt set)

executePostMediaCallStarted

When media starts flowing on the call.

IMediaCallStartedContext (call, with activatedAt set)

executePostMediaCallEnded

When a call ends for any reason, including calls that were missed, declined, transferred, expired, or failed.

IMediaCallEndedContext (call, with ended and endedAt set, and durationMs)

Each method receives the event context followed by the read, http, persistence, and modify accessors.

The call object 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

caller

The contact making the call.

callee

The contact receiving the call.

createdBy

Who requested the call. This is the caller, except on transfers.

features

The features requested for the call. Supported values are audio, screen-share, transfer, and hold.

origin

internal (between two workspace users), sip-outbound (a user calling an external number), or sip-inbound (an external number calling a user).

parentCallId

Set when the call replaces another call through a transfer.

divertedBy

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 features can be changed. Contacts and origin can't.

EventResult.prevent({ reason }) or EventResult.prevent({ i18n: { key, args } })

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 an i18n key, 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 true when

isAnsweredCall(context)

The callee accepted the call.

isRejectedCall(context)

The callee saw the call and declined it. A declined call is not a missed call.

isMissedCall(context)

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. Use isMissedCall instead.

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

none, ringing, accepted, active, or hangup.

origin

internal, sip-outbound, or sip-inbound.

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.

createdAt, acceptedAt, activatedAt, endedAt

When the call was created, accepted, started flowing media, and ended.

ended, endedBy, hangupReason

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.json replaces the default permissions, so list every permission your app uses.

Every method reads the history of one user, identified by uid.

Method

Description

getById(id, uid)

Returns one history entry by its ID, or undefined if it doesn't exist.

getByCallId(callId, uid)

Returns the user's history entry for a call, or undefined if it doesn't exist.

search(uid, filters?, pagination?)

Searches the user's history, newest first. Returns { items, total }, where total is the number of matching entries regardless of pagination.

The search method accepts the following options:

Option

Description

filters.searchTerm

Matches the contact's name, username, or extension.

filters.direction

inbound or outbound.

filters.inStates

An array of states to include: ended, not-answered, failed, error, transferred, or prevented.

pagination.count

How many entries to return. Defaults to 50, maximum 100.

pagination.offset

How many entries to skip.

pagination.sort

Sort order, for example { ts: -1 }.

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 includes contactId and, when available, contactName, contactUsername, rid (the direct message room), and messageId (the message sent after the call ended).

  • For calls with an external number (external: true), the entry includes contactExtension.

The history entry is saved after executePostMediaCallEnded runs. 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.